export type Portal = 'admin' | 'client'; export type SessionUser = { id: string; tenantId?: string | null; tenantName?: string | null; username: string; email?: string | null; phone?: string | null; displayName: string; roles: string[]; }; export type LoginSession = { portal: Portal; user: SessionUser; idleTimeoutSeconds: number; lockRecoverySeconds: number; absoluteExpiresAt: string; lastActivityAt: string; recentAuthenticationExpiresAt: string; locked?: boolean; }; export type SessionRecovery = { returnUrl: string; code?: string; message?: string; }; const legacySessionKey = 'cmpp-auth-session'; const sessionKey = (portal: Portal) => `cmpp-auth-session:${portal}`; const recoveryKey = (portal: Portal) => `cmpp-session-recovery:${portal}`; const sessionEventName = (portal: Portal, type: 'locked' | 'unlocked' | 'logout') => `cmpp-session-${portal}-${type}`; export function portalFromPath(path: string): Portal | undefined { if (/^\/?(?:api\/)?admin(?:\/|$)/.test(path)) return 'admin'; if (/^\/?(?:api\/)?client(?:\/|$)/.test(path)) return 'client'; return undefined; } export function readSession(portal: Portal): LoginSession | null { try { const raw = window.localStorage.getItem(sessionKey(portal)); if (raw) return JSON.parse(raw) as LoginSession; // One-time migration for sessions created before portal storage was isolated. const legacyRaw = window.localStorage.getItem(legacySessionKey); if (!legacyRaw) return null; const legacy = JSON.parse(legacyRaw) as LoginSession; if (legacy.portal !== portal) return null; window.localStorage.setItem(sessionKey(portal), legacyRaw); window.localStorage.removeItem(legacySessionKey); return legacy; } catch { return null; } } export function writeSession(session: LoginSession) { window.localStorage.setItem(sessionKey(session.portal), JSON.stringify(session)); window.localStorage.removeItem(legacySessionKey); } export function clearSession(portal: Portal) { window.localStorage.removeItem(sessionKey(portal)); try { const legacyRaw = window.localStorage.getItem(legacySessionKey); if (legacyRaw && (JSON.parse(legacyRaw) as LoginSession).portal === portal) { window.localStorage.removeItem(legacySessionKey); } } catch { window.localStorage.removeItem(legacySessionKey); } } export function updateSessionTiming(portal: Portal, timing: Partial>) { const current = readSession(portal); if (current) writeSession({ ...current, ...timing }); } export function safeReturnUrl(portal: Portal, value: string) { const normalized = value.trim(); if (!normalized.startsWith(`/${portal}`) || normalized.startsWith(`/${portal}/login`)) return undefined; if (normalized.startsWith('//') || normalized.includes('\\')) return undefined; try { const parsed = new URL(normalized, window.location.origin); if (parsed.origin !== window.location.origin || !parsed.pathname.startsWith(`/${portal}`)) return undefined; return `${parsed.pathname}${parsed.search}${parsed.hash}`; } catch { return undefined; } } export function saveSessionRecovery(portal: Portal, recovery: SessionRecovery) { const returnUrl = safeReturnUrl(portal, recovery.returnUrl); if (!returnUrl) return; window.sessionStorage.setItem(recoveryKey(portal), JSON.stringify({ ...recovery, returnUrl })); } export function readSessionRecovery(portal: Portal): SessionRecovery | null { try { const raw = window.sessionStorage.getItem(recoveryKey(portal)); if (!raw) return null; const recovery = JSON.parse(raw) as SessionRecovery; const returnUrl = safeReturnUrl(portal, recovery.returnUrl); return returnUrl ? { ...recovery, returnUrl } : null; } catch { return null; } } export function consumeSessionRecovery(portal: Portal) { const recovery = readSessionRecovery(portal); window.sessionStorage.removeItem(recoveryKey(portal)); return recovery; } export function clearSessionRecovery(portal: Portal) { window.sessionStorage.removeItem(recoveryKey(portal)); } export function currentRouteForPortal(portal: Portal) { const hashRoute = window.location.hash.startsWith('#/') ? window.location.hash.slice(1) : ''; return safeReturnUrl(portal, hashRoute) ?? safeReturnUrl(portal, `${window.location.pathname}${window.location.search}${window.location.hash}`) ?? `/${portal}`; } export function redirectToPortalLogin(portal: Portal) { window.location.assign(`${window.location.origin}/#/${portal}/login`); } let lastUserActivityAt = Date.now(); let reauthenticationHandler: (() => Promise) | undefined; export function markUserActivity() { lastUserActivityAt = Date.now(); } export function getLastUserActivityAt() { return lastUserActivityAt; } export function hasRecentUserActivity() { return Date.now() - lastUserActivityAt < 60_000; } export function setReauthenticationHandler(handler?: () => Promise) { reauthenticationHandler = handler; } export function requestReauthentication() { if (!reauthenticationHandler) return Promise.reject(new Error('请重新验证当前密码后再操作')); return reauthenticationHandler(); } export function dispatchSessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout', detail?: Record) { window.dispatchEvent(new CustomEvent(sessionEventName(portal, type), { detail })); try { const channel = new BroadcastChannel(`cmpp-session:${portal}`); channel.postMessage({ type, detail }); channel.close(); } catch { // BroadcastChannel is an enhancement; the current tab still receives the DOM event. } } export function sessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout') { return sessionEventName(portal, type); } export function sessionChannel(portal: Portal) { return `cmpp-session:${portal}`; } export function getSessionTenantId() { return readSession('client')?.user.tenantId ?? undefined; }