From d507605a24995378d076b3e871fbf2cc5f4a9b0c Mon Sep 17 00:00:00 2001 From: keysersoft Date: Wed, 30 Sep 2026 22:40:37 +0200 Subject: [PATCH] Frontend: a request without an answer no longer signs people out A Mac woke from sleep before its network was back; the session check failed with Safari's 'Load failed' and the app deleted the saved session and sent the user to /login, offline, where the page fell into the global error screen. Only a 4xx from the backend now ends the session; on no answer or a 5xx the session is kept and re-checked when the browser is back online. The error boundaries recognise network failures, report them as warnings and reload the page by themselves once the connection returns. Fixes ANYTHINGMCP-CLOUD-FRONTEND-7 --- packages/frontend/src/app/error.tsx | 21 +++-- packages/frontend/src/app/global-error.tsx | 21 ++++- packages/frontend/src/lib/auth-context.tsx | 40 ++++++--- packages/frontend/src/lib/network-error.ts | 55 ++++++++++++ .../frontend/tests/e2e/network-drop.spec.ts | 87 +++++++++++++++++++ 5 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 packages/frontend/src/lib/network-error.ts create mode 100644 packages/frontend/tests/e2e/network-drop.spec.ts diff --git a/packages/frontend/src/app/error.tsx b/packages/frontend/src/app/error.tsx index a475c6ca..6890dd64 100644 --- a/packages/frontend/src/app/error.tsx +++ b/packages/frontend/src/app/error.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import * as Sentry from '@sentry/nextjs'; +import { isNetworkError, reloadWhenOnline } from '@/lib/network-error'; /** * Global error boundary. Renders for any uncaught exception inside an App @@ -19,16 +20,23 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { + const offline = isNetworkError(error); + useEffect(() => { - // Reported with its stack; a no-op when Sentry is not configured. - Sentry.captureException(error, { tags: { boundary: 'segment' } }); + // Reported with its stack; a no-op when Sentry is not configured. A + // dropped connection is counted as a warning and retried by itself. + Sentry.captureException(error, { + level: offline ? 'warning' : 'error', + tags: { boundary: 'segment', network: offline ? 'yes' : 'no' }, + }); // Avoid leaking stack traces; just record that something tripped. if (typeof window !== 'undefined' && error?.digest) { console.error(`[error.tsx] uncaught error (digest=${error.digest})`); } else { console.error('[error.tsx] uncaught error', error); } - }, [error]); + return offline ? reloadWhenOnline() : undefined; + }, [error, offline]); return (

- Something went wrong + {offline ? 'Connection lost' : 'Something went wrong'}

- The page hit an unexpected error. You can retry, or go back to the - dashboard. + {offline + ? 'The page could not reach the server. It reloads by itself when your connection is back.' + : 'The page hit an unexpected error. You can retry, or go back to the dashboard.'}

