diff --git a/src/components/layout/ConnectionBanner.tsx b/src/components/layout/ConnectionBanner.tsx new file mode 100644 index 0000000..18be2c3 --- /dev/null +++ b/src/components/layout/ConnectionBanner.tsx @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: shown while the session can't be refreshed because the server + * doesn't answer. The reader stays signed in and on the page; the refresh is + * retried on its own, and here on request. + */ + +import { useTranslation } from 'react-i18next'; +import { WifiOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { retryRefresh } from '@/services/api'; +import { useConnectionStore } from '@/stores/connectionStore'; + +export function ConnectionBanner() { + const { t } = useTranslation(); + const unreachable = useConnectionStore((s) => s.unreachable); + + if (!unreachable) return null; + + return ( +
+ +

+ {t('connection.unreachable', "Can't reach the server. You're still signed in, and it keeps trying.")} +

+ +
+ ); +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 442d20b..db81dbd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2337,5 +2337,9 @@ "check": "Check the connection", "searchFailed": "The directory opened, but searching it failed: {{error}}", "connected": "The server can connect to this directory and search it." + }, + "connection": { + "unreachable": "Can't reach the server. You're still signed in, and it keeps trying.", + "retry": "Try now" } } diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index ec3c84e..e016fce 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -25,6 +25,7 @@ import { Sidebar } from '@/components/layout/Sidebar'; import { SectionNav } from '@/components/layout/SectionNav'; import { MainContent } from '@/components/layout/MainContent'; import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner'; +import { ConnectionBanner } from '@/components/layout/ConnectionBanner'; import { ExplainPanel } from '@/features/ai/explain/ExplainPanel'; import { useExplainStore } from '@/features/ai/explain/explainStore'; import { explainAvailable } from '@/features/ai/explain/explain'; @@ -344,6 +345,7 @@ export default function AdminPanel() { !useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'), )} > + diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 7d47f7a..3e11da2 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -17,6 +17,7 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { startAuthFlow } from '@/services/auth/oauth'; +import { clearReturnPath, readReturnPath } from '@/services/auth/session'; import { SourceLink } from '@/components/common/SourceLink'; /** @@ -28,7 +29,9 @@ import { SourceLink } from '@/components/common/SourceLink'; export default function LoginPage() { const { t } = useTranslation(); const location = useLocation(); - const originalPath = (location.state as { from?: string } | null)?.from ?? null; + // The page before signing in: from the route guard, or kept by a session + // the server ended (see `endSession`). + const originalPath = (location.state as { from?: string } | null)?.from ?? readReturnPath(); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const started = useRef(false); @@ -40,6 +43,8 @@ export default function LoginPage() { setLoading(true); try { await startAuthFlow(null, originalPath); + // The sign-in round trip carries it from here. + clearReturnPath(); } catch (err) { setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred')); setLoading(false); diff --git a/src/services/api.test.ts b/src/services/api.test.ts new file mode 100644 index 0000000..77c5302 --- /dev/null +++ b/src/services/api.test.ts @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const { endSession } = vi.hoisted(() => ({ endSession: vi.fn() })); +vi.mock('./auth/session', () => ({ endSession })); + +// Fresh modules per test: the refresh keeps its retry state at module level. +async function load() { + vi.resetModules(); + const api = await import('./api'); + const { useAuthStore } = await import('@/stores/authStore'); + const { useConnectionStore } = await import('@/stores/connectionStore'); + return { ...api, useAuthStore, useConnectionStore }; +} + +const fetchMock = vi.fn(); +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +beforeEach(() => { + endSession.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + sessionStorage.clear(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('refreshAccessToken', () => { + it("stays signed in when the server doesn't answer", async () => { + const { refreshAccessToken, useAuthStore, useConnectionStore } = await load(); + useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token'); + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); + + await expect(refreshAccessToken()).rejects.toThrow('Failed to fetch'); + expect(endSession).not.toHaveBeenCalled(); + expect(useAuthStore.getState().refreshToken).toBe('ref'); + expect(useConnectionStore.getState().unreachable).toBe(true); + }); + + it.each([500, 502, 503, 429])('stays signed in on a %i', async (status) => { + const { refreshAccessToken, useAuthStore, useConnectionStore } = await load(); + useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token'); + fetchMock.mockResolvedValueOnce(new Response('busy', { status })); + + await expect(refreshAccessToken()).rejects.toThrow(); + expect(endSession).not.toHaveBeenCalled(); + expect(useConnectionStore.getState().unreachable).toBe(true); + }); + + it('stays signed in when a proxy answers with a page instead of a token', async () => { + const { refreshAccessToken, useAuthStore, useConnectionStore } = await load(); + useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token'); + fetchMock.mockResolvedValueOnce(new Response('maintenance', { status: 200 })); + + await expect(refreshAccessToken()).rejects.toThrow(); + expect(endSession).not.toHaveBeenCalled(); + expect(useConnectionStore.getState().unreachable).toBe(true); + }); + + it.each([400, 401, 403])('ends the session when the server refuses (%i)', async (status) => { + const { refreshAccessToken, useAuthStore, useConnectionStore } = await load(); + useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token'); + fetchMock.mockResolvedValueOnce(json({ error: 'invalid_grant' }, status)); + + await expect(refreshAccessToken()).rejects.toThrow(); + expect(endSession).toHaveBeenCalledOnce(); + expect(useConnectionStore.getState().unreachable).toBe(false); + }); + + it('tries again on its own and clears the note once the server answers', async () => { + vi.useFakeTimers(); + const { refreshAccessToken, useAuthStore, useConnectionStore } = await load(); + useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token'); + fetchMock + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce(json({ access_token: 'acc2', refresh_token: 'ref2', expires_in: 3600 })); + + await expect(refreshAccessToken()).rejects.toThrow(); + expect(useConnectionStore.getState().unreachable).toBe(true); + + await vi.advanceTimersByTimeAsync(5_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(useConnectionStore.getState().unreachable).toBe(false); + expect(useAuthStore.getState().accessToken).toBe('acc2'); + expect(useAuthStore.getState().refreshToken).toBe('ref2'); + }); + + it('stops retrying once signed out', async () => { + vi.useFakeTimers(); + const { refreshAccessToken, useAuthStore, useConnectionStore } = await load(); + useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token'); + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); + + await expect(refreshAccessToken()).rejects.toThrow(); + useAuthStore.getState().logout(); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(endSession).not.toHaveBeenCalled(); + expect(useConnectionStore.getState().unreachable).toBe(false); + }); +}); + +describe('apiFetch', () => { + it('still sends a request with a token that has not run out', async () => { + const { apiFetch, useAuthStore } = await load(); + // Expiring within the minute, so a refresh is tried first. + useAuthStore.getState().setTokens('acc', 'ref', 30, 'https://mail.test/token'); + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValueOnce(json({ ok: true })); + + const response = await apiFetch('/jmap/session'); + expect(response.status).toBe(200); + const [, init] = fetchMock.mock.calls[1]; + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer acc'); + }); + + it("reports the server can't be reached, not a 401, when a refresh gets no answer", async () => { + const { apiFetch, ApiError, useAuthStore } = await load(); + useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token'); + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockRejectedValueOnce(new TypeError('Failed to fetch')); + + const error = await apiFetch('/jmap/session').catch((e: unknown) => e); + expect(error).toBeInstanceOf(TypeError); + expect(error).not.toBeInstanceOf(ApiError); + expect(endSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/api.ts b/src/services/api.ts index bbb0a9f..f7a18ef 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -8,7 +8,8 @@ */ import { useAuthStore } from '../stores/authStore'; -import { getBasePath } from '@/lib/basePath'; +import { useConnectionStore } from '../stores/connectionStore'; +import { endSession } from './auth/session'; import { getOAuthClientId } from '@/lib/oauthClientId'; export function getApiBaseUrl(): string { @@ -41,24 +42,78 @@ export class ApiError extends Error { let refreshPromise: Promise | null = null; +/** + * inbuxa: the token endpoint turned the refresh token down (spent, revoked, + * or the account locked), so the session is over. Anything else, no answer + * at all, a 5xx or rate limiting, passes, and signing out over it would lose + * the page and whatever was typed into it. + */ +function isRefusal(status: number): boolean { + return status === 400 || status === 401 || status === 403; +} + +// inbuxa: a refresh that failed without a refusal is tried again on its own: +// after 5, 10 and 20 seconds, then every minute, and at once when the browser +// says it is back online. +const RETRY_DELAYS = [5_000, 10_000, 20_000, 60_000]; +let retryTimer: ReturnType | null = null; +let retryCount = 0; +let listeningForOnline = false; + +/** Try the refresh again now, as the retry timer, the browser coming online or the reader would. */ +export function retryRefresh(): void { + if (!useAuthStore.getState().refreshToken) { + stopRetrying(); + return; + } + refreshAccessToken().catch(() => {}); +} + +function serverUnreachable(): void { + useConnectionStore.getState().setUnreachable(true); + if (!listeningForOnline) { + listeningForOnline = true; + window.addEventListener('online', () => { + if (useConnectionStore.getState().unreachable) retryRefresh(); + }); + } + if (retryTimer) return; + const delay = RETRY_DELAYS[Math.min(retryCount, RETRY_DELAYS.length - 1)]; + retryCount += 1; + retryTimer = setTimeout(() => { + retryTimer = null; + retryRefresh(); + }, delay); +} + +function stopRetrying(): void { + retryCount = 0; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + useConnectionStore.getState().setUnreachable(false); +} + export async function refreshAccessToken(): Promise { if (refreshPromise) { return refreshPromise; } refreshPromise = (async () => { - const { refreshToken, tokenEndpoint, logout } = useAuthStore.getState(); + const { refreshToken, tokenEndpoint } = useAuthStore.getState(); if (!refreshToken || !tokenEndpoint) { - logout(); - window.location.href = `${getBasePath()}/login`; + stopRetrying(); + endSession(); throw new Error('No refresh token or token endpoint available'); } const clientId = getOAuthClientId(); + let response: Response; try { - const response = await fetch(tokenEndpoint, { + response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -67,20 +122,44 @@ export async function refreshAccessToken(): Promise { client_id: clientId, }), }); - - if (!response.ok) { - throw new Error(`Token refresh failed: ${response.status}`); - } - - const data = await response.json(); - useAuthStore - .getState() - .setTokens(data.access_token, data.refresh_token ?? refreshToken, data.expires_in, tokenEndpoint); } catch (error) { - useAuthStore.getState().logout(); - window.location.href = `${getBasePath()}/login`; + // No answer: a dropped connection, the server restarting, a laptop waking up. + serverUnreachable(); throw error; } + + if (!response.ok) { + if (isRefusal(response.status)) { + stopRetrying(); + endSession(); + } else { + serverUnreachable(); + } + throw new Error(`Token refresh failed: ${response.status}`); + } + + let data: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; + try { + data = await response.json(); + } catch (error) { + // A proxy's error page rather than the server's answer. + serverUnreachable(); + throw error; + } + if (typeof data.access_token !== 'string' || typeof data.expires_in !== 'number') { + serverUnreachable(); + throw new Error('Token refresh answered without a token'); + } + + useAuthStore + .getState() + .setTokens( + data.access_token, + typeof data.refresh_token === 'string' ? data.refresh_token : refreshToken, + data.expires_in, + tokenEndpoint, + ); + stopRetrying(); })(); try { @@ -94,7 +173,12 @@ export async function apiFetch(path: string, options?: RequestInit): Promise => { @@ -121,13 +205,14 @@ export async function apiFetch(path: string, options?: RequestInit): Promise { + beforeEach(() => { + sessionStorage.clear(); + useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token'); + }); + + it('signs out, goes to sign-in and keeps the page to come back to', () => { + window.history.pushState({}, '', '/management/x:Domain?page=2'); + const go = vi.fn(); + endSession(go); + expect(go).toHaveBeenCalledWith('/login'); + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(useAuthStore.getState().refreshToken).toBeNull(); + expect(readReturnPath()).toBe('/management/x:Domain?page=2'); + }); + + it("doesn't come back to the sign-in pages", () => { + sessionStorage.setItem('inbuxa_admin_return_path', '/stale'); + window.history.pushState({}, '', '/oauth/callback?code=c&state=s'); + endSession(vi.fn()); + expect(readReturnPath()).toBeNull(); + }); + + it('is cleared once sign-in has taken it', () => { + window.history.pushState({}, '', '/settings'); + endSession(vi.fn()); + clearReturnPath(); + expect(readReturnPath()).toBeNull(); + }); +}); diff --git a/src/services/auth/session.ts b/src/services/auth/session.ts new file mode 100644 index 0000000..f8f151c --- /dev/null +++ b/src/services/auth/session.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// When the server ends a session (it refused to refresh it), signing in again +// brings the reader back to the page they were on. The way out is a full page +// load, which drops the router's state, so the page is kept in sessionStorage +// for that one trip. + +import { useAuthStore } from '@/stores/authStore'; +import { getBasePath } from '@/lib/basePath'; + +const RETURN_KEY = 'inbuxa_admin_return_path'; + +function isAuthPath(path: string): boolean { + return ( + path === '/login' || + path.startsWith('/login?') || + path === '/oauth/callback' || + path.startsWith('/oauth/callback?') + ); +} + +/** Sign out because the server ended the session, coming back here after signing in again. */ +export function endSession( + go: (url: string) => void = (url) => { + window.location.href = url; + }, +): void { + const basePath = getBasePath(); + const here = window.location.pathname + window.location.search; + const path = basePath && here.startsWith(basePath) ? here.slice(basePath.length) || '/' : here; + try { + if (isAuthPath(path)) sessionStorage.removeItem(RETURN_KEY); + else sessionStorage.setItem(RETURN_KEY, path); + } catch { + // Storage off: signing in lands on the dashboard. + } + useAuthStore.getState().logout(); + go(`${basePath}/login`); +} + +/** The page an ended session was on, if signing in hasn't taken it back there yet. */ +export function readReturnPath(): string | null { + try { + return sessionStorage.getItem(RETURN_KEY); + } catch { + return null; + } +} + +export function clearReturnPath(): void { + try { + sessionStorage.removeItem(RETURN_KEY); + } catch { + // Nothing kept, nothing to clear. + } +} diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts new file mode 100644 index 0000000..f4ed2d1 --- /dev/null +++ b/src/stores/connectionStore.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { create } from 'zustand'; + +interface ConnectionState { + /** The last token refresh got no answer from the server, or a passing error. */ + unreachable: boolean; + setUnreachable: (unreachable: boolean) => void; +} + +export const useConnectionStore = create()((set) => ({ + unreachable: false, + setUnreachable: (unreachable) => set({ unreachable }), +}));