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";
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) {
......
// 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);
}
};
......@@ -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);
......
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