diff --git a/src/app/accountRepository.test.ts b/src/app/accountRepository.test.ts index abdb89b8..14156a1e 100644 --- a/src/app/accountRepository.test.ts +++ b/src/app/accountRepository.test.ts @@ -4,6 +4,7 @@ import { createCharacter, createLocalAccount, createOfflineSession, + clearSession, listCharacters, loadAccountBossCards, saveAccountBossCards, @@ -12,6 +13,7 @@ import { loadCombatPresentationSettings, loginLocalAccount, restoreSession, + saveSession, updateCharacterActionBindings, updateCharacterEquipment, updateCharacterDungeonRunCheckpoint, @@ -165,6 +167,31 @@ describe("local profile repository", () => { expect(restoreSession(storage)?.roles).toEqual([]); }); + it("keeps a login across browser sessions and clears it on sign-out", () => { + const persistent = new MemoryStorage(); + const firstTab = new MemoryStorage(); + const nextTab = new MemoryStorage(); + const session = { kind: "account" as const, ownerId: "account-1", displayName: "Healer", accessToken: "server-token", roles: ["gm" as const] }; + saveSession(session, persistent, firstTab); + + expect(firstTab.getItem(PROFILE_STORAGE_KEYS.session)).toBeNull(); + expect(restoreSession(persistent, nextTab)).toMatchObject({ ...session, roles: [] }); + + clearSession(persistent, nextTab); + expect(restoreSession(persistent, nextTab)).toBeNull(); + }); + + it("moves a previous tab-scoped session into persistent storage", () => { + const persistent = new MemoryStorage(); + const previousTab = new MemoryStorage(); + const session = { kind: "offline" as const, ownerId: "offline-roster", displayName: "Offline" }; + previousTab.setItem(PROFILE_STORAGE_KEYS.session, JSON.stringify(session)); + + expect(restoreSession(persistent, previousTab)).toMatchObject(session); + expect(previousTab.getItem(PROFILE_STORAGE_KEYS.session)).toBeNull(); + expect(restoreSession(persistent, new MemoryStorage())).toMatchObject(session); + }); + it("normalizes legacy profiles and persists progression atomically", () => { const storage = new MemoryStorage(); storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ diff --git a/src/app/accountRepository.ts b/src/app/accountRepository.ts index 25143c09..0b1a8abc 100644 --- a/src/app/accountRepository.ts +++ b/src/app/accountRepository.ts @@ -429,11 +429,25 @@ export function createOfflineSession(): PlayerSession { return { kind: "offline", ownerId: OFFLINE_OWNER_ID, displayName: "Offline" }; } -export function saveSession(session: PlayerSession, storage: StorageLike | null = browserSessionStorage()): void { - storage?.setItem(SESSION_KEY, JSON.stringify(session)); +export function saveSession( + session: PlayerSession, + storage: StorageLike | null = browserLocalStorage(), + transientStorage: StorageLike | null = browserSessionStorage(), +): void { + const serialized = JSON.stringify(session); + try { + if (storage) { + storage.setItem(SESSION_KEY, serialized); + if (transientStorage !== storage) { + try { transientStorage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ } + } + return; + } + } catch { /* Fall back to tab-scoped storage when persistent storage is unavailable. */ } + try { transientStorage?.setItem(SESSION_KEY, serialized); } catch { /* Storage may be disabled. */ } } -export function restoreSession(storage: StorageLike | null = browserSessionStorage()): PlayerSession | null { +function readStoredSession(storage: StorageLike | null): PlayerSession | null { if (!storage) return null; try { const session = JSON.parse(storage.getItem(SESSION_KEY) ?? "null") as Partial | null; @@ -451,8 +465,26 @@ export function restoreSession(storage: StorageLike | null = browserSessionStora } } -export function clearSession(storage: StorageLike | null = browserSessionStorage()): void { - storage?.removeItem(SESSION_KEY); +export function restoreSession( + storage: StorageLike | null = browserLocalStorage(), + transientStorage: StorageLike | null = browserSessionStorage(), +): PlayerSession | null { + const persistent = readStoredSession(storage); + if (persistent) return persistent; + if (transientStorage === storage) return null; + const transient = readStoredSession(transientStorage); + if (transient) saveSession(transient, storage, transientStorage); + return transient; +} + +export function clearSession( + storage: StorageLike | null = browserLocalStorage(), + transientStorage: StorageLike | null = browserSessionStorage(), +): void { + try { storage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ } + if (transientStorage !== storage) { + try { transientStorage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ } + } } export function listCharacters(ownerId: string, storage: StorageLike | null = browserLocalStorage()): CharacterProfile[] { diff --git a/src/app/onlineAccountClient.ts b/src/app/onlineAccountClient.ts index cb461ef4..4e0a7481 100644 --- a/src/app/onlineAccountClient.ts +++ b/src/app/onlineAccountClient.ts @@ -38,6 +38,10 @@ class OnlineApiError extends Error { } } +export function isOnlineAuthenticationError(error: unknown): boolean { + return error instanceof OnlineApiError && error.status === 401; +} + let activeSession: PlayerSession | null = null; let suppressUpload = false; let pendingCharacters: CharacterProfile[] | null = null; @@ -135,11 +139,11 @@ function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[] setRosterChangeListener(queueCloudSave); -export function activateOnlineSession(session: PlayerSession): void { - if (activeSession?.ownerId !== session.ownerId && saveTimer) { +export function activateOnlineSession(session: PlayerSession | null): void { + if (activeSession?.ownerId !== session?.ownerId && saveTimer) { clearTimeout(saveTimer); saveTimer = null; flushCloudSave(); } - activeSession = session.accessToken ? session : null; + activeSession = session?.accessToken ? session : null; } export function retryCloudSave(): void { diff --git a/src/app/shellStore.ts b/src/app/shellStore.ts index b6444a06..d817a488 100644 --- a/src/app/shellStore.ts +++ b/src/app/shellStore.ts @@ -56,6 +56,7 @@ import { manastormShellRuntime } from "./manastormShellRuntimeBridge"; import { activateOnlineSession, hydrateOnlineSession, + isOnlineAuthenticationError, logoutOnlineSession, refreshOnlineSession, } from "./onlineAccountClient"; @@ -642,15 +643,17 @@ export function selectedCharacter(state: ShellState): CharacterProfile | null { return state.characters.find((character) => character.id === state.selectedCharacterId) ?? null; } -if (restored.session?.accessToken) { - activateOnlineSession(restored.session); - void Promise.all([ - hydrateOnlineSession(restored.session), - refreshOnlineSession(restored.session), - ]).then(([characters, refreshedSession]) => { - const current = useShellStore.getState().session; - if (current?.ownerId !== restored.session?.ownerId) return; +const restoredOnlineSession = restored.session?.accessToken ? restored.session : null; +if (restoredOnlineSession) { + activateOnlineSession(restoredOnlineSession); + void (async () => { + const refreshedSession = await refreshOnlineSession(restoredOnlineSession); + if (useShellStore.getState().session?.accessToken !== restoredOnlineSession.accessToken) return; saveSession(refreshedSession); + useShellStore.setState({ session: refreshedSession }); + const characters = await hydrateOnlineSession(refreshedSession); + const current = useShellStore.getState().session; + if (current?.accessToken !== restoredOnlineSession.accessToken) return; useShellStore.setState({ session: refreshedSession, characters, @@ -660,7 +663,22 @@ if (restored.session?.accessToken) { : characters[0]?.id ?? null, notice: "Cloud roster synchronized.", }); - }).catch((error) => { + })().catch((error) => { + if (useShellStore.getState().session?.accessToken !== restoredOnlineSession.accessToken) return; + if (isOnlineAuthenticationError(error)) { + clearSession(); + activateOnlineSession(null); + useShellStore.setState({ + phase: "login", + session: null, + characters: [], + bossCards: emptyBossCardCollection(), + selectedCharacterId: null, + activeCharacter: null, + notice: "Your session has expired. Please sign in again.", + }); + return; + } useShellStore.setState({ notice: error instanceof Error ? `${error.message} Using the cached roster.`