diff --git a/eslint.config.mjs b/eslint.config.mjs index fc93951080..57ad41f70f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,37 @@ const config = [ 'react-hooks/immutability': 'off', }, }, + // P1.5 — Discourage bare `fetch('/api/...')`. + // The team must migrate to `apiFetch()` from `@/lib/api-client` so that + // 401 / 403 / 5xx / network failures are handled uniformly. + // Phase 1 (this PR): warn level, allow incremental migration. + // Phase 3 (final cleanup): upgrade to error and forbid merges that introduce + // new bare fetch('/api/...') sites. + // Selector rationale: + // - covers single-quoted, double-quoted, and template-literal forms + // - filters by /api prefix so cross-origin / external fetches stay untouched + // - exempts api-client.ts itself (the one allowed implementer) + { + files: ['src/**/*.{ts,tsx,js,jsx}'], + ignores: ['src/lib/api-client.ts'], + rules: { + 'no-restricted-syntax': [ + 'warn', + { + selector: + "CallExpression[callee.name='fetch'] > Literal[value=/^\\/api\\//]", + message: + "Use apiFetch() from '@/lib/api-client' instead of bare fetch('/api/...'). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly. See PR-api-client.md.", + }, + { + selector: + "CallExpression[callee.name='fetch'] > TemplateLiteral.arguments:first-child[quasis.0.value.raw=/^\\/api\\//]", + message: + "Use apiFetch() from '@/lib/api-client' instead of bare fetch(`/api/...`). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly.", + }, + ], + }, + }, ] export default config diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e80a841622..70401fa04d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { NextIntlClientProvider } from 'next-intl' import { getLocale, getMessages } from 'next-intl/server' import { THEME_IDS } from '@/lib/themes' import { ThemeBackground } from '@/components/ui/theme-background' +import { AuthExpiredListener } from '@/components/auth-expired-listener' import './globals.css' const inter = Inter({ @@ -114,6 +115,7 @@ export default async function RootLayout({ disableTransitionOnChange > +
{children}
diff --git a/src/components/auth-expired-listener.tsx b/src/components/auth-expired-listener.tsx new file mode 100644 index 0000000000..f376983084 --- /dev/null +++ b/src/components/auth-expired-listener.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useEffect } from 'react' + +/** + * Listens for `mc:auth-expired` CustomEvent dispatched by `apiFetch()` when the + * server returns 401. The redirect to `/login?from=...` is already handled inside + * `apiFetch`; this listener exists so we have a single observability hook the + * team can extend (toast, telemetry, Sentry). + * + * Mounted once at the root layout. SSR-safe (effect runs only on the client). + * + * Why a separate component? + * - layout.tsx is a server component (uses `await headers()`); we cannot + * attach window listeners there directly. + * - Co-locating the listener with the api-client keeps the auth-failure + * contract in one place. + */ +export function AuthExpiredListener(): null { + useEffect(() => { + const onExpired = (e: Event) => { + const detail = (e as CustomEvent<{ path: string; status: number }>).detail + // No toast lib installed yet — log for now. Replace with sonner in next PR. + console.warn( + `[mc] session expired on ${detail?.path ?? 'unknown'} (status=${detail?.status ?? 401}), redirecting to /login` + ) + } + window.addEventListener('mc:auth-expired', onExpired) + return () => window.removeEventListener('mc:auth-expired', onExpired) + }, []) + + return null +} diff --git a/src/components/panels/multi-gateway-panel.tsx b/src/components/panels/multi-gateway-panel.tsx index 07e4aefe86..a5b9295835 100644 --- a/src/components/panels/multi-gateway-panel.tsx +++ b/src/components/panels/multi-gateway-panel.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { useMissionControl } from '@/store' import { useWebSocket } from '@/lib/websocket' +import { apiFetch } from '@/lib/api-client' interface Gateway { id: number @@ -84,8 +85,7 @@ export function MultiGatewayPanel() { const fetchGateways = useCallback(async () => { try { - const res = await fetch('/api/gateways') - const data = await res.json() + const data = await apiFetch<{ gateways?: Gateway[] }>('/api/gateways') setGateways(data.gateways || []) } catch { /* ignore */ } setLoading(false) @@ -93,24 +93,21 @@ export function MultiGatewayPanel() { const fetchDirectConnections = useCallback(async () => { try { - const res = await fetch('/api/connect') - const data = await res.json() + const data = await apiFetch<{ connections?: DirectConnection[] }>('/api/connect') setDirectConnections(data.connections || []) } catch { /* ignore */ } }, []) const fetchDiscovered = useCallback(async () => { try { - const res = await fetch('/api/gateways/discover') - const data = await res.json() + const data = await apiFetch<{ gateways?: DiscoveredGateway[] }>('/api/gateways/discover') setDiscoveredGateways(data.gateways || []) } catch { /* ignore */ } }, []) const fetchHistory = useCallback(async () => { try { - const res = await fetch('/api/gateways/health/history') - const data = await res.json() + const data = await apiFetch<{ history?: GatewayHistory[] }>('/api/gateways/health/history') const map: Record = {} for (const entry of data.history || []) { map[entry.gatewayId] = entry @@ -141,40 +138,41 @@ export function MultiGatewayPanel() { !gateways.some(gatewayMatchesConnection) const setPrimary = async (gw: Gateway) => { - await fetch('/api/gateways', { + await apiFetch('/api/gateways', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: gw.id, is_primary: 1 }), - }) + }).catch(() => {}) fetchGateways() fetchHistory() } const deleteGateway = async (id: number) => { - await fetch('/api/gateways', { + await apiFetch('/api/gateways', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), - }) + }).catch(() => {}) fetchGateways() fetchHistory() } const updateToken = async (gw: Gateway, token: string) => { - await fetch('/api/gateways', { + await apiFetch('/api/gateways', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: gw.id, token }), - }) + }).catch(() => {}) fetchGateways() } const connectTo = async (gw: Gateway) => { try { - const res = await fetch('/api/gateways/connect', { + const res = await apiFetch('/api/gateways/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: gw.id }), + raw: true, }) if (!res.ok) return const payload = await res.json() @@ -192,8 +190,7 @@ export function MultiGatewayPanel() { const probeAll = async () => { try { - const res = await fetch("/api/gateways/health", { method: "POST" }) - const data = await res.json().catch(() => ({})) + const data = await apiFetch<{ results?: GatewayHealthProbe[] }>("/api/gateways/health", { method: "POST" }) const rows = Array.isArray(data?.results) ? data.results as GatewayHealthProbe[] : [] const mapped = new Map() for (const row of rows) { @@ -213,7 +210,7 @@ export function MultiGatewayPanel() { const disconnectCli = async (connectionId: string) => { try { - await fetch('/api/connect', { + await apiFetch('/api/connect', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId }), @@ -350,7 +347,7 @@ export function MultiGatewayPanel() {