import { useEffect } from 'react'; import type { MatrixClient } from 'matrix-js-sdk'; import { getFallbackSession, Session, subscribeSessionChanges } from '../state/sessions'; /** The credential identity this tab is currently running on. */ export type SessionIdentity = Pick; /** * What an out-of-tab session change means for this tab: * - `none` — nothing credential-relevant changed (metadata-only rewrite, * or a duplicate storage event from the dual-write). * - `removed` — the session disappeared (logout / localStorage.clear()). * - `relogin` — a different user or device signed in. * - `rotated` — same user + device, only the access token changed (an OIDC * refresh performed by another tab). */ export type SessionChange = 'none' | 'removed' | 'relogin' | 'rotated'; /** Pure: classify a freshly-read session against the one this tab runs on. */ export const classifySessionChange = ( current: SessionIdentity | null, next: SessionIdentity | null, ): SessionChange => { if (!next) return current ? 'removed' : 'none'; if (!current) return 'relogin'; if (next.userId !== current.userId || next.deviceId !== current.deviceId) return 'relogin'; if (next.accessToken !== current.accessToken) return 'rotated'; return 'none'; }; /** * Keep this tab in sync with session changes performed in other tabs/windows. * * The coordinator mounts this once inside the authenticated client shell. * `storage` events fire only in tabs that did NOT perform the write, so the * callback here always represents an out-of-tab change. * * Default action is the safest one for auth-critical state — a full reload: * - session REMOVED elsewhere (logout / localStorage.clear()) → the access * token disappears, so we reload; the router bounces to auth on next boot. * - session APPEARED or its user/device CHANGED elsewhere (a fresh login) → * we reload so the client re-initialises with the new credentials rather * than running on a stale/revoked token. * * #16 — a same-user, same-device access-token ROTATION (a routine OIDC refresh * in another tab) must NOT reload: that would drop in-progress calls/uploads * every few minutes. Instead the new tokens are swapped into the running * client in place. When no client is available yet we fall back to the reload. * * A change that does not alter the credentials (e.g. an OIDC metadata-only * rewrite) is ignored, which also collapses the several storage events emitted * by a single dual-write into at most one reaction. */ export const useSessionSync = (mx?: MatrixClient): void => { useEffect(() => { // Snapshot the credential this tab runs on; compare against it so we only // react to a genuine credential change. Updated on an in-place rotation. let current: SessionIdentity | null = getFallbackSession() ?? null; const unsubscribe = subscribeSessionChanges((session) => { const change = classifySessionChange(current, session); if (change === 'none') return; if (change === 'rotated' && mx && session) { // Same opts object backs the SDK's TokenRefresher, so updating the // refresh token here keeps its next refresh on the rotated token. mx.setAccessToken(session.accessToken); if (session.refreshToken) mx.http.opts.refreshToken = session.refreshToken; current = session; return; } window.location.reload(); }); return unsubscribe; }, [mx]); };