// 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: (