Commit c3061002 authored by Steven's avatar Steven

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.
parent 4aaebc50
...@@ -9,7 +9,7 @@ import { cleanupExpiredOAuthState } from "./utils/oauth"; ...@@ -9,7 +9,7 @@ import { cleanupExpiredOAuthState } from "./utils/oauth";
const App = () => { const App = () => {
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const { profile: instanceProfile, generalSetting: instanceGeneralSetting } = useInstance(); const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance();
// Apply user preferences reactively // Apply user preferences reactively
useUserLocale(); useUserLocale();
...@@ -20,12 +20,13 @@ const App = () => { ...@@ -20,12 +20,13 @@ const App = () => {
cleanupExpiredOAuthState(); 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(() => { useEffect(() => {
if (!instanceProfile.admin) { if (profileLoaded && !instanceProfile.admin) {
navigateTo("/auth/signup"); navigateTo("/auth/signup");
} }
}, [instanceProfile.admin, navigateTo]); }, [profileLoaded, instanceProfile.admin, navigateTo]);
useEffect(() => { useEffect(() => {
if (instanceGeneralSetting.additionalStyle) { if (instanceGeneralSetting.additionalStyle) {
......
// Access token storage using sessionStorage for persistence across page refreshes // Access token storage using localStorage for persistence across tabs and sessions.
// sessionStorage is cleared when the tab/window is closed, providing reasonable security // Tokens are cleared on logout or expiry.
// while avoiding unnecessary token refreshes on page reload
let accessToken: string | null = null; let accessToken: string | null = null;
let tokenExpiresAt: Date | null = null; let tokenExpiresAt: Date | null = null;
const SESSION_TOKEN_KEY = "memos_access_token"; const TOKEN_KEY = "memos_access_token";
const SESSION_EXPIRES_KEY = "memos_token_expires_at"; const EXPIRES_KEY = "memos_token_expires_at";
export const getAccessToken = (): string | null => { export const getAccessToken = (): string | null => {
// If not in memory, try to restore from sessionStorage
if (!accessToken) { if (!accessToken) {
try { try {
const storedToken = sessionStorage.getItem(SESSION_TOKEN_KEY); const storedToken = localStorage.getItem(TOKEN_KEY);
const storedExpires = sessionStorage.getItem(SESSION_EXPIRES_KEY); const storedExpires = localStorage.getItem(EXPIRES_KEY);
if (storedToken && storedExpires) { if (storedToken && storedExpires) {
const expiresAt = new Date(storedExpires); const expiresAt = new Date(storedExpires);
// Only restore if token hasn't expired
if (expiresAt > new Date()) { if (expiresAt > new Date()) {
accessToken = storedToken; accessToken = storedToken;
tokenExpiresAt = expiresAt; tokenExpiresAt = expiresAt;
} else { } else {
// Token expired, clean up sessionStorage // Token expired, clean up
sessionStorage.removeItem(SESSION_TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(SESSION_EXPIRES_KEY); localStorage.removeItem(EXPIRES_KEY);
} }
} }
} catch (e) { } catch (e) {
// sessionStorage might not be available (e.g., in some privacy modes) // localStorage might not be available (e.g., in some privacy modes)
console.warn("Failed to access sessionStorage:", e); console.warn("Failed to access localStorage:", e);
} }
} }
return accessToken; return accessToken;
...@@ -40,17 +37,15 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void => ...@@ -40,17 +37,15 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void =>
try { try {
if (token && expiresAt) { if (token && expiresAt) {
// Store in sessionStorage for persistence across page refreshes localStorage.setItem(TOKEN_KEY, token);
sessionStorage.setItem(SESSION_TOKEN_KEY, token); localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString());
sessionStorage.setItem(SESSION_EXPIRES_KEY, expiresAt.toISOString());
} else { } else {
// Clear sessionStorage if token is being cleared localStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(SESSION_TOKEN_KEY); localStorage.removeItem(EXPIRES_KEY);
sessionStorage.removeItem(SESSION_EXPIRES_KEY);
} }
} catch (e) { } catch (e) {
// sessionStorage might not be available (e.g., in some privacy modes) // localStorage might not be available (e.g., in some privacy modes)
console.warn("Failed to write to sessionStorage:", e); console.warn("Failed to write to localStorage:", e);
} }
}; };
...@@ -62,14 +57,14 @@ export const isTokenExpired = (bufferMs: number = 30000): boolean => { ...@@ -62,14 +57,14 @@ export const isTokenExpired = (bufferMs: number = 30000): boolean => {
return new Date() >= new Date(tokenExpiresAt.getTime() - bufferMs); 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 // 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 // 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. // to refresh, so we can skip the network round-trip entirely.
export const hasStoredToken = (): boolean => { export const hasStoredToken = (): boolean => {
if (accessToken) return true; if (accessToken) return true;
try { try {
return !!sessionStorage.getItem(SESSION_TOKEN_KEY); return !!localStorage.getItem(TOKEN_KEY);
} catch { } catch {
return false; return false;
} }
...@@ -80,9 +75,9 @@ export const clearAccessToken = (): void => { ...@@ -80,9 +75,9 @@ export const clearAccessToken = (): void => {
tokenExpiresAt = null; tokenExpiresAt = null;
try { try {
sessionStorage.removeItem(SESSION_TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(SESSION_EXPIRES_KEY); localStorage.removeItem(EXPIRES_KEY);
} catch (e) { } catch (e) {
console.warn("Failed to clear sessionStorage:", e); console.warn("Failed to clear localStorage:", e);
} }
}; };
...@@ -27,6 +27,10 @@ interface InstanceState { ...@@ -27,6 +27,10 @@ interface InstanceState {
settings: InstanceSetting[]; settings: InstanceSetting[];
isInitialized: boolean; isInitialized: boolean;
isLoading: 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 { interface InstanceContextValue extends InstanceState {
...@@ -46,6 +50,7 @@ export function InstanceProvider({ children }: { children: ReactNode }) { ...@@ -46,6 +50,7 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
settings: [], settings: [],
isInitialized: false, isInitialized: false,
isLoading: true, isLoading: true,
profileLoaded: false,
}); });
// Memoize derived settings to prevent unnecessary recalculations // Memoize derived settings to prevent unnecessary recalculations
...@@ -97,6 +102,7 @@ export function InstanceProvider({ children }: { children: ReactNode }) { ...@@ -97,6 +102,7 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
settings: [generalSetting, memoRelatedSettingResponse], settings: [generalSetting, memoRelatedSettingResponse],
isInitialized: true, isInitialized: true,
isLoading: false, isLoading: false,
profileLoaded: true,
}); });
} catch (error) { } catch (error) {
console.error("Failed to initialize instance:", error); console.error("Failed to initialize instance:", error);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment