diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx index c0e3d5d1..d5638695 100644 --- a/src/components/Dashboard/context/ApiServerContext.jsx +++ b/src/components/Dashboard/context/ApiServerContext.jsx @@ -717,12 +717,17 @@ const ApiServerProvider = ({ children }) => { const connectToServer = useCallback(() => { if (token && authenticated == true) { - if (socketRef.current) { + if (socketRef.current?.authToken === token) { if (!socketRef.current.connected) { socketRef.current.connect() } return } + if (socketRef.current) { + const previousSocket = socketRef.current + socketRef.current = null + previousSocket.disconnect() + } logger.debug('Token is available, connecting to api server...') @@ -738,6 +743,7 @@ const ApiServerProvider = ({ children }) => { if (typeof newSocket.io?.reconnection === 'function') { newSocket.io.reconnection(false) } + newSocket.authToken = token setConnecting(true) diff --git a/src/components/Dashboard/context/AuthContext.jsx b/src/components/Dashboard/context/AuthContext.jsx index 639c9059..c92a4eb3 100644 --- a/src/components/Dashboard/context/AuthContext.jsx +++ b/src/components/Dashboard/context/AuthContext.jsx @@ -30,6 +30,9 @@ import { getAuthCookies, setAuthCookies, clearAuthCookies, + getSlicerAuthSession, + setSlicerAuthSession, + clearSlicerAuthSession, areCookiesEnabled, validateAuthCookies, setupCookieSync, @@ -68,6 +71,30 @@ const AuthProvider = ({ children }) => { const [notificationApi, notificationContextHolder] = notification.useNotification() const [initialWebAuth] = useState(() => { + // The slicer opens through a one-time app-password link. Never reuse a + // saved FarmControl login, which expires and replaces that session. + if (window.location.pathname === '/slicer') { + const hasAuthCode = new URLSearchParams(window.location.search).has( + 'authCode' + ) + const slicerSession = hasAuthCode ? null : getSlicerAuthSession() + if (slicerSession?.token) { + return { + retrieved: true, + token: slicerSession.token, + expiresAt: slicerSession.expiresAt, + user: slicerSession.user, + authenticated: true + } + } + return { + retrieved: true, + token: null, + expiresAt: null, + user: null, + authenticated: false + } + } if (detectIsElectron()) { return { retrieved: false } } @@ -134,9 +161,13 @@ const AuthProvider = ({ children }) => { }) sessionRef.current = { token, expiresAt, userProfile, authenticated } const isDesktopHelperRoute = location.pathname.startsWith('/desktop') + const isSlicerRoute = location.pathname === '/slicer' const slicerAppKey = getSlicerAppKey(location) const suppressUnauthorizedModal = - isDesktopHelperRoute || Boolean(slicerAppKey) || slicerAuthPending + isDesktopHelperRoute || + isSlicerRoute || + Boolean(slicerAppKey) || + slicerAuthPending var redirectType = 'web' @@ -183,6 +214,13 @@ const AuthProvider = ({ children }) => { const persistSession = useCallback( async ({ token: nextToken, expiresAt: nextExpiresAt, user: nextUser }) => { + if (locationRef.current?.pathname === '/slicer') { + return setSlicerAuthSession({ + access_token: nextToken, + expires_at: nextExpiresAt, + user: nextUser + }) + } if (isElectron) { return await setAuthSession({ token: nextToken, @@ -200,6 +238,9 @@ const AuthProvider = ({ children }) => { ) const clearPersistedSession = useCallback(async () => { + if (locationRef.current?.pathname === '/slicer') { + return clearSlicerAuthSession() + } if (isElectron) { return await clearAuthSession() } @@ -283,13 +324,19 @@ const AuthProvider = ({ children }) => { // An auth code represents a newly completed login and must take precedence // over any existing persisted session. if ( + location.pathname === '/slicer' || location.pathname === '/applaunch' || location.pathname.startsWith('/desktop') || authCode != null ) { - if (location.pathname.startsWith('/desktop')) { + if ( + location.pathname === '/slicer' || + location.pathname.startsWith('/desktop') + ) { setRetreivedTokenFromCookies(true) - setInitialized(true) + if (location.pathname.startsWith('/desktop')) { + setInitialized(true) + } } return } @@ -306,6 +353,7 @@ const AuthProvider = ({ children }) => { useEffect(() => { if (isElectron) return const cleanupCookieSync = setupCookieSync(() => { + if (locationRef.current?.pathname === '/slicer') return try { if (validateAuthCookies()) { const { @@ -658,6 +706,10 @@ const AuthProvider = ({ children }) => { let intervalId const tokenRefresh = () => { + if (locationRef.current?.pathname === '/slicer') { + notificationApi.destroy('token-expiration') + return + } if (expiresAt) { const now = new Date() const expirationDate = new Date(expiresAt) @@ -878,6 +930,7 @@ const AuthProvider = ({ children }) => { open={ showSessionExpiredModal && !isDesktopHelperRoute && + !isSlicerRoute && !loading && !showAuthErrorModal } diff --git a/src/utils/cookies.js b/src/utils/cookies.js index 1e63f2d1..8efa1f33 100644 --- a/src/utils/cookies.js +++ b/src/utils/cookies.js @@ -7,6 +7,7 @@ const COOKIE_OPTIONS = { } const AUTH_STORAGE_KEY = 'farmcontrol.auth' +const SLICER_AUTH_STORAGE_KEY = 'farmcontrol.slicerAuth' const LEGACY_AUTH_COOKIE_NAMES = ['authToken', 'authExpiresAt', 'user'] const getAuthExpiryTime = (expiresAt) => { @@ -325,3 +326,63 @@ export const clearAuthCookies = () => { } clearLegacyAuthCookies() } + +const readStoredSession = (storageKey) => { + const raw = window.localStorage.getItem(storageKey) + if (!raw) return null + const session = parseStoredSession(raw) + const expirationTime = getAuthExpiryTime(session.expiresAt) + if ( + !session.token || + !Number.isFinite(expirationTime) || + expirationTime <= Date.now() + ) { + window.localStorage.removeItem(storageKey) + return null + } + return session +} + +/** + * Slicer app-password sessions are stored apart from the normal login so a + * short-lived FarmControl session cannot replace or expire them. + */ +export const getSlicerAuthSession = () => { + try { + return readStoredSession(SLICER_AUTH_STORAGE_KEY) + } catch (error) { + console.error('Error reading slicer auth session:', error) + return null + } +} + +export const setSlicerAuthSession = (authData) => { + try { + if (!authData?.access_token || !authData?.expires_at) { + console.warn('Auth data is required') + return false + } + + const existing = getSlicerAuthSession() + const serialized = serializeAuthSession(authData, existing?.user) + if (window.localStorage.getItem(SLICER_AUTH_STORAGE_KEY) === serialized) { + return true + } + + window.localStorage.setItem(SLICER_AUTH_STORAGE_KEY, serialized) + return true + } catch (error) { + console.error('Error setting slicer auth session:', error) + return false + } +} + +export const clearSlicerAuthSession = () => { + try { + window.localStorage.removeItem(SLICER_AUTH_STORAGE_KEY) + return true + } catch (error) { + console.error('Error clearing slicer auth session:', error) + return false + } +}