490c5b803e
The axios 401 interceptor calls clearAuthClientState() on every 401, which dispatched "auth-changed"; the App handler re-fetched /auth/me, which 401'd again → interceptor → clearAuthClientState() → "auth-changed" → ... an unbounded request storm (observed live: 100+ GET /auth/me and climbing) that ran whenever the user was logged out (login page, expired session) — burning CPU, network and battery and flooding the server. Fix: make clearAuthClientState idempotent — only emit "auth-changed" when it actually removes a stored user key (a real signed-in→out transition), so repeated 401s can no longer re-trigger the fetch. Runtime-verified in a live stack: /auth/me went from 100+ & growing to 0 & stable. login-page/settings tests green. Documented in docs/performance/PERFORMANCE_IMPROVEMENTS.md (Phase 3.5 runtime finding). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
105 lines
3.1 KiB
TypeScript
105 lines
3.1 KiB
TypeScript
export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
|
|
const AUTH_PERSISTENCE_KEY = "authTokenPersistence";
|
|
const AUTH_USER_KEY = "authUserKey";
|
|
const AUTH_CSRF_COOKIE = "XSRF-TOKEN";
|
|
|
|
export type AuthPersistence = "local" | "session";
|
|
|
|
function safeGet(storage: Storage, key: string): string | null {
|
|
try {
|
|
return storage.getItem(key);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function safeSet(storage: Storage, key: string, value: string) {
|
|
try {
|
|
storage.setItem(key, value);
|
|
} catch {
|
|
// ignore storage failures
|
|
}
|
|
}
|
|
|
|
function safeRemove(storage: Storage, key: string) {
|
|
try {
|
|
storage.removeItem(key);
|
|
} catch {
|
|
// ignore storage failures
|
|
}
|
|
}
|
|
|
|
function emitAuthChanged() {
|
|
window.dispatchEvent(new Event("auth-changed"));
|
|
}
|
|
|
|
function normalizePersistence(value: string | null | undefined): AuthPersistence {
|
|
return value === "session" ? "session" : "local";
|
|
}
|
|
|
|
function persistPreference(persistence: AuthPersistence) {
|
|
safeSet(window.localStorage, AUTH_PERSISTENCE_KEY, persistence);
|
|
safeSet(window.localStorage, AUTH_REMEMBER_ME_KEY, persistence === "local" ? "1" : "0");
|
|
}
|
|
|
|
function getStoredPersistence(): AuthPersistence {
|
|
const explicit = safeGet(window.localStorage, AUTH_PERSISTENCE_KEY);
|
|
if (explicit) return normalizePersistence(explicit);
|
|
const rememberMe = safeGet(window.localStorage, AUTH_REMEMBER_ME_KEY);
|
|
if (rememberMe === "0") return "session";
|
|
return "local";
|
|
}
|
|
|
|
export function getRememberMePref(): boolean {
|
|
return getAuthPersistencePreference() === "local";
|
|
}
|
|
|
|
export function setRememberMePref(value: boolean) {
|
|
persistPreference(value ? "local" : "session");
|
|
}
|
|
|
|
export function getAuthPersistencePreference(): AuthPersistence {
|
|
return getStoredPersistence();
|
|
}
|
|
|
|
export function setAuthPersistencePreference(persistence: AuthPersistence) {
|
|
persistPreference(persistence);
|
|
emitAuthChanged();
|
|
}
|
|
|
|
export function getAuthUserKey(): string {
|
|
return safeGet(window.localStorage, AUTH_USER_KEY) ?? "anon";
|
|
}
|
|
|
|
export function setAuthUserKey(value: string | null | undefined, emit = true) {
|
|
const next = typeof value === "string" ? value.trim() : "";
|
|
if (!next) {
|
|
safeRemove(window.localStorage, AUTH_USER_KEY);
|
|
} else {
|
|
safeSet(window.localStorage, AUTH_USER_KEY, next);
|
|
}
|
|
if (emit) emitAuthChanged();
|
|
}
|
|
|
|
export function clearAuthClientState(emit = true) {
|
|
// Only emit "auth-changed" when this call actually transitions from
|
|
// "signed in" to "signed out". The response interceptor calls this on every
|
|
// 401; without this guard each 401 re-dispatches "auth-changed", which
|
|
// re-fetches /auth/me, which 401s again — an infinite request loop whenever
|
|
// the user is logged out (login page, expired session).
|
|
const had = safeGet(window.localStorage, AUTH_USER_KEY) != null;
|
|
safeRemove(window.localStorage, AUTH_USER_KEY);
|
|
if (emit && had) emitAuthChanged();
|
|
}
|
|
|
|
export function getCsrfToken(): string | null {
|
|
try {
|
|
const parts = document.cookie.split(";").map((part) => part.trim());
|
|
const match = parts.find((part) => part.startsWith(`${AUTH_CSRF_COOKIE}=`));
|
|
if (!match) return null;
|
|
return decodeURIComponent(match.slice(AUTH_CSRF_COOKIE.length + 1));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|