Files
lislgosms/src/api/session.ts
T

178 lines
5.9 KiB
TypeScript

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<Omit<LoginSession, 'portal' | 'user'>>) {
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<void>) | 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<void>) {
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<string, unknown>) {
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;
}