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.
This commit is contained in:
parent
4aaebc507e
commit
c3061002f3
3 changed files with 33 additions and 31 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue