diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 77c685c1..e1032b3a 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -53,6 +53,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIdentitySync } from "@/hooks/use-identity-sync"; +import { useResumeConnectivity } from "@/hooks/use-resume-connectivity"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useProTabStore } from "@/stores/pro-tab-store"; import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes"; @@ -125,6 +126,7 @@ export default function Home() { const { identities } = useIdentityStore(); const multiAccountIdentities = useProMultiAccountIdentities(); useIdentitySync(); + useResumeConnectivity(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(); diff --git a/hooks/use-resume-connectivity.ts b/hooks/use-resume-connectivity.ts new file mode 100644 index 00000000..f55e9a7e --- /dev/null +++ b/hooks/use-resume-connectivity.ts @@ -0,0 +1,48 @@ +'use client'; + +import { useEffect } from 'react'; +import { useAuthStore } from '@/stores/auth-store'; + +/** + * When the tab becomes visible again or the browser reports that the network + * came back, force an immediate connectivity check on the active JMAP client + * (bypassing the keep-alive backoff). + * + * Without this hook the keep-alive loop backs off to ~5 minutes between + * pings after a few consecutive failures. That is fine while the tab is + * dormant, but it means a user who suspends their laptop / switches Wi-Fi / + * returns from lunch can watch the "Attempting to reconnect…" banner sit + * for minutes on end even though the server is reachable again. + * + * The hook is a no-op when there is no active client (login screen, demo + * bootstrap in flight) or when the connection is already healthy — the + * client's `resumeConnectivity()` short-circuits both of those. + */ +export function useResumeConnectivity() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const client = useAuthStore((s) => s.client); + + useEffect(() => { + if (!isAuthenticated || !client) return; + + const attempt = () => { + // Fire-and-forget: the client handles its own retry/backoff internally, + // and the connectionChange callback drives the banner state. + void client.resumeConnectivity(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') attempt(); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('online', attempt); + window.addEventListener('focus', attempt); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('online', attempt); + window.removeEventListener('focus', attempt); + }; + }, [isAuthenticated, client]); +} diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index e7b42fc5..f8577356 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -36,6 +36,11 @@ export class DemoJMAPClient implements IJMAPClient { async reconnect(): Promise { /* no-op */ } async ping(): Promise { /* no-op */ } + async resumeConnectivity(): Promise { + // The demo client is always "connected"; just re-notify listeners so any + // stale banner clears. + this.connectionCallback?.(true); + } // ── Session / auth accessors ────────────────────────────────── diff --git a/lib/jmap/__tests__/resume-connectivity.test.ts b/lib/jmap/__tests__/resume-connectivity.test.ts new file mode 100644 index 00000000..3704bc0c --- /dev/null +++ b/lib/jmap/__tests__/resume-connectivity.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../client'; + +// Non-exhaustive helpers to reach into the private state we care about +// (backoff counters, callbacks) without exposing them on the public surface. +interface ClientInternals { + pingSkipRemaining: number; + pingFailureCount: number; + apiUrl: string; + intentionallyDisconnected: boolean; + connectionChangeCallback: ((connected: boolean) => void) | null; + rateLimitedUntil: number; +} + +function internals(client: JMAPClient): ClientInternals { + return client as unknown as ClientInternals; +} + +describe('JMAPClient.resumeConnectivity', () => { + let client: JMAPClient; + + beforeEach(() => { + vi.restoreAllMocks(); + client = new JMAPClient('https://example.test', 'user@example.test', 'pw'); + // Simulate a fully connected client (apiUrl set by a prior connect()). + internals(client).apiUrl = 'https://example.test/jmap'; + internals(client).pingSkipRemaining = 5; + internals(client).pingFailureCount = 3; + }); + + it('fires the connection callback on a successful ping and resets backoff', async () => { + const onChange = vi.fn(); + client.onConnectionChange(onChange); + const pingSpy = vi.spyOn(client, 'ping').mockResolvedValue(); + const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue(); + + await client.resumeConnectivity(); + + expect(pingSpy).toHaveBeenCalledOnce(); + expect(reconnectSpy).not.toHaveBeenCalled(); + // Backoff must be wiped so the next scheduled tick fires immediately if + // things flip again — this is the whole point of the manual resume. + expect(internals(client).pingSkipRemaining).toBe(0); + expect(internals(client).pingFailureCount).toBe(0); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('falls through to reconnect() when the ping throws', async () => { + const onChange = vi.fn(); + client.onConnectionChange(onChange); + vi.spyOn(client, 'ping').mockRejectedValue(new Error('boom')); + const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue(); + + await client.resumeConnectivity(); + + expect(reconnectSpy).toHaveBeenCalledOnce(); + expect(internals(client).pingFailureCount).toBe(0); + expect(onChange).toHaveBeenLastCalledWith(true); + }); + + it('goes straight to reconnect() when apiUrl is empty (never connected)', async () => { + internals(client).apiUrl = ''; + const pingSpy = vi.spyOn(client, 'ping').mockResolvedValue(); + const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue(); + + await client.resumeConnectivity(); + + expect(pingSpy).not.toHaveBeenCalled(); + expect(reconnectSpy).toHaveBeenCalledOnce(); + }); + + it('is a no-op after intentional disconnect', async () => { + internals(client).intentionallyDisconnected = true; + const pingSpy = vi.spyOn(client, 'ping').mockResolvedValue(); + const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue(); + + await client.resumeConnectivity(); + + expect(pingSpy).not.toHaveBeenCalled(); + expect(reconnectSpy).not.toHaveBeenCalled(); + }); + + it('is a no-op while rate-limited', async () => { + internals(client).rateLimitedUntil = Date.now() + 60_000; + const pingSpy = vi.spyOn(client, 'ping').mockResolvedValue(); + const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue(); + + await client.resumeConnectivity(); + + expect(pingSpy).not.toHaveBeenCalled(); + expect(reconnectSpy).not.toHaveBeenCalled(); + }); + + it('keeps quiet when both ping and reconnect fail (banner state stays)', async () => { + const onChange = vi.fn(); + client.onConnectionChange(onChange); + vi.spyOn(client, 'ping').mockRejectedValue(new Error('ping down')); + vi.spyOn(client, 'reconnect').mockRejectedValue(new Error('reconnect down')); + + // Should not throw — the caller (visibilitychange handler) has nothing + // useful to do with the error. + await expect(client.resumeConnectivity()).resolves.toBeUndefined(); + + // Never claim a successful re-establishment. + expect(onChange).not.toHaveBeenCalledWith(true); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 2693f3d8..a86de2e5 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -14,6 +14,15 @@ export interface IJMAPClient { disconnect(): void; reconnect(): Promise; ping(): Promise; + /** + * Force an immediate connectivity check, bypassing the keep-alive backoff. + * Called when the tab becomes visible again or the browser reports the + * network came back — situations where we know something changed and the + * ~5-min backoff would otherwise strand the "reconnecting…" banner. + * Fires the connectionChange callback on success, and triggers a reconnect + * attempt on failure. + */ + resumeConnectivity(): Promise; // ── Session / auth accessors ────────────────────────────────── getServerUrl(): string; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d2655a77..8e3e9d9e 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1004,6 +1004,52 @@ export class JMAPClient implements IJMAPClient { await this.connect(); } + /** + * Force an immediate connectivity check that bypasses `pingSkipRemaining`. + * + * The keep-alive loop backs off exponentially on failure (up to ~5 min + * between attempts). That's the right behaviour while the tab is dormant, + * but wrong when we have a reason to believe things have changed: the tab + * just became visible, the network came back, the user's about to interact. + * In those cases the "reconnecting…" banner would otherwise stick around + * for minutes despite the underlying issue being gone. + * + * On success this fires the connection callback (clearing the banner) and + * resets the backoff counters. On failure it drops through to `reconnect()` + * so the caller doesn't have to duplicate the re-establish logic. + */ + async resumeConnectivity(): Promise { + if (this.intentionallyDisconnected) return; + if (this.isRateLimited()) return; + // Reset the skip counter so the next scheduled tick fires immediately if + // this manual attempt also fails. + this.pingSkipRemaining = 0; + try { + // ping() throws if !this.apiUrl, so short-circuit through reconnect() + // in that case (session was never fully established). + if (!this.apiUrl) { + await this.reconnect(); + } else { + await this.ping(); + } + this.pingFailureCount = 0; + this.connectionChangeCallback?.(true); + } catch { + // Ping/session refresh failed — try a full reconnect once. Errors bubble + // up so callers can log; the banner state is already false from the + // last ping tick or will be set by the next scheduled ping. + try { + await this.reconnect(); + this.pingFailureCount = 0; + this.pingSkipRemaining = 0; + this.connectionChangeCallback?.(true); + } catch (reconnectError) { + // Leave the banner as-is; the next keep-alive tick will retry. + console.error('resumeConnectivity: reconnect failed:', reconnectError); + } + } + } + disconnect(): void { this.intentionallyDisconnected = true; this.stopKeepAlive(); diff --git a/stores/__tests__/auth-store-soft-signout.test.ts b/stores/__tests__/auth-store-soft-signout.test.ts new file mode 100644 index 00000000..9d3a8706 --- /dev/null +++ b/stores/__tests__/auth-store-soft-signout.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import * as browserNavigation from '@/lib/browser-navigation'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +type FetchInput = Parameters[0]; +type FetchInit = Parameters[1]; + +/** + * These tests lock in the soft-signout contract: when the server rejects a + * refresh token, the auth flow must preserve the account entry so the user + * re-signs in from the switcher without losing settings/identities/subs. + * + * The pre-existing test file (auth-store-logout.test.ts) covers the old + * behaviour where a 401 caused a full evict + redirect. That path stays valid + * for the "no active account bound" defensive fallback; here we cover the + * happy path where accountId is bound and the account entry survives. + */ +describe('auth-store soft signout', () => { + beforeEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + window.history.pushState({}, '', '/en'); + + useAccountStore.setState({ + accounts: [], + activeAccountId: null, + defaultAccountId: null, + }); + + useAuthStore.setState({ + isAuthenticated: false, + isLoading: false, + error: null, + serverUrl: null, + username: null, + client: null, + identities: [], + primaryIdentity: null, + authMode: 'basic', + rememberMe: false, + accessToken: null, + tokenExpiresAt: null, + connectionLost: false, + activeAccountId: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('marks the account needsReauth and drops both cookies on softSignOut', () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); + vi.stubGlobal('fetch', fetchMock); + + // Register an account so softSignOut has something to act on. + const accountStore = useAccountStore.getState(); + accountStore.addAccount({ + label: 'luc@undust.co', + serverUrl: 'https://mail.undust.co', + username: 'luc@undust.co', + authMode: 'oauth', + rememberMe: true, + displayName: 'Luc', + email: 'luc@undust.co', + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: true, + }); + const accountId = useAccountStore.getState().accounts[0].id; + + useAuthStore.getState().softSignOut(accountId, 'refresh_rejected'); + + const acc = useAccountStore.getState().getAccountById(accountId); + expect(acc).toBeDefined(); + expect(acc!.needsReauth).toBe(true); + expect(acc!.hasError).toBe(true); + expect(acc!.errorMessage).toBe('refresh_rejected'); + expect(acc!.isConnected).toBe(false); + + // Session cookie always cleared; OAuth account also clears refresh token. + const urls = fetchMock.mock.calls + .map(([input, init]) => `${(init as FetchInit)?.method ?? 'GET'} ${String(input)}`); + expect(urls).toContain('DELETE /api/auth/session?slot=0'); + expect(urls).toContain('DELETE /api/auth/token?slot=0'); + }); + + it('preserves the account entry (and its settings blob) across a softSignOut', () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + + const accountStore = useAccountStore.getState(); + accountStore.addAccount({ + label: 'luc@undust.co', + serverUrl: 'https://mail.undust.co', + username: 'luc@undust.co', + authMode: 'oauth', + rememberMe: true, + displayName: 'Luc', + email: 'luc@undust.co', + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: true, + }); + const accountId = useAccountStore.getState().accounts[0].id; + const originalSlot = useAccountStore.getState().accounts[0].cookieSlot; + + useAuthStore.getState().softSignOut(accountId, 'session_expired'); + + // The account is still there, same id, same slot — a subsequent login for + // the same (username, serverUrl) will land back on this row and reuse + // the settings blob rather than provisioning a new slot. + const still = useAccountStore.getState().getAccountById(accountId); + expect(still).toBeDefined(); + expect(still!.cookieSlot).toBe(originalSlot); + expect(useAccountStore.getState().accounts).toHaveLength(1); + }); + + it('routes the 401 refresh path through softSignOut (not through logout eviction)', async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === '/api/auth/token?slot=0' && method === 'PUT') { + return { ok: false, status: 401, json: async () => ({}) }; + } + if (method === 'DELETE') { + return { ok: true, json: async () => ({}) }; + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + + const accountStore = useAccountStore.getState(); + accountStore.addAccount({ + label: 'luc@undust.co', + serverUrl: 'https://mail.undust.co', + username: 'luc@undust.co', + authMode: 'oauth', + rememberMe: true, + displayName: 'Luc', + email: 'luc@undust.co', + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: true, + }); + const accountId = useAccountStore.getState().accounts[0].id; + + useAuthStore.setState({ + isAuthenticated: true, + authMode: 'oauth', + activeAccountId: accountId, + }); + + await useAuthStore.getState().refreshAccessToken(); + await vi.runAllTimersAsync(); + + // The account entry survives — this is the whole point of soft signout. + const survivor = useAccountStore.getState().getAccountById(accountId); + expect(survivor).toBeDefined(); + expect(survivor!.needsReauth).toBe(true); + }); + + it('when no account is bound, the refresh 401 path still falls back to full logout', async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === '/api/auth/token?slot=0' && method === 'PUT') { + return { ok: false, status: 401, json: async () => ({}) }; + } + if (method === 'DELETE') return { ok: true, json: async () => ({}) }; + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + + // No account entry → activeAccountId=null. The defensive branch keeps the + // original logout behaviour so an orphaned session doesn't get stuck. + useAuthStore.setState({ + isAuthenticated: true, + authMode: 'oauth', + activeAccountId: null, + }); + + await useAuthStore.getState().refreshAccessToken(); + await vi.runAllTimersAsync(); + + expect(sessionStorage.getItem('session_expired')).toBe('true'); + expect(replaceSpy).toHaveBeenCalled(); + }); + + it('a soft-signed-out account is re-armed (needsReauth cleared) on the next successful login', () => { + const accountStore = useAccountStore.getState(); + accountStore.addAccount({ + label: 'luc@undust.co', + serverUrl: 'https://mail.undust.co', + username: 'luc@undust.co', + authMode: 'oauth', + rememberMe: true, + displayName: 'Luc', + email: 'luc@undust.co', + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: true, + }); + const accountId = useAccountStore.getState().accounts[0].id; + + // Simulate a prior softSignOut having flipped the flag. + accountStore.updateAccount(accountId, { + needsReauth: true, + hasError: true, + errorMessage: 'session_expired', + }); + + // The login success path (auth-store.ts) hard-sets needsReauth=false on + // the updateAccount call — mirror the same shape here so the assertion + // reflects the production sequence. + accountStore.updateAccount(accountId, { + authMode: 'oauth', + rememberMe: true, + isConnected: true, + hasError: false, + errorMessage: undefined, + needsReauth: false, + lastLoginAt: Date.now(), + }); + + const acc = useAccountStore.getState().getAccountById(accountId); + expect(acc!.needsReauth).toBe(false); + expect(acc!.hasError).toBe(false); + expect(acc!.errorMessage).toBeUndefined(); + }); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index 49835fe4..0880535f 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -37,6 +37,15 @@ export interface AccountEntry { /** Whether this account had a connection error */ hasError: boolean; errorMessage?: string; + /** + * The account is signed out (refresh token rejected or basic-auth session + * cookie missing / decrypt-failed) but not evicted. Settings, identities, + * push subscriptions and offline cache stay put; the switcher shows a + * "Sign in again" affordance instead of the account label so a single tap + * restores the session without re-adding the account. Cleared on the next + * successful login for this account. + */ + needsReauth?: boolean; /** Whether this is the default account (loaded on app start) */ isDefault: boolean; } diff --git a/stores/auth-store.ts b/stores/auth-store.ts index f302a0cc..16a42570 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -45,6 +45,15 @@ interface AuthState { refreshAccessToken: () => Promise; logout: () => Promise; logoutAll: () => Promise; + /** + * Sign an account out without evicting it. Used when the server rejects a + * refresh token (or a basic-auth session cookie fails to decrypt): the + * account entry, its identities, subscriptions and cached settings all + * survive so the user re-signs in from the switcher rather than re-adding + * the account from scratch. `logout()` still exists for user-initiated + * sign-out and burns the account entry as before. + */ + softSignOut: (accountId: string, reason?: string) => void; removeAccount: (accountId: string) => void; switchAccount: (accountId: string) => Promise; checkAuth: () => Promise; @@ -711,6 +720,10 @@ export const useAuthStore = create()( isConnected: true, hasError: false, errorMessage: undefined, + // Reaching this success path means the credentials are good again; + // the soft-signed-out flag from a previous refresh failure must be + // cleared so the switcher stops showing "Sign in again". + needsReauth: false, lastLoginAt: Date.now(), }); @@ -1137,15 +1150,25 @@ export const useAuthStore = create()( const res = await apiFetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' }); if (!res.ok) { - // Only a definitive 401 ends the session. Anything else (5xx - // while the server restarts, proxy errors) is an outage - keep - // the session and retry shortly so "stay signed in" survives - // maintenance windows and offline spells. + // A definitive 401 from the token endpoint means the server + // dropped this refresh token (rotation, restart, actual + // revocation). Previously this triggered a full logout that + // evicted the account entry and forced the user to re-enter + // both server and credentials from scratch. Soft-signout keeps + // settings/identities/subscriptions and lets the switcher + // offer a one-tap re-auth. Anything else (5xx, proxy errors) + // remains a transient outage and retries with backoff. if (res.status === 401) { resetRefreshBackoff(accountId ?? undefined); notifyParent('sso:session-expired'); markSessionExpired(); - get().logout(); + if (accountId) { + get().softSignOut(accountId, 'session_expired'); + } else { + // No active account bound (shouldn't happen for a running + // refresh, but be defensive) — fall back to full logout. + get().logout(); + } return null; } if (shouldRetryRefresh(accountId ?? undefined)) { @@ -1309,6 +1332,92 @@ export const useAuthStore = create()( redirectToLogin(); }, + // Sign an account out without evicting it: disconnect its client, mark + // it needsReauth=true (so the switcher offers "Sign in again to X"), + // clear the cookies its refresh path failed on, but keep the account + // entry — its settings, identities, subscriptions and cached data all + // stay put so the next sign-in restores everything. + // + // If the account is the active one, we behave like logout(): switch to + // any other connected account, otherwise clear the shell state and send + // the user to the login screen. The distinguishing feature is that the + // affected account's registry entry survives. + softSignOut: (accountId: string, reason?: string) => { + const state = get(); + const accountStore = useAccountStore.getState(); + const account = accountStore.getAccountById(accountId); + if (!account) return; + const slot = account.cookieSlot ?? 0; + const wasOAuth = account.authMode === 'oauth'; + const wasActive = state.activeAccountId === accountId; + + // Kill the client and any pending refresh timers first so no stale + // requests fire against a session we know is dead. + clearRefreshTimer(accountId); + const client = clients.get(accountId); + if (client) { try { client.disconnect(); } catch { /* noop */ } } + clients.delete(accountId); + evictAccount(accountId); + + accountStore.updateAccount(accountId, { + isConnected: false, + hasError: true, + needsReauth: true, + errorMessage: reason || 'session_expired', + }); + + // Drop the cookies the server rejected. Not doing this leaves a stale + // encrypted blob around; a later restore attempt would loop on the + // same failure. + apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + if (wasOAuth) { + apiFetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + } + + if (!wasActive) return; + + // Active-account signout: prefer switching to another still-connected + // account. Snapshot the current account's per-store data before we + // clear the shell so a hypothetical later switch-back finds it intact. + snapshotAccount(accountId); + + const nextAccount = accountStore.accounts.find( + (a) => a.id !== accountId && clients.has(a.id) && !a.needsReauth, + ); + + if (nextAccount) { + const nextClient = clients.get(nextAccount.id)!; + clearAllStores(); + const restored = restoreAccount(nextAccount.id); + accountStore.setActiveAccount(nextAccount.id); + const restoredIdentities = restored ? useIdentityStore.getState().identities : []; + set({ + isAuthenticated: true, + isLoading: false, + serverUrl: nextAccount.serverUrl, + username: nextAccount.username, + client: nextClient, + authMode: nextAccount.authMode, + rememberMe: nextAccount.rememberMe, + connectionLost: false, + error: null, + activeAccountId: nextAccount.id, + identities: restoredIdentities, + primaryIdentity: restoredIdentities[0] ?? null, + }); + if (!restored) { + initializeFeatureStores(nextClient); + } + return; + } + + // Nothing to switch to: clear the shell and send the user to login. + // The account entry survives, so the login page can show it in a + // "Sign in again to " affordance once that UI ships. + performFullLogout(set); + redirectToLogin(); + }, + // Remove a specific (typically non-active) account: tear down its client, // drop it from the registry, and clear its per-slot cookies. If asked to // remove the active account, defer to logout() which handles switching @@ -1721,11 +1830,24 @@ export const useAuthStore = create()( }); continue; } - // Remove unrestorable accounts so the user is prompted to log in - // again rather than seeing a stale error entry forever. + // Definitive rejection (401/400 from token refresh, or a basic + // session cookie that failed to decrypt): mark the account + // needsReauth and drop the cookies, but keep the entry so the + // switcher can offer a one-tap re-auth. Full eviction — the old + // behaviour, which threw away identities, subscriptions and + // synced settings — is now user-initiated only, via + // removeAccount / logout. evictAccount(account.id); - accountStore.removeAccount(account.id); + accountStore.updateAccount(account.id, { + isConnected: false, + hasError: true, + needsReauth: true, + errorMessage: err instanceof Error ? err.message : 'session_expired', + }); apiFetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'DELETE' }).catch(() => {}); + if (account.authMode === 'oauth') { + apiFetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'DELETE' }).catch(() => {}); + } } }