feat: harden platform workflows and UI governance
This commit is contained in:
+106
-14
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user