feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
+106 -14
View File
@@ -19,32 +19,116 @@ export type LoginSession = {
absoluteExpiresAt: string;
lastActivityAt: string;
recentAuthenticationExpiresAt: string;
locked?: boolean;
};
const sessionKey = 'cmpp-auth-session';
export type SessionRecovery = {
returnUrl: string;
code?: string;
message?: string;
};
export function readSession(): LoginSession | null {
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);
return raw ? JSON.parse(raw) as LoginSession : null;
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, JSON.stringify(session));
window.localStorage.setItem(sessionKey(session.portal), JSON.stringify(session));
window.localStorage.removeItem(legacySessionKey);
}
export function clearSession() {
window.localStorage.removeItem(sessionKey);
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(timing: Partial<Omit<LoginSession, 'portal' | 'user'>>) {
const current = readSession();
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;
@@ -69,10 +153,10 @@ export function requestReauthentication() {
return reauthenticationHandler();
}
export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', detail?: Record<string, unknown>) {
window.dispatchEvent(new CustomEvent(`cmpp-session-${type}`, { detail }));
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');
const channel = new BroadcastChannel(`cmpp-session:${portal}`);
channel.postMessage({ type, detail });
channel.close();
} catch {
@@ -80,6 +164,14 @@ export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', det
}
}
export function getSessionTenantId() {
return readSession()?.user.tenantId ?? undefined;
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;
}