{error?.digest ? ( diff --git a/packages/frontend/src/app/global-error.tsx b/packages/frontend/src/app/global-error.tsx index b9bcd848..272b1a46 100644 --- a/packages/frontend/src/app/global-error.tsx +++ b/packages/frontend/src/app/global-error.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import * as Sentry from '@sentry/nextjs'; +import { isNetworkError, reloadWhenOnline } from '@/lib/network-error'; /** * Last-resort boundary: renders when the root layout itself throws, where @@ -13,9 +14,17 @@ export default function GlobalError({ }: { error: Error & { digest?: string }; }) { + const offline = isNetworkError(error); + useEffect(() => { - Sentry.captureException(error, { tags: { boundary: 'global' } }); - }, [error]); + // A dropped connection is not a bug in the page: keep counting it, as a + // warning, and reload by ourselves once the network is back. + Sentry.captureException(error, { + level: offline ? 'warning' : 'error', + tags: { boundary: 'global', network: offline ? 'yes' : 'no' }, + }); + return offline ? reloadWhenOnline() : undefined; + }, [error, offline]); return ( @@ -31,9 +40,13 @@ export default function GlobalError({ }} >
-

Something went wrong

+

+ {offline ? 'Connection lost' : 'Something went wrong'} +

- AnythingMCP could not load this page. Reload to try again. + {offline + ? 'AnythingMCP could not reach the server. The page reloads by itself when your connection is back.' + : 'AnythingMCP could not load this page. Reload to try again.'}

{error?.digest ? (

diff --git a/packages/frontend/src/lib/auth-context.tsx b/packages/frontend/src/lib/auth-context.tsx index c710e459..c8b257aa 100644 --- a/packages/frontend/src/lib/auth-context.tsx +++ b/packages/frontend/src/lib/auth-context.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { useRouter, usePathname } from 'next/navigation'; -import { users, server, organizations, AUTH_EXPIRED_EVENT } from './api'; +import { users, server, organizations, AUTH_EXPIRED_EVENT, ApiError } from './api'; import { storage } from './storage'; interface User { @@ -95,19 +95,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { setToken(savedToken); setUser(JSON.parse(savedUser)); - users.me(savedToken).then((freshUser) => { - setUser(freshUser); - storage.set('amcp_user', JSON.stringify(freshUser)); - fetchOrgData(savedToken); - setIsLoading(false); - }).catch(() => { - setToken(null); - setUser(null); - storage.remove('amcp_token'); - storage.remove('amcp_user'); - document.cookie = 'amcp_token=; path=/; max-age=0'; - setIsLoading(false); - }); + const refresh = () => + users.me(savedToken).then((freshUser) => { + setUser(freshUser); + storage.set('amcp_user', JSON.stringify(freshUser)); + fetchOrgData(savedToken); + }); + + refresh().catch((err) => { + if (err instanceof ApiError && err.status < 500) { + // The backend answered and rejected the session. + setToken(null); + setUser(null); + storage.remove('amcp_token'); + storage.remove('amcp_user'); + document.cookie = 'amcp_token=; path=/; max-age=0'; + return; + } + // No answer (offline, a laptop waking before its network) or a 5xx + // during a deploy says nothing about the session. Signing out here + // sent people to /login, offline, for a token that was still valid + // (ANYTHINGMCP-CLOUD-FRONTEND-7). Keep the saved session and check + // it again once the connection is back; a real 401 still signs out + // through AUTH_EXPIRED_EVENT. + window.addEventListener('online', () => void refresh().catch(() => {}), { once: true }); + }).finally(() => setIsLoading(false)); } else { setIsLoading(false); } diff --git a/packages/frontend/src/lib/network-error.ts b/packages/frontend/src/lib/network-error.ts new file mode 100644 index 00000000..4c4a003c --- /dev/null +++ b/packages/frontend/src/lib/network-error.ts @@ -0,0 +1,55 @@ +/** + * Recognises the errors a browser raises when a request never got an answer: + * the machine went offline, woke from sleep before the network was back, or + * a script chunk could not be downloaded. Each engine words it differently. + */ +const NETWORK_ERROR_MESSAGES = [ + /^Load failed$/i, // Safari + /^Failed to fetch$/i, // Chrome, Edge + /^NetworkError when attempting to fetch resource\.?$/i, // Firefox + /^Network request failed$/i, + /^Importing a module script failed\.?$/i, // Safari, dynamic import + /^Failed to fetch dynamically imported module/i, // Chrome, dynamic import + /^Loading (CSS )?chunk [\w-]+ failed/i, // webpack/turbopack chunk loader +]; + +export function isNetworkError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const { name, message } = error as { name?: unknown; message?: unknown }; + if (name === 'ChunkLoadError') return true; + if (typeof message !== 'string') return false; + return NETWORK_ERROR_MESSAGES.some((re) => re.test(message.trim())); +} + +const RELOAD_KEY = 'amcp_network_reload_at'; +/** One automatic reload per this window, so a page that fails for another reason cannot loop. */ +const RELOAD_COOLDOWN_MS = 30_000; + +/** + * Reloads the page once the connection is back: at once if the browser says + * it is online, otherwise on the next `online` event. Returns a cleanup + * function. Does nothing if it already reloaded in the last 30 seconds. + */ +export function reloadWhenOnline(): () => void { + if (typeof window === 'undefined') return () => {}; + let last = 0; + try { + last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0; + } catch {} + if (Date.now() - last < RELOAD_COOLDOWN_MS) return () => {}; + + const reload = () => { + try { + sessionStorage.setItem(RELOAD_KEY, String(Date.now())); + } catch {} + window.location.reload(); + }; + + if (navigator.onLine) { + // A moment's grace: a wake from sleep often reports online before DNS works. + const timer = setTimeout(reload, 2_000); + return () => clearTimeout(timer); + } + window.addEventListener('online', reload, { once: true }); + return () => window.removeEventListener('online', reload); +} diff --git a/packages/frontend/tests/e2e/network-drop.spec.ts b/packages/frontend/tests/e2e/network-drop.spec.ts new file mode 100644 index 00000000..26908058 --- /dev/null +++ b/packages/frontend/tests/e2e/network-drop.spec.ts @@ -0,0 +1,87 @@ +import { expect, test, type Page } from '@playwright/test'; +import { isNetworkError } from '../../src/lib/network-error'; + +/** + * A request that gets no answer is not a rejected session. + * + * ANYTHINGMCP-CLOUD-FRONTEND-7: a Mac woke from sleep before its network was + * back, the session check (/api/users/me) failed with Safari's "Load failed", + * and the app treated that as an expired token: it deleted the saved session + * and sent the user to /login, offline, where the page broke. Only an answer + * from the backend that rejects the session may sign someone out. + */ + +const USER = { + id: 'u1', + email: 'admin@example.com', + name: 'Admin', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +async function savedSession(page: Page, me: 'network-error' | 'server-error' | 'unauthorized') { + const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; + await page.context().addCookies([{ name: 'amcp_token', value: 't', url: origin }]); + await page.addInitScript((user) => { + // Only on the first load: the sign-out path clears these on purpose. + if (sessionStorage.getItem('seeded')) return; + sessionStorage.setItem('seeded', '1'); + localStorage.setItem('amcp_token', 't'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, USER); + await page.route(/\/(api|health)\//, async (route) => { + const p = new URL(route.request().url()).pathname; + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + if (p === '/health/server-info') { + return json({ deploymentMode: 'self-hosted', mcpAuthMode: 'none', hasUsers: true, registrationEnabled: true, ssoProviders: [] }); + } + if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: new Date().toISOString() }); + if (p.endsWith('/users/me')) { + if (me === 'network-error') return route.abort('internetdisconnected'); + if (me === 'server-error') return json({ message: 'Bad Gateway' }, 502); + return json({ message: 'Unauthorized' }, 401); + } + if (p.endsWith('/license/status')) return json({ plan: 'business', status: 'active', features: {} }); + return json([]); + }); +} + +test.describe('session check without an answer', () => { + test('keeps the session when /users/me gets no answer', async ({ page }) => { + await savedSession(page, 'network-error'); + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + expect(new URL(page.url()).pathname).not.toBe('/login'); + expect(await page.evaluate(() => localStorage.getItem('amcp_token'))).toBe('t'); + }); + + test('keeps the session when the backend answers 502 during a deploy', async ({ page }) => { + await savedSession(page, 'server-error'); + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + expect(new URL(page.url()).pathname).not.toBe('/login'); + expect(await page.evaluate(() => localStorage.getItem('amcp_token'))).toBe('t'); + }); + + test('still signs out when the backend rejects the token', async ({ page }) => { + await savedSession(page, 'unauthorized'); + await page.goto('/settings'); + await expect(page).toHaveURL(/\/login/); + expect(await page.evaluate(() => localStorage.getItem('amcp_token'))).toBeNull(); + }); +}); + +test.describe('isNetworkError', () => { + test('recognises what each browser says when a request gets no answer', () => { + expect(isNetworkError(new TypeError('Load failed'))).toBe(true); + expect(isNetworkError(new TypeError('Failed to fetch'))).toBe(true); + expect(isNetworkError(new TypeError('NetworkError when attempting to fetch resource.'))).toBe(true); + expect(isNetworkError(new TypeError('Importing a module script failed.'))).toBe(true); + expect(isNetworkError(Object.assign(new Error('x'), { name: 'ChunkLoadError' }))).toBe(true); + expect(isNetworkError(new TypeError("Cannot read properties of undefined (reading 'id')"))).toBe(false); + expect(isNetworkError(new Error('Load failed because of X'))).toBe(false); + expect(isNetworkError(null)).toBe(false); + }); +});