From 611717795732b8c5e193dffc62a28e9ef0b3ba63 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 08:23:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20polish=20web=20(logout,=20401?= =?UTF-8?q?=E2=86=92pairing,=20reconnexion=20globale)=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow logout : POST /api/logout puis retour à l'écran de pairing et déconnexion du live. 401 renvoie au pairing sans boucle. Bannière de reconnexion couvrant le cas live-only et le serveur indisponible. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/http/index.ts | 27 ++++++-- frontend/src/adapters/http/webLive.ts | 9 +++ frontend/src/adapters/http/webSession.test.ts | 29 +++++++++ frontend/src/adapters/http/webSession.ts | 20 ++++++ frontend/src/adapters/http/wsLiveClient.ts | 48 +++++++++++++- .../http/wsLiveClientReconnect.test.ts | 62 +++++++++++++++++++ frontend/src/features/web/WebApp.test.tsx | 26 ++++++++ frontend/src/features/web/WebApp.tsx | 28 ++++++--- frontend/src/features/web/WebWorkspace.tsx | 24 +++++++ .../features/web/useLiveConnectionState.ts | 29 +++++++++ 10 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 frontend/src/features/web/useLiveConnectionState.ts diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 57af01d..0f1871b 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -79,14 +79,33 @@ function resolveEndpoints(config: HttpWsGatewaysConfig): { */ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways { const { baseUrl, wsUrl } = resolveEndpoints(config); - // Route 401s back to the pairing screen via the shared web session (F2). + // Route 401s back to the pairing screen via the shared web session (F2/F6). const session = getWebSession(); + // `ws` is referenced by the invoker's 401 handler (declared below); the closure + // only runs after both are constructed, so the forward reference is safe. + let ws: WsLiveClient; const http = new HttpInvoker({ baseUrl, token: config.token, - onUnauthorized: () => session.notifyUnauthorized(), + onUnauthorized: () => { + // F6: a 401 means the session cookie is gone/revoked. Route back to pairing + // AND tear down the live socket so it stops its (now hopeless) reconnect + // loop. Re-pairing brings the same client back up cleanly. + session.notifyUnauthorized(); + ws?.disconnect(); + }, + }); + ws = new WsLiveClient({ + wsUrl, + token: config.token, + // F6: when a WS reconnect fails we cannot read its HTTP status, so probe with + // a cheap `health` call. A revoked session answers `401` → the invoker's + // `onUnauthorized` above bounces to pairing; a network error is swallowed and + // the WS backoff keeps retrying. + onReconnectFailed: () => { + void http.invoke("health", { request: null }).catch(() => {}); + }, }); - const ws = new WsLiveClient({ wsUrl, token: config.token }); // Share the live client with the web feature so live surfaces can re-sync on // reconnect (F5). Inert on desktop (never called there). setWebLiveClient(ws); @@ -120,5 +139,5 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway export { HttpInvoker } from "./httpInvoker"; export { WsLiveClient } from "./wsLiveClient"; export { WebSession, getWebSession, setWebSessionForTests } from "./webSession"; -export { getWebLiveClient, setWebLiveClient } from "./webLive"; +export { getWebLiveClient, setWebLiveClient, disconnectWebLive } from "./webLive"; export type { ConnectionState } from "./wsLiveClient"; diff --git a/frontend/src/adapters/http/webLive.ts b/frontend/src/adapters/http/webLive.ts index 0241beb..628d9dc 100644 --- a/frontend/src/adapters/http/webLive.ts +++ b/frontend/src/adapters/http/webLive.ts @@ -26,3 +26,12 @@ export function setWebLiveClient(client: WsLiveClient | null): void { export function getWebLiveClient(): WsLiveClient | null { return liveClient; } + +/** + * Soft-disconnects the shared live client (sign-out / auth bounce — F6): tears + * down the socket and stops the reconnect loop but keeps the client reusable, so + * re-pairing reconnects cleanly. Inert (no client registered) on desktop. + */ +export function disconnectWebLive(): void { + liveClient?.disconnect(); +} diff --git a/frontend/src/adapters/http/webSession.test.ts b/frontend/src/adapters/http/webSession.test.ts index afef6d2..d5092e7 100644 --- a/frontend/src/adapters/http/webSession.test.ts +++ b/frontend/src/adapters/http/webSession.test.ts @@ -90,3 +90,32 @@ describe("WebSession unauthorized handling", () => { expect(session.isPaired()).toBe(false); }); }); + +describe("WebSession logout", () => { + it("POSTs /api/logout same-origin and clears the flag on success", async () => { + const store = memStore(); + const { fetchImpl, calls } = fetchReturning(200, { revoked: true }); + const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); + session.markPaired(); + + await session.logout(); + + expect(session.isPaired()).toBe(false); + expect(calls[0].url).toBe("https://host/api/logout"); + const init = calls[0].init as { method: string; credentials: string }; + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("same-origin"); + }); + + it("still clears the flag when the server is unreachable (best-effort)", async () => { + const store = memStore(); + const fetchImpl: FetchLike = async () => { + throw new Error("network down"); + }; + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); + session.markPaired(); + + await expect(session.logout()).resolves.toBeUndefined(); + expect(session.isPaired()).toBe(false); + }); +}); diff --git a/frontend/src/adapters/http/webSession.ts b/frontend/src/adapters/http/webSession.ts index 810e5f2..e3fdf67 100644 --- a/frontend/src/adapters/http/webSession.ts +++ b/frontend/src/adapters/http/webSession.ts @@ -96,6 +96,26 @@ export class WebSession { this.store.removeItem(PAIRED_FLAG_KEY); } + /** + * Full sign-out (F6): asks the server to revoke the session cookie + * (`POST /api/logout`, same-origin so the cookie rides along) and then clears + * the local flag. Best-effort — a network failure or an already-expired session + * still clears the flag locally, so the UI always returns to pairing without a + * dead end. Disconnecting the live WS is the caller's job (composition seam). + */ + async logout(): Promise { + try { + await this.fetchImpl(`${this.baseUrl}/api/logout`, { + method: "POST", + credentials: "same-origin", + }); + } catch { + // Server unreachable / already gone: fall through to the local clear. + } finally { + this.forget(); + } + } + /** * Performs the pairing handshake: `POST /api/pair {code}`. On success the * server sets the session cookie and this records the paired flag. A wrong code diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index 96056d2..ffa992b 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -78,6 +78,14 @@ export interface WsLiveClientConfig { setTimeoutImpl?: SetTimeoutLike; /** Injected `clearTimeout`. */ clearTimeoutImpl?: ClearTimeoutLike; + /** + * Called after a reconnection attempt fails. The browser cannot read the HTTP + * status of a rejected WS upgrade, so an auth-revoked socket looks like any + * other outage from here. F6 wires this to a cheap HTTP probe (`health`): a + * `401` there routes back to pairing (via the invoker's `onUnauthorized`), + * while a network error just lets the backoff keep retrying. + */ + onReconnectFailed?: () => void; } /** A live terminal subscription tracked for output routing + reconnection replay. */ @@ -137,6 +145,7 @@ export class WsLiveClient { private readonly reconnectMaxMs: number; private readonly setTimeoutImpl: SetTimeoutLike; private readonly clearTimeoutImpl: ClearTimeoutLike; + private readonly onReconnectFailed?: () => void; private socket: WebSocketLike | null = null; private opening: Promise | null = null; @@ -172,6 +181,7 @@ export class WsLiveClient { config.setTimeoutImpl ?? ((fn, ms) => setTimeout(fn, ms)); this.clearTimeoutImpl = config.clearTimeoutImpl ?? ((h) => clearTimeout(h as ReturnType)); + this.onReconnectFailed = config.onReconnectFailed; } // ------------------------------------------------------------------------- @@ -274,7 +284,7 @@ export class WsLiveClient { } /** Whether the socket should auto-reconnect (a terminal or a live subscription). */ - private needsReconnect(): boolean { + needsReconnect(): boolean { return this.terminals.size > 0 || this.domainEventHandler !== null; } @@ -297,7 +307,10 @@ export class WsLiveClient { await this.connect(); await this.reattachAll(); } catch { - // Still down: back off and retry (unless nothing needs the socket anymore). + // Still down: probe (an auth-revoked upgrade is indistinguishable from an + // outage here — the probe surfaces a 401 as a pairing bounce), then back + // off and retry (unless nothing needs the socket anymore). + this.onReconnectFailed?.(); if (this.needsReconnect()) this.scheduleReconnect(); } } @@ -479,6 +492,37 @@ export class WsLiveClient { return bytesToBase64(bytes); } + /** + * Soft teardown for a sign-out / auth bounce (F6): closes the socket, drops all + * live state and stops the reconnect loop, but — unlike {@link dispose} — leaves + * the client reusable. A later `ensureConnected` (after re-pairing) reconnects + * as a fresh `connecting`. The socket's own `onclose` is detached first so it + * cannot flip the state back to `reconnecting`. + */ + disconnect(): void { + if (this.reconnectHandle) { + this.clearTimeoutImpl(this.reconnectHandle); + this.reconnectHandle = null; + } + this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" }); + this.outputSinks.clear(); + this.terminals.clear(); + this.domainEventHandler = null; + const socket = this.socket; + if (socket) { + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(); + } + this.socket = null; + this.opening = null; + this.everConnected = false; + this.reconnectAttempt = 0; + this.setConnState("closed"); + } + /** Closes the socket and rejects everything pending. */ dispose(): void { this.disposed = true; diff --git a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts index d0084bf..c93803a 100644 --- a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts +++ b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts @@ -190,4 +190,66 @@ describe("WsLiveClient connection state + reconnection", () => { expect(h.ws.getConnectionState()).toBe("closed"); expect(h.hasTimer()).toBe(false); }); + + it("disconnect() closes and stops reconnecting but stays reusable (F6 sign-out)", async () => { + const h = harness(); + h.ws.setDomainEventHandler(() => {}); + await h.ws.ensureConnected(); + await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); + + // Sign-out: soft teardown. State is closed, the reconnect loop is dropped, and + // the detached socket's onclose cannot flip the state back to reconnecting. + h.ws.disconnect(); + expect(h.ws.getConnectionState()).toBe("closed"); + expect(h.hasTimer()).toBe(false); + expect(h.ws.needsReconnect()).toBe(false); + + // Reusable: a later ensureConnected reconnects as a fresh socket (re-pairing). + await h.ws.ensureConnected(); + await vi.waitFor(() => expect(h.sockets.length).toBe(2)); + await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); + }); + + it("calls onReconnectFailed when a reconnect attempt fails (auth probe seam)", async () => { + const failed = vi.fn(); + let openNextSocket = true; + let timerFn: (() => void) | null = null; + const sockets: FakeSocket[] = []; + const ws = new WsLiveClient({ + wsUrl: "wss://host", + reconnectBaseMs: 1, + onReconnectFailed: failed, + socketFactory: () => { + const s = new FakeSocket(); + sockets.push(s); + // First socket connects; the reconnect socket errors (upgrade rejected). + if (openNextSocket) queueMicrotask(() => s.onopen?.()); + else queueMicrotask(() => s.onerror?.(new Error("upgrade rejected"))); + return s; + }, + setTimeoutImpl: (fn) => { + timerFn = fn; + return 1; + }, + clearTimeoutImpl: () => { + timerFn = null; + }, + }); + + ws.setDomainEventHandler(() => {}); + await ws.ensureConnected(); + await vi.waitFor(() => expect(ws.getConnectionState()).toBe("connected")); + + // Drop the socket; the next connect attempt will fail the upgrade. + openNextSocket = false; + sockets[0].close(); + expect(ws.getConnectionState()).toBe("reconnecting"); + const fire = timerFn as (() => void) | null; + timerFn = null; + fire?.(); + + // The failed reconnect fires the probe hook and schedules another attempt. + await vi.waitFor(() => expect(failed).toHaveBeenCalled()); + expect(timerFn).not.toBeNull(); + }); }); diff --git a/frontend/src/features/web/WebApp.test.tsx b/frontend/src/features/web/WebApp.test.tsx index e85645c..8680705 100644 --- a/frontend/src/features/web/WebApp.test.tsx +++ b/frontend/src/features/web/WebApp.test.tsx @@ -31,6 +31,16 @@ const okFetch: FetchLike = async () => ({ text: async () => "{}", }); +/** Records fetch calls so a test can assert the logout POST. */ +function recordingFetch(): { fetchImpl: FetchLike; calls: string[] } { + const calls: string[] = []; + const fetchImpl: FetchLike = async (url) => { + calls.push(url); + return { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "{}" }; + }; + return { fetchImpl, calls }; +} + const rejectFetch: FetchLike = async () => ({ ok: false, status: 401, @@ -120,6 +130,22 @@ describe("WebApp pairing routing", () => { expect(await screen.findByTestId("web-agent-cell")).toBeTruthy(); }); + it("signs out via POST /api/logout and returns to pairing", async () => { + const { fetchImpl, calls } = recordingFetch(); + const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() }); + session.markPaired(); + renderWebApp(session, await seededGateways()); + + expect(await screen.findByText("Projets")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Se déconnecter" })); + + await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy()); + expect(calls).toContain("https://h/api/logout"); + expect(session.isPaired()).toBe(false); + expect(screen.queryByText("Projets")).toBeNull(); + }); + it("returns to pairing when the session reports unauthorized (401)", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); session.markPaired(); diff --git a/frontend/src/features/web/WebApp.tsx b/frontend/src/features/web/WebApp.tsx index 274f06d..b24e072 100644 --- a/frontend/src/features/web/WebApp.tsx +++ b/frontend/src/features/web/WebApp.tsx @@ -5,14 +5,15 @@ * Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen}; * paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's * `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing - * cookie) drops back to pairing automatically. A best-effort "Se déconnecter" - * clears the local flag (server-side cookie revocation is B8). + * cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the + * server session (`POST /api/logout`), tears down the live WS singleton, and + * returns to pairing. */ import { useEffect, useState } from "react"; import { Button } from "@/shared"; -import { getWebSession, type WebSession } from "@/adapters/http"; +import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; import { PairingScreen } from "./PairingScreen"; import { WebWorkspace } from "./WebWorkspace"; @@ -24,15 +25,26 @@ interface WebAppProps { export function WebApp({ session }: WebAppProps = {}) { const webSession = session ?? getWebSession(); const [paired, setPaired] = useState(() => webSession.isPaired()); + const [signingOut, setSigningOut] = useState(false); useEffect(() => { - // A 401 anywhere clears the flag and fires this: return to pairing. + // A 401 anywhere clears the flag and fires this: return to pairing. The live + // WS is torn down by the composition-root 401 handler (F6). return webSession.onUnauthorized(() => setPaired(false)); }, [webSession]); - function signOut(): void { - webSession.forget(); - setPaired(false); + async function signOut(): Promise { + if (signingOut) return; + setSigningOut(true); + try { + // Revoke the server session (best-effort), then drop the live socket so it + // stops reconnecting, and return to pairing regardless of the outcome. + await webSession.logout(); + } finally { + disconnectWebLive(); + setSigningOut(false); + setPaired(false); + } } return ( @@ -45,7 +57,7 @@ export function WebApp({ session }: WebAppProps = {}) { {paired && ( - )} diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index af45ade..3d978d2 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -28,6 +28,7 @@ import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { Button, Panel, Spinner, cn } from "@/shared"; import { WebAgentCell } from "./WebAgentCell"; import { useLiveReconnect } from "./useLiveReconnect"; +import { useLiveConnectionState } from "./useLiveConnectionState"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -75,6 +76,7 @@ export function WebWorkspace() { return (
+

Projets