Tom Butcher 49dca65470
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Implemented session status.
2026-06-20 00:33:46 +01:00

880 lines
25 KiB
JavaScript

// src/contexts/AuthContext.js
import {
createContext,
useState,
useCallback,
useEffect,
useContext,
useRef
} from 'react'
import axios from 'axios'
import {
message,
Modal,
notification,
Progress,
Button,
Space,
Typography
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
import ExclamationOctogonIcon from '../../Icons/ExclamationOctagonIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import config from '../../../config'
import loglevel from 'loglevel'
import { ElectronContext } from './ElectronContext'
import { useLocation, useNavigate } from 'react-router-dom'
import {
getAuthCookies,
setAuthCookies,
clearAuthCookies,
areCookiesEnabled,
validateAuthCookies,
setupCookieSync,
checkAuthCookiesExpiry
} from '../../../utils/cookies'
const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel)
const AuthContext = createContext()
const { Text } = Typography
const extractUserFromAuthData = (authData) => {
if (!authData || typeof authData !== 'object') return null
if (authData.user && typeof authData.user === 'object') return authData.user
// Some endpoints may return the "user" fields at the top-level. Only treat it
// as a user object if it looks like one (avoid confusing token-only payloads).
const looksLikeUser =
authData._id || authData.username || authData.email || authData.name
if (looksLikeUser) return authData
return null
}
const AuthProvider = ({ children }) => {
const [messageApi, contextHolder] = message.useMessage()
const [notificationApi, notificationContextHolder] =
notification.useNotification()
const [authenticated, setAuthenticated] = useState(false)
const [initialized, setInitialized] = useState(false)
const [retreivedTokenFromCookies, setRetreivedTokenFromCookies] =
useState(false)
const [loading, setLoading] = useState(false)
const [token, setToken] = useState(null)
const [expiresAt, setExpiresAt] = useState(null)
const [userProfile, setUserProfile] = useState(null)
const [profileImageUrl, setProfileImageUrl] = useState(null)
const profileImageUrlRef = useRef(null)
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false)
const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false)
const [showAuthErrorModal, setShowAuthErrorModal] = useState(false)
const [authError, setAuthError] = useState(null)
const {
openExternalUrl,
isElectron,
getAuthSession,
setAuthSession,
clearAuthSession
} = useContext(ElectronContext)
const location = useLocation()
const navigate = useNavigate()
var redirectType = 'web'
if (isElectron == true && import.meta.env.MODE == 'development') {
redirectType = 'app-localhost'
}
if (isElectron == true && import.meta.env.MODE != 'development') {
redirectType = 'app-scheme'
}
const isSessionExpired = (session) => {
if (!session?.expiresAt) return true
const now = new Date()
const expirationDate = new Date(session.expiresAt)
return expirationDate <= now
}
const getUserInfo = useCallback(
async (token, getIsCancelled = () => false) => {
try {
const response = await axios.get(`${config.backendUrl}/auth/user`, {
headers: { Authorization: `Bearer ${token}` }
})
if (!getIsCancelled() && response.status === 200 && response.data) {
const nextUser = extractUserFromAuthData(response.data)
if (nextUser) setUserProfile(nextUser)
}
} catch (err) {
if (!getIsCancelled()) {
logger.debug('Failed to refresh user from API:', err)
if (err.response?.status === 401) {
setAuthenticated(false)
setToken(null)
setUserProfile(null)
setExpiresAt(null)
setShowUnauthorizedModal(true)
}
}
}
},
[]
)
const persistSession = useCallback(
async ({ token: nextToken, expiresAt: nextExpiresAt, user: nextUser }) => {
if (isElectron) {
return await setAuthSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
}
return setAuthCookies({
access_token: nextToken,
expires_at: nextExpiresAt,
user: nextUser
})
},
[isElectron, setAuthSession]
)
const clearPersistedSession = useCallback(async () => {
if (isElectron) {
return await clearAuthSession()
}
clearAuthCookies()
return true
}, [isElectron, clearAuthSession])
// Check if cookies are enabled and show warning if not (web only)
useEffect(() => {
if (isElectron) return
if (!areCookiesEnabled()) {
messageApi.warning(
'Cookies are disabled. Login state may not persist between tabs.'
)
}
}, [messageApi, isElectron])
// Read token from cookies (web) or keytar (electron) if present
useEffect(() => {
let cancelled = false
const load = async () => {
try {
if (isElectron) {
const session = await getAuthSession()
if (
!cancelled &&
session &&
session.token &&
!isSessionExpired(session)
) {
setToken(session.token)
setUserProfile(session.user)
setExpiresAt(session.expiresAt)
setAuthenticated(true)
if (session.user) {
getUserInfo(session.token, () => cancelled)
}
} else if (!cancelled) {
setAuthenticated(false)
setUserProfile(null)
setShowUnauthorizedModal(true)
}
} else {
// First validate existing cookies to clean up expired ones
if (validateAuthCookies()) {
const {
token: storedToken,
expiresAt: storedExpiresAt,
user: storedUser
} = getAuthCookies()
if (!cancelled) {
setToken(storedToken)
setUserProfile(storedUser)
setExpiresAt(storedExpiresAt)
setAuthenticated(true)
if (storedToken && storedUser) {
getUserInfo(storedToken, () => cancelled)
}
}
} else if (!cancelled) {
setAuthenticated(false)
setUserProfile(null)
setShowUnauthorizedModal(true)
}
}
} catch (error) {
console.error('Error loading persisted auth session:', error)
await clearPersistedSession()
if (!cancelled) {
setAuthenticated(false)
setUserProfile(null)
setShowUnauthorizedModal(true)
}
} finally {
if (!cancelled) setRetreivedTokenFromCookies(true)
}
}
if (location.pathname === '/applaunch') {
return
}
load()
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- run only on mount to load persisted session; deps are stable in behavior
}, [])
// Set up cookie synchronization between tabs
useEffect(() => {
if (isElectron) return
const cleanupCookieSync = setupCookieSync(() => {
// When cookies change in another tab, re-validate and update state
try {
if (validateAuthCookies()) {
const {
token: newToken,
expiresAt: newExpiresAt,
user: newUser
} = getAuthCookies()
if (
newToken !== token ||
newExpiresAt !== expiresAt ||
JSON.stringify(newUser) !== JSON.stringify(userProfile)
) {
setToken(newToken)
setExpiresAt(newExpiresAt)
setUserProfile(newUser)
setAuthenticated(true)
logger.debug('Auth state synchronized from another tab')
}
} else {
// Cookies are invalid, clear state
setToken(null)
setExpiresAt(null)
setUserProfile(null)
setAuthenticated(false)
setShowUnauthorizedModal(true)
logger.debug(
'Auth state cleared due to invalid cookies from another tab'
)
}
} catch (error) {
console.error('Error syncing auth state:', error)
}
})
return cleanupCookieSync
}, [token, expiresAt, userProfile, isElectron])
// Persist userProfile changes to cookies/electron storage so updates (e.g. from
// WebSocket or profile edits) are saved for session restoration
useEffect(() => {
if (!authenticated || !token || !expiresAt) return
persistSession({
token,
expiresAt,
user: userProfile
})
}, [authenticated, token, expiresAt, userProfile, persistSession])
// Fetch and cache profile image when userProfile.profileImage changes
useEffect(() => {
const profileImage = userProfile?.profileImage
const profileImageId =
profileImage?._id ??
(typeof profileImage === 'string' ? profileImage : null)
console.log('Fetching profile image:', profileImageId)
if (!token) {
if (profileImageUrlRef.current) {
URL.revokeObjectURL(profileImageUrlRef.current)
profileImageUrlRef.current = null
}
setProfileImageUrl(null)
return
}
if (!profileImageId) {
if (profileImageUrlRef.current) {
URL.revokeObjectURL(profileImageUrlRef.current)
profileImageUrlRef.current = null
}
setProfileImageUrl(null)
return
}
let cancelled = false
const file =
typeof profileImage === 'object' && profileImage !== null
? profileImage
: { _id: profileImageId, name: '', extension: '' }
const fetchProfileImage = async () => {
try {
const response = await axios.get(
`${config.backendUrl}/files/${file._id}/content`,
{
headers: {
Accept: '*/*',
Authorization: `Bearer ${token}`
},
responseType: 'blob'
}
)
const blob = new Blob([response.data], {
type: response.headers['content-type']
})
const fileURL = window.URL.createObjectURL(blob)
if (!cancelled) {
if (profileImageUrlRef.current) {
URL.revokeObjectURL(profileImageUrlRef.current)
}
profileImageUrlRef.current = fileURL
setProfileImageUrl(fileURL)
} else {
URL.revokeObjectURL(fileURL)
}
} catch (err) {
logger.debug('Failed to fetch profile image:', err)
if (!cancelled) {
setProfileImageUrl(null)
}
}
}
fetchProfileImage()
return () => {
cancelled = true
if (profileImageUrlRef.current) {
URL.revokeObjectURL(profileImageUrlRef.current)
profileImageUrlRef.current = null
}
setProfileImageUrl(null)
}
}, [userProfile?.profileImage?._id ?? userProfile?.profileImage, token])
useEffect(() => {
console.log('userProfile', userProfile)
}, [userProfile])
const logout = useCallback(
(redirectUri = '/login') => {
setAuthenticated(false)
setToken(null)
setExpiresAt(null)
setUserProfile(null)
clearPersistedSession()
window.location.href = `${config.backendUrl}/auth/logout?redirect_uri=${encodeURIComponent(redirectUri)}`
},
[clearPersistedSession]
)
// Login using query parameters
const loginWithSSO = useCallback(
(redirectUri = location.pathname + location.search) => {
messageApi.info('Logging in with tombutcher.work')
const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(redirectUri)}`
if (isElectron) {
logger.debug('Opening external url...')
openExternalUrl(loginUrl)
setLoading(true)
} else {
logger.debug('Redirecting...')
window.location.href = loginUrl
}
},
[
redirectType,
messageApi,
openExternalUrl,
isElectron,
location.search,
location.pathname
]
)
const getLoginToken = useCallback(
async (code) => {
setLoading(true)
setShowUnauthorizedModal(false)
setShowSessionExpiredModal(false)
setAuthError(null)
try {
// Make a call to your backend to check auth status
const response = await axios.get(
`${config.backendUrl}/auth/${redirectType}/token?code=${code}`
)
if (response.status === 200 && response.data) {
logger.debug('Got auth token!')
const authData = response.data
const nextToken = authData.access_token
const nextExpiresAt = authData.expires_at
const nextUser = extractUserFromAuthData(authData)
setToken(nextToken)
setExpiresAt(nextExpiresAt)
setUserProfile(nextUser)
setAuthenticated(true)
// Persist session (cookies on web, keytar on electron)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
if (!persisted) {
messageApi.warning(
'Authentication successful but failed to save login state. You may need to log in again when you restart the app.'
)
}
const searchParams = new URLSearchParams(location.search)
searchParams.delete('authCode')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
} else {
setAuthenticated(false)
setAuthError('Failed to authenticate user.')
setShowAuthErrorModal(true)
}
} catch (error) {
logger.debug('Auth check failed', error)
if (error.response?.status === 401) {
setShowUnauthorizedModal(true)
} else {
const errorMessage =
error?.response?.data?.error ||
'Error connecting to authentication service.'
const fullStop = errorMessage.endsWith('.')
setAuthError(`${errorMessage}${!fullStop && '.'}`)
setShowAuthErrorModal(true)
}
setAuthenticated(false)
} finally {
setLoading(false)
}
},
[
navigate,
location.search,
location.pathname,
messageApi,
persistSession,
redirectType
]
)
// Function to check if the user is logged in
const checkAuthStatus = useCallback(async () => {
setLoading(true)
setAuthError(null)
try {
// Make a call to your backend to check auth status
const response = await axios.get(`${config.backendUrl}/auth/user`, {
headers: {
Authorization: `Bearer ${token}`
}
})
if (response.status === 200 && response.data) {
logger.debug('Got auth token!')
const authData = response.data
const nextToken = authData.access_token
const nextExpiresAt = authData.expires_at
const nextUser = extractUserFromAuthData(authData)
setToken(nextToken)
setExpiresAt(nextExpiresAt)
setUserProfile(nextUser)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
if (!persisted) {
messageApi.warning(
'Failed to update login state. You may need to log in again if you close this tab.'
)
}
} else {
setAuthenticated(false)
setAuthError('Failed to authenticate user.')
setShowAuthErrorModal(true)
}
} catch (error) {
logger.debug('Auth check failed', error)
if (error.response?.status === 401) {
setShowUnauthorizedModal(true)
} else {
setAuthError('Error connecting to authentication service.')
setShowAuthErrorModal(true)
}
setAuthenticated(false)
} finally {
setLoading(false)
}
}, [token, messageApi, persistSession])
const setUnauthenticated = () => {
setToken(null)
setExpiresAt(null)
setUserProfile(null)
clearPersistedSession()
setAuthenticated(false)
if (showSessionExpiredModal == false) {
setShowUnauthorizedModal(true)
}
}
const refreshToken = useCallback(async () => {
try {
const response = await axios.get(`${config.backendUrl}/auth/refresh`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
})
if (response.status === 200 && response.data) {
const authData = response.data
const nextToken = authData.access_token
const nextExpiresAt = authData.expires_at
const nextUser = extractUserFromAuthData(authData) || userProfile
setToken(nextToken)
setExpiresAt(nextExpiresAt)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
if (!persisted) {
messageApi.warning(
'Failed to update login state. You may need to log in again if you close this tab.'
)
}
}
} catch (error) {
console.error('Token refresh failed', error)
}
}, [token, messageApi, persistSession, userProfile])
const handleSessionExpiredModalOk = () => {
setShowSessionExpiredModal(false)
loginWithSSO()
}
// Initialize on component mount
useEffect(() => {
let intervalId
const tokenRefresh = () => {
if (expiresAt) {
const now = new Date()
const expirationDate = new Date(expiresAt)
const timeRemaining = expirationDate - now
if (timeRemaining <= 0) {
if (authenticated == true) {
setAuthenticated(false)
}
setShowSessionExpiredModal(true)
notificationApi.destroy('token-expiration')
} else {
if (authenticated == false) {
setAuthenticated(true)
}
const minutes = Math.floor(timeRemaining / 60000)
const seconds = Math.floor((timeRemaining % 60000) / 1000)
// Only show notification in the final minute
if (minutes === 0) {
const totalSeconds = 60
const remainingSeconds = totalSeconds - seconds
const progress = (remainingSeconds / totalSeconds) * 100
notificationApi.info({
message: 'Session Expiring Soon',
description: (
<div>
<div style={{ marginBottom: 8 }}>
Your session will expire in {seconds} seconds
</div>
<Progress
percent={progress}
size='small'
status='active'
showInfo={false}
/>
</div>
),
duration: 0,
key: 'token-expiration',
icon: null,
placement: 'bottomRight',
style: {
width: 360
},
className: 'token-expiration-notification',
closeIcon: null,
onClose: () => {},
btn: (
<Button
type='primary'
size='small'
onClick={() => {
notificationApi.destroy('token-expiration')
refreshToken()
}}
>
Reload Session
</Button>
)
})
} else if (minutes === 1) {
// Clear any existing notification when we enter the final minute
notificationApi.destroy('token-expiration')
}
}
} else {
// Check cookies directly if expiresAt is not set in state (web only)
if (isElectron) return
const expiryInfo = checkAuthCookiesExpiry(5) // Check if expiring within 5 minutes
if (expiryInfo.isExpiringSoon && expiryInfo.minutesRemaining <= 1) {
// Show notification for cookies expiring soon
const seconds = Math.floor((expiryInfo.timeRemaining % 60000) / 1000)
const totalSeconds = 60
const remainingSeconds = totalSeconds - seconds
const progress = (remainingSeconds / totalSeconds) * 100
notificationApi.info({
message: 'Session Expiring Soon',
description: (
<div>
<div style={{ marginBottom: 8 }}>
Your session will expire in {seconds} seconds
</div>
<Progress
percent={progress}
size='small'
status='active'
showInfo={false}
/>
</div>
),
duration: 0,
key: 'token-expiration',
icon: null,
placement: 'bottomRight',
style: {
width: 360
},
className: 'token-expiration-notification',
closeIcon: null,
onClose: () => {},
btn: (
<Button
type='primary'
size='small'
onClick={() => {
notificationApi.destroy('token-expiration')
refreshToken()
}}
>
Reload Session
</Button>
)
})
}
}
}
intervalId = setInterval(tokenRefresh, 1000)
tokenRefresh()
return () => {
if (intervalId) {
clearInterval(intervalId)
}
}
}, [expiresAt, authenticated, notificationApi, refreshToken, isElectron])
useEffect(() => {
const authCode =
new URLSearchParams(location.search).get('authCode') || null
if (authCode != null) {
getLoginToken(authCode)
} else if (
token == null &&
retreivedTokenFromCookies == true &&
initialized == false &&
authCode == null
) {
setInitialized(true)
setShowUnauthorizedModal(true)
setAuthenticated(false)
}
}, [
checkAuthStatus,
location.search,
getLoginToken,
initialized,
location.pathname,
navigate,
token,
retreivedTokenFromCookies
])
return (
<>
{contextHolder}
{notificationContextHolder}
<AuthContext.Provider
value={{
authenticated,
authInitialized: retreivedTokenFromCookies,
setUnauthenticated,
loginWithSSO,
getLoginToken,
token,
loading,
userProfile,
setUserProfile,
profileImageUrl,
logout
}}
>
{children}
</AuthContext.Provider>
<Modal
title={
<Space size={'middle'}>
<InfoCircleIcon />
Session Expired
</Space>
}
open={showSessionExpiredModal && !loading && !showAuthErrorModal}
onOk={handleSessionExpiredModalOk}
okText='Log In'
style={{ maxWidth: 430 }}
closable={false}
centered
maskClosable={false}
footer={[
<Button
key='submit'
type='primary'
onClick={handleSessionExpiredModalOk}
>
Log In
</Button>
]}
>
<Text>Your session has expired. Please log in again to continue.</Text>
</Modal>
<Modal
title={
<Space size={'middle'}>
<ExclamationOctogonIcon />
Please log in to continue
</Space>
}
open={showUnauthorizedModal && !loading && !showAuthErrorModal}
onOk={() => {
setShowUnauthorizedModal(false)
loginWithSSO()
}}
okText='Log In'
style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }}
closable={false}
maskClosable={false}
footer={[
<Button
key='submit'
type='primary'
onClick={() => {
setShowUnauthorizedModal(false)
loginWithSSO()
}}
>
Log In
</Button>
]}
>
<Text>
You need to be logged in to access FarmControl. Please log in with
tombutcher.work to continue.
</Text>
</Modal>
<Modal
open={loading}
className={'loading-modal'}
title={false}
height={20}
style={{ maxWidth: 220, top: '50%', transform: 'translateY(-50%)' }}
closable={false}
maskClosable={false}
footer={false}
>
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Loading, please wait...</Text>
</Space>
</Modal>
<Modal
title={
<Space size={'middle'}>
<ExclamationOctogonIcon />
Authentication Error
</Space>
}
open={showAuthErrorModal && !loading}
style={{ maxWidth: 430 }}
closable={false}
centered
maskClosable={false}
footer={[
<Button
key='submit'
onClick={() => {
setShowAuthErrorModal(false)
loginWithSSO()
}}
>
Retry Login
</Button>
]}
>
<Text>{authError}</Text>
</Modal>
</>
)
}
AuthProvider.propTypes = {
children: PropTypes.node.isRequired
}
export { AuthContext, AuthProvider }