From c3061002f32dea3f98b6b8344e43dab8c2f84781 Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 22 Feb 2026 13:55:46 +0800 Subject: [PATCH] fix(web): persist auth token in localStorage for cross-tab sessions Switch from sessionStorage to localStorage so the auth token survives across tabs and browser restarts, matching standard platform behavior. Also guard the signup redirect in App.tsx behind profileLoaded to avoid a false redirect when the instance profile fetch fails. --- web/src/App.tsx | 9 ++--- web/src/auth-state.ts | 49 +++++++++++++--------------- web/src/contexts/InstanceContext.tsx | 6 ++++ 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 0acba201..ec13fd8e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,7 +9,7 @@ import { cleanupExpiredOAuthState } from "./utils/oauth"; const App = () => { const navigateTo = useNavigateTo(); - const { profile: instanceProfile, generalSetting: instanceGeneralSetting } = useInstance(); + const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance(); // Apply user preferences reactively useUserLocale(); @@ -20,12 +20,13 @@ const App = () => { cleanupExpiredOAuthState(); }, []); - // Redirect to sign up page if instance not initialized (no admin account exists yet) + // Redirect to sign up page if instance not initialized (no admin account exists yet). + // Guard with profileLoaded so a fetch failure doesn't incorrectly trigger the redirect. useEffect(() => { - if (!instanceProfile.admin) { + if (profileLoaded && !instanceProfile.admin) { navigateTo("/auth/signup"); } - }, [instanceProfile.admin, navigateTo]); + }, [profileLoaded, instanceProfile.admin, navigateTo]); useEffect(() => { if (instanceGeneralSetting.additionalStyle) { diff --git a/web/src/auth-state.ts b/web/src/auth-state.ts index 8729feba..f71dfc36 100644 --- a/web/src/auth-state.ts +++ b/web/src/auth-state.ts @@ -1,34 +1,31 @@ -// Access token storage using sessionStorage for persistence across page refreshes -// sessionStorage is cleared when the tab/window is closed, providing reasonable security -// while avoiding unnecessary token refreshes on page reload +// Access token storage using localStorage for persistence across tabs and sessions. +// Tokens are cleared on logout or expiry. let accessToken: string | null = null; let tokenExpiresAt: Date | null = null; -const SESSION_TOKEN_KEY = "memos_access_token"; -const SESSION_EXPIRES_KEY = "memos_token_expires_at"; +const TOKEN_KEY = "memos_access_token"; +const EXPIRES_KEY = "memos_token_expires_at"; export const getAccessToken = (): string | null => { - // If not in memory, try to restore from sessionStorage if (!accessToken) { try { - const storedToken = sessionStorage.getItem(SESSION_TOKEN_KEY); - const storedExpires = sessionStorage.getItem(SESSION_EXPIRES_KEY); + const storedToken = localStorage.getItem(TOKEN_KEY); + const storedExpires = localStorage.getItem(EXPIRES_KEY); if (storedToken && storedExpires) { const expiresAt = new Date(storedExpires); - // Only restore if token hasn't expired if (expiresAt > new Date()) { accessToken = storedToken; tokenExpiresAt = expiresAt; } else { - // Token expired, clean up sessionStorage - sessionStorage.removeItem(SESSION_TOKEN_KEY); - sessionStorage.removeItem(SESSION_EXPIRES_KEY); + // Token expired, clean up + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(EXPIRES_KEY); } } } catch (e) { - // sessionStorage might not be available (e.g., in some privacy modes) - console.warn("Failed to access sessionStorage:", e); + // localStorage might not be available (e.g., in some privacy modes) + console.warn("Failed to access localStorage:", e); } } return accessToken; @@ -40,17 +37,15 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void => try { if (token && expiresAt) { - // Store in sessionStorage for persistence across page refreshes - sessionStorage.setItem(SESSION_TOKEN_KEY, token); - sessionStorage.setItem(SESSION_EXPIRES_KEY, expiresAt.toISOString()); + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString()); } else { - // Clear sessionStorage if token is being cleared - sessionStorage.removeItem(SESSION_TOKEN_KEY); - sessionStorage.removeItem(SESSION_EXPIRES_KEY); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(EXPIRES_KEY); } } catch (e) { - // sessionStorage might not be available (e.g., in some privacy modes) - console.warn("Failed to write to sessionStorage:", e); + // localStorage might not be available (e.g., in some privacy modes) + console.warn("Failed to write to localStorage:", e); } }; @@ -62,14 +57,14 @@ export const isTokenExpired = (bufferMs: number = 30000): boolean => { return new Date() >= new Date(tokenExpiresAt.getTime() - bufferMs); }; -// Returns true if a token exists in sessionStorage, even if it is expired. +// Returns true if a token exists in localStorage, even if it is expired. // Used to decide whether to attempt GetCurrentUser on app init — if no token // was ever stored, the user is definitively not logged in and there is nothing // to refresh, so we can skip the network round-trip entirely. export const hasStoredToken = (): boolean => { if (accessToken) return true; try { - return !!sessionStorage.getItem(SESSION_TOKEN_KEY); + return !!localStorage.getItem(TOKEN_KEY); } catch { return false; } @@ -80,9 +75,9 @@ export const clearAccessToken = (): void => { tokenExpiresAt = null; try { - sessionStorage.removeItem(SESSION_TOKEN_KEY); - sessionStorage.removeItem(SESSION_EXPIRES_KEY); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(EXPIRES_KEY); } catch (e) { - console.warn("Failed to clear sessionStorage:", e); + console.warn("Failed to clear localStorage:", e); } }; diff --git a/web/src/contexts/InstanceContext.tsx b/web/src/contexts/InstanceContext.tsx index 8cd0656a..31c89a04 100644 --- a/web/src/contexts/InstanceContext.tsx +++ b/web/src/contexts/InstanceContext.tsx @@ -27,6 +27,10 @@ interface InstanceState { settings: InstanceSetting[]; isInitialized: boolean; isLoading: boolean; + // True only when the profile was successfully fetched from the server. + // Remains false if initialization failed, so consumers can distinguish + // "no admin exists" from "failed to load profile". + profileLoaded: boolean; } interface InstanceContextValue extends InstanceState { @@ -46,6 +50,7 @@ export function InstanceProvider({ children }: { children: ReactNode }) { settings: [], isInitialized: false, isLoading: true, + profileLoaded: false, }); // Memoize derived settings to prevent unnecessary recalculations @@ -97,6 +102,7 @@ export function InstanceProvider({ children }: { children: ReactNode }) { settings: [generalSetting, memoRelatedSettingResponse], isInitialized: true, isLoading: false, + profileLoaded: true, }); } catch (error) { console.error("Failed to initialize instance:", error);