HomePilot / frontend /src /ui /lib /userScopedStorage.ts
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
335f515
Raw
History Blame Contribute Delete
4.82 kB
/**
* userScopedStorage β€” per-user localStorage namespacing.
*
* Industry best-practice for multi-account SaaS clients (mirrors the pattern
* used by ChatGPT, Claude Enterprise, and other production multi-tenant UIs):
* client-side UI state MUST be namespaced per authenticated user so that
* switching accounts never leaks the previous user's context into the new
* session. This module provides the minimum primitives to enforce that.
*
* Design
* ------
* - Feature modules (Chat, Projects, Imagine, Gallery, …) register a
* localStorage base key once, at module load.
* - AuthGate orchestrates the lifecycle:
* on login β†’ restoreActiveStateForUser(user.id)
* on account switch β†’ persistActiveStateForUser(prev.id)
* then restoreActiveStateForUser(next.id)
* on logout β†’ persistActiveStateForUser(user.id)
* then clearActiveUiState()
*
* Physical layout in localStorage
* -------------------------------
* <baseKey> β†’ current-session pointer (read by the UI)
* <baseKey>:user:<userId> β†’ per-user saved copy
*
* The UI reads/writes the un-suffixed base key as before. Scoping happens
* only at login / logout / account-switch boundaries, so feature code does
* not need to thread a userId through every call site.
*
* Call-site helpers
* -----------------
* userScopedKey(base, userId) β€” compose a scoped key (for features that
* want to address scoped storage directly,
* e.g. hydrating state on mount).
* getCurrentUserId() β€” resolve the active user id from the
* persisted auth blob. Returns '' if
* unauthenticated.
*/
const USER_KEY_PREFIX = ':user:'
const AUTH_USER_LS_KEY = 'homepilot_auth_user'
const registry = new Set<string>()
/** Feature modules call this at module load to opt their key into per-user
* scoping. Idempotent. */
export function registerUserScopedKey(baseKey: string): void {
if (baseKey) registry.add(baseKey)
}
/** Inspect the current registry (primarily for tests / debugging). */
export function registeredUserScopedKeys(): readonly string[] {
return Array.from(registry)
}
/** Compose the per-user scoped key. */
export function userScopedKey(baseKey: string, userId: string): string {
return `${baseKey}${USER_KEY_PREFIX}${userId}`
}
/** Resolve the active user id from persisted auth state. '' if none. */
export function getCurrentUserId(): string {
try {
const raw = localStorage.getItem(AUTH_USER_LS_KEY)
if (!raw) return ''
const parsed = JSON.parse(raw)
return (parsed && typeof parsed.id === 'string') ? parsed.id : ''
} catch {
return ''
}
}
/** Save every registered global pointer into the user's scoped slot.
* Called when a user is about to stop being the active session
* (logout, account switch). */
export function persistActiveStateForUser(userId: string): void {
if (!userId) return
for (const base of registry) {
try {
const current = localStorage.getItem(base)
if (current !== null) {
localStorage.setItem(userScopedKey(base, userId), current)
}
} catch {
// localStorage can throw (quota, private mode). Non-fatal.
}
}
}
/** Restore the user's previously-saved pointers into the global slots used
* by the UI. If the user has no saved state, the global slot is cleared
* so the incoming session starts clean. */
export function restoreActiveStateForUser(userId: string): void {
if (!userId) return
for (const base of registry) {
try {
const saved = localStorage.getItem(userScopedKey(base, userId))
if (saved !== null) {
localStorage.setItem(base, saved)
} else {
localStorage.removeItem(base)
}
} catch {
// Non-fatal.
}
}
}
/** Wipe every registered global pointer. Used at logout, after the outgoing
* user's state has already been persisted into their scoped slot. */
export function clearActiveUiState(): void {
for (const base of registry) {
try { localStorage.removeItem(base) } catch { /* non-fatal */ }
}
}
// ── Pre-registered application-wide pointers ────────────────────────────────
// These are the UI state keys that must never bleed across accounts. Feature
// modules may still register additional keys at their own module load.
registerUserScopedKey('homepilot_conversation')
registerUserScopedKey('homepilot_current_project')
registerUserScopedKey('homepilot_imagine_items')
registerUserScopedKey('homepilot_personality_id')
registerUserScopedKey('homepilot_voice_style_id')