// Session — minimal auth scaffold backed by localStorage. // Sign-in is owned by `persistFromArcadiaLogin`, which is called by the auth // routes after a successful arcadia API exchange. The shape here matches what // AppShell + useUser expect. import { useSyncExternalStore } from "react" import { profileInitials } from "~/lib/profile" import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt" export type AvailableTenant = { id: string slug?: string name?: string roles: string[] } export type Session = { userId: string name: string email: string token: string // Issued at, ms since epoch. issuedAt: number // Active membership context — derived from the JWT. tenantId?: string tenantSlug?: string roles: string[] availableTenants: AvailableTenant[] // Set (to the operator's user id) when this session is an impersonation — // derived from the JWT's `impersonated_by` claim. Drives the "Viewing as" // banner and gates the normal identity chrome. impersonatedBy?: string } const STORAGE_KEY = "crema.session" // Where the operator's real session is parked while they impersonate someone, // so Stop can restore it without another round-trip. const IMPERSONATION_BACKUP_KEY = "crema.session.impersonation-backup" const CHANGE_EVENT = "crema:session-change" function readFromStorage(): Session | null { if (typeof window === "undefined") return null try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return null const parsed = JSON.parse(raw) as Partial if ( typeof parsed.userId !== "string" || typeof parsed.email !== "string" || typeof parsed.token !== "string" ) return null return { userId: parsed.userId, name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name : parsed.email, email: parsed.email, token: parsed.token, issuedAt: typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(), tenantId: typeof parsed.tenantId === "string" ? parsed.tenantId : undefined, tenantSlug: typeof parsed.tenantSlug === "string" ? parsed.tenantSlug : undefined, roles: Array.isArray(parsed.roles) ? parsed.roles.filter((r): r is string => typeof r === "string") : [], availableTenants: Array.isArray(parsed.availableTenants) ? (parsed.availableTenants.filter( (t): t is AvailableTenant => !!t && typeof (t as AvailableTenant).id === "string", ) as AvailableTenant[]) : [], impersonatedBy: typeof parsed.impersonatedBy === "string" ? parsed.impersonatedBy : undefined, } } catch { return null } } export function loadSession(): Session | null { return readFromStorage() } export function signOut() { if (typeof window === "undefined") return localStorage.removeItem(STORAGE_KEY) sessionStorage.removeItem("arcadia_access_token") sessionStorage.removeItem("arcadia_refresh_token") window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) } /** Bridge: persist a Session record from a successful arcadia login. * Stores the JWT in sessionStorage (where ArcadiaProvider's getToken reads * it) and writes the user-shaped Session into localStorage so the existing * AppShell / useUser machinery keeps working unchanged. */ export function persistFromArcadiaLogin( tokens: { access_token: string; refresh_token?: string }, user?: { id: string; email: string; full_name?: string; first_name?: string; last_name?: string } | null, ): Session { const name = user?.full_name || [user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.email || "Signed-in user" const claims = decodeJwt(tokens.access_token) ?? {} const availableTenants: AvailableTenant[] = Array.isArray( claims.available_tenants, ) ? (claims.available_tenants as AvailableTenantClaim[]) .filter((t) => t && typeof t.id === "string") .map((t) => ({ id: t.id as string, slug: t.slug, name: t.name, roles: Array.isArray(t.roles) ? t.roles : [], })) : [] const session: Session = { userId: user?.id ?? `arcadia-${Date.now().toString(36)}`, name, email: user?.email ?? "", token: tokens.access_token, issuedAt: Date.now(), tenantId: typeof claims.tenant_id === "string" ? claims.tenant_id : undefined, tenantSlug: typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined, roles: Array.isArray(claims.roles) ? claims.roles : [], availableTenants, } if (typeof window !== "undefined") { sessionStorage.setItem("arcadia_access_token", tokens.access_token) if (tokens.refresh_token) sessionStorage.setItem("arcadia_refresh_token", tokens.refresh_token) localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) } return session } /** Patch the stored session's identity fields without changing the token. * Use after the operator edits their profile so the appbar avatar and * protected-shell greeting reflect the new name/email immediately. */ export function updateSessionUser(patch: { name?: string email?: string }): Session | null { if (typeof window === "undefined") return null const current = readFromStorage() if (!current) return null const next: Session = { ...current, name: patch.name?.trim() ? patch.name : current.name, email: patch.email?.trim() ? patch.email : current.email, } localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) return next } /** True if a non-expired session is in storage. */ export function hasSession(): boolean { return !!readFromStorage() } /** Build a Session from a bare access token (used for impersonation, where the * server hands back a token but no user record — identity comes from the * token's own claims). */ function sessionFromToken(token: string, fallbackEmail = "impersonated user"): Session { const claims = decodeJwt(token) ?? {} const email = typeof claims.email === "string" ? claims.email : fallbackEmail // `sub` is ":" — take the user id. const sub = typeof claims.sub === "string" ? claims.sub.split(":")[0] : "" const availableTenants: AvailableTenant[] = Array.isArray(claims.available_tenants) ? (claims.available_tenants as AvailableTenantClaim[]) .filter((t) => t && typeof t.id === "string") .map((t) => ({ id: t.id as string, slug: t.slug, name: t.name, roles: t.roles ?? [] })) : [] return { userId: (typeof claims.sub === "string" ? sub : "") || email, name: email, email, token, issuedAt: Date.now(), tenantId: typeof claims.tenant_id === "string" ? claims.tenant_id : undefined, tenantSlug: typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined, roles: Array.isArray(claims.roles) ? (claims.roles as string[]) : [], availableTenants, impersonatedBy: typeof claims.impersonated_by === "string" ? claims.impersonated_by : undefined, } } /** * Enter impersonation: park the operator's real session, then swap the active * session + token to the impersonation token the server minted. Every * subsequent request acts as the target user. */ export function beginImpersonation(impersonationToken: string): Session | null { if (typeof window === "undefined") return null const current = readFromStorage() if (!current) return null // Back up the operator's real session + API token so Stop can restore both. const currentApiToken = sessionStorage.getItem("arcadia_access_token") localStorage.setItem( IMPERSONATION_BACKUP_KEY, JSON.stringify({ session: current, apiToken: currentApiToken }), ) const next = sessionFromToken(impersonationToken, current.email) sessionStorage.setItem("arcadia_access_token", impersonationToken) localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) return next } /** True when an impersonation backup is parked (i.e. we can Stop). */ export function isImpersonating(): boolean { if (typeof window === "undefined") return false return !!localStorage.getItem(IMPERSONATION_BACKUP_KEY) } /** * Leave impersonation: restore the operator's parked session + API token. * Impersonation is stateless (just which token the client sends), so this is a * pure client-side restore — no server round-trip needed. */ export function endImpersonation(): Session | null { if (typeof window === "undefined") return null const raw = localStorage.getItem(IMPERSONATION_BACKUP_KEY) if (!raw) return null try { const { session, apiToken } = JSON.parse(raw) as { session: Session apiToken: string | null } if (apiToken) sessionStorage.setItem("arcadia_access_token", apiToken) else sessionStorage.removeItem("arcadia_access_token") localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) localStorage.removeItem(IMPERSONATION_BACKUP_KEY) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) return session } catch { localStorage.removeItem(IMPERSONATION_BACKUP_KEY) return null } } // `useSyncExternalStore` demands a *referentially stable* snapshot: it must // return the identical object until the underlying value genuinely changes. // Keying the cache on the raw stored string gives us that for free — reparse // only when the bytes differ. // // This used to keep a `cacheValid` flag that `useSession` cleared on mount, // which meant the very next render reparsed storage and produced a brand-new // Session object. Every `useEffect([session, …])` in the app then saw a // "changed" session and refetched: three identical GETs per list screen, and // enough request volume during navigation to trip arcadia's own rate limiter // and greet the operator with 429 banners. The session had not changed at all. let cached: Session | null = null let cachedRaw: string | null = null let primed = false function readRaw(): string | null { if (typeof window === "undefined") return null try { return localStorage.getItem(STORAGE_KEY) } catch { return null } } function subscribe(cb: () => void): () => void { const onChange = () => { // Force the next getSnapshot to reparse, then let React re-render. primed = false cb() } window.addEventListener(CHANGE_EVENT, onChange) window.addEventListener("storage", (e) => { if (e.key === STORAGE_KEY) onChange() }) return () => window.removeEventListener(CHANGE_EVENT, onChange) } function getSnapshot(): Session | null { const raw = readRaw() if (!primed || raw !== cachedRaw) { cachedRaw = raw // readFromStorage re-validates expiry and may clear the token; when it // does, `raw` differs on the next read and we reparse again. cached = readFromStorage() primed = true } return cached } function getServerSnapshot(): Session | null { return null } export function useSession(): Session | null { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } export function sessionInitials(session: Session | null): string { if (!session) return "?" return profileInitials(session.name || session.email) }