2025-11-29 01:25:24 +00:00

635 lines
18 KiB
JavaScript

// 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: (
<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
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])
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}
<AuthContext.Provider
value={{
authenticated,
setUnauthenticated,
loginWithSSO,
getLoginToken,
token,
loading,
userProfile,
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 }