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 }),
+}));