// 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, isElectron as detectIsElectron } 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 [initialWebAuth] = useState(() => { if (detectIsElectron()) { return { retrieved: false } } try { if (validateAuthCookies()) { const session = getAuthCookies() return { retrieved: true, token: session.token, expiresAt: session.expiresAt, user: session.user, authenticated: Boolean(session.token) } } } catch (error) { console.error('Error reading initial auth session:', error) } return { retrieved: true, token: null, expiresAt: null, user: null, authenticated: false } }) const [authenticated, setAuthenticated] = useState( Boolean(initialWebAuth.authenticated) ) const [initialized, setInitialized] = useState(false) const [retreivedTokenFromCookies, setRetreivedTokenFromCookies] = useState( Boolean(initialWebAuth.retrieved) ) const [loading, setLoading] = useState(false) const [token, setToken] = useState(initialWebAuth.token ?? null) const [expiresAt, setExpiresAt] = useState(initialWebAuth.expiresAt ?? null) const [userProfile, setUserProfile] = useState(initialWebAuth.user ?? null) const [profileImageUrl, setProfileImageUrl] = useState(null) const profileImageUrlRef = useRef(null) const processedAuthCodeRef = 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() const sessionRef = useRef({ token, expiresAt, userProfile, authenticated }) sessionRef.current = { token, expiresAt, userProfile, authenticated } 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( 'Browser storage is disabled. Login state may not persist between tabs.' ) } }, [messageApi, isElectron]) // Read token from cookies (web) or electron session storage if present useEffect(() => { let cancelled = false const authCode = new URLSearchParams(location.search).get('authCode') || null 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 if (validateAuthCookies()) { const { token: storedToken, expiresAt: storedExpiresAt, user: storedUser } = getAuthCookies() if (!cancelled) { setToken(storedToken) setUserProfile(storedUser) setExpiresAt(storedExpiresAt) setAuthenticated(true) if (storedToken) { getUserInfo(storedToken, () => cancelled) } } } else if (!cancelled && !sessionRef.current.token) { setAuthenticated(false) setUserProfile(null) setShowUnauthorizedModal(true) } } catch (error) { console.error('Error loading persisted auth session:', error) if (!cancelled && !sessionRef.current.token) { setAuthenticated(false) setUserProfile(null) setShowUnauthorizedModal(true) } } finally { if (!cancelled) setRetreivedTokenFromCookies(true) } } // An auth code represents a newly completed login and must take precedence // over any existing persisted session. if (location.pathname === '/applaunch' || authCode != null) { 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 }, []) // Sync auth from other tabs. Only treat a missing token as logout — never // drop a valid in-memory session because another tab is still booting. useEffect(() => { if (isElectron) return const cleanupCookieSync = setupCookieSync(() => { try { if (validateAuthCookies()) { const { token: newToken, expiresAt: newExpiresAt, user: newUser } = getAuthCookies() const current = sessionRef.current const expiresChanged = String(newExpiresAt ?? '') !== String(current.expiresAt ?? '') const userChanged = newUser != null && JSON.stringify(newUser) !== JSON.stringify(current.userProfile) if ( newToken !== current.token || expiresChanged || userChanged ) { setToken(newToken) setExpiresAt(newExpiresAt) if (newUser) setUserProfile(newUser) setAuthenticated(true) setShowUnauthorizedModal(false) logger.debug('Auth state synchronized from another tab') } } else { const { token: storedToken } = getAuthCookies() if (!storedToken && sessionRef.current.token) { setToken(null) setExpiresAt(null) setUserProfile(null) setAuthenticated(false) setShowUnauthorizedModal(true) logger.debug('Auth state cleared due to logout in another tab') } } } catch (error) { console.error('Error syncing auth state:', error) } }) return cleanupCookieSync }, [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) 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, token]) 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) setShowUnauthorizedModal(false) // Persist session (cookies on web, electron storage on desktop) 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) setRetreivedTokenFromCookies(true) } }, [ 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 = useCallback(() => { const hadSession = Boolean(sessionRef.current.token) setToken(null) setExpiresAt(null) setUserProfile(null) setAuthenticated(false) if (hadSession) { clearPersistedSession() } if (showSessionExpiredModal == false) { setShowUnauthorizedModal(true) } }, [clearPersistedSession, showSessionExpiredModal]) 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: (
Your session will expire in {seconds} seconds
), duration: 0, key: 'token-expiration', icon: null, placement: 'bottomRight', style: { width: 360 }, className: 'token-expiration-notification', closeIcon: null, onClose: () => {}, btn: ( ) }) } 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: (
Your session will expire in {seconds} seconds
), duration: 0, key: 'token-expiration', icon: null, placement: 'bottomRight', style: { width: 360 }, className: 'token-expiration-notification', closeIcon: null, onClose: () => {}, btn: ( ) }) } } } 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 && processedAuthCodeRef.current !== authCode ) { processedAuthCodeRef.current = authCode 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} {children} Session Expired } open={showSessionExpiredModal && !loading && !showAuthErrorModal} onOk={handleSessionExpiredModalOk} okText='Log In' style={{ maxWidth: 430 }} closable={false} centered maskClosable={false} footer={[ ]} > Your session has expired. Please log in again to continue. Please log in to continue } open={ showUnauthorizedModal && !authenticated && !loading && !showAuthErrorModal } onOk={() => { setShowUnauthorizedModal(false) loginWithSSO() }} okText='Log In' style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }} closable={false} maskClosable={false} footer={[ ]} > You need to be logged in to access FarmControl. Please log in with tombutcher.work to continue. Loading, please wait... Authentication Error } open={showAuthErrorModal && !loading} style={{ maxWidth: 430 }} closable={false} centered maskClosable={false} footer={[ ]} > {authError} ) } AuthProvider.propTypes = { children: PropTypes.node.isRequired } export { AuthContext, AuthProvider }