// src/contexts/AuthContext.js import { createContext, useState, useCallback, useEffect, useContext } 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 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 [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false) const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false) const [showAuthErrorModal, setShowAuthErrorModal] = useState(false) const [authError, setAuthError] = useState(null) const { openExternalUrl, isElectron } = 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' } // Check if cookies are enabled and show warning if not useEffect(() => { if (!areCookiesEnabled()) { messageApi.warning( 'Cookies are disabled. Login state may not persist between tabs.' ) } }, [messageApi]) // Read token from cookies if present useEffect(() => { try { console.log( 'Retreiving token from cookies...', getAuthCookies(), validateAuthCookies() ) // First validate existing cookies to clean up expired ones if (validateAuthCookies()) { const { token: storedToken, expiresAt: storedExpiresAt, user: storedUser } = getAuthCookies() console.log('Retrieved from cookies:', { storedUser, storedToken, storedExpiresAt }) setToken(storedToken) setUserProfile(storedUser) setExpiresAt(storedExpiresAt) setAuthenticated(true) } else { setAuthenticated(false) setUserProfile(null) setShowUnauthorizedModal(true) } } catch (error) { console.error('Error reading auth cookies:', error) clearAuthCookies() setAuthenticated(false) setUserProfile(null) setShowUnauthorizedModal(true) } setRetreivedTokenFromCookies(true) }, []) // Set up cookie synchronization between tabs useEffect(() => { 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) console.log('Auth state synchronized from another tab') } } else { // Cookies are invalid, clear state setToken(null) setExpiresAt(null) setUserProfile(null) setAuthenticated(false) setShowUnauthorizedModal(true) console.log( 'Auth state cleared due to invalid cookies from another tab' ) } } catch (error) { console.error('Error syncing auth state:', error) } }) return cleanupCookieSync }, [token, expiresAt, userProfile]) const logout = useCallback((redirectUri = '/login') => { setAuthenticated(false) setToken(null) setExpiresAt(null) setUserProfile(null) clearAuthCookies() window.location.href = `${config.backendUrl}/auth/logout?redirect_uri=${encodeURIComponent(redirectUri)}` }, []) // 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) { console.log('Opening external url...') openExternalUrl(loginUrl) setLoading(true) } else { console.log('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 setToken(authData.access_token) setExpiresAt(authData.expires_at) setUserProfile(authData) // Store in cookies for persistence between tabs const cookieSuccess = setAuthCookies({ user: authData, access_token: authData.access_token, expires_at: authData.expires_at }) if (!cookieSuccess) { messageApi.warning( 'Authentication successful but failed to save login state. You may need to log in again if you close this tab.' ) } 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) } }, [isElectron, navigate, location.search, location.pathname, messageApi] ) // 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 setToken(authData.access_token) setExpiresAt(authData.expires_at) setUserProfile(authData) // Update cookies with fresh data const cookieSuccess = setAuthCookies(authData) if (!cookieSuccess) { 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]) const setUnauthenticated = () => { setToken(null) setExpiresAt(null) setUserProfile(null) clearAuthCookies() 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 setToken(authData.access_token) setExpiresAt(authData.expires_at) // Update cookies with fresh token data const cookieSuccess = setAuthCookies(authData) if (!cookieSuccess) { 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]) 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 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]) 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) console.log('Showing unauth', token, authCode) 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 && !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 }