feat(frontend): polish web (logout, 401→pairing, reconnexion globale) (#13)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 08:23:11 +02:00
parent d538808a0d
commit 6117177957
10 changed files with 288 additions and 14 deletions

View File

@ -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";

View File

@ -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();
}

View File

@ -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);
});
});

View File

@ -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<void> {
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

View File

@ -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<void> | 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<typeof setTimeout>));
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;

View File

@ -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();
});
});

View File

@ -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();

View File

@ -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<void> {
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 = {}) {
</span>
</div>
{paired && (
<Button variant="ghost" size="sm" onClick={signOut}>
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}>
Se déconnecter
</Button>
)}

View File

@ -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 (
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
<ReconnectBanner />
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
@ -119,6 +121,28 @@ export function WebWorkspace() {
);
}
/**
* Global reconnection banner (F6). The F3 terminal path writes a "déconnecté"
* notice into xterm, but live-only surfaces (no terminal open) had no visible
* signal. This surfaces the shared live socket's `reconnecting` state so the user
* always knows the view may be momentarily stale; `useLiveReconnect` re-syncs the
* read-model on recovery. Inert on desktop (hook returns `null`).
*/
function ReconnectBanner() {
const connection = useLiveConnectionState();
if (connection !== "reconnecting") return null;
return (
<div
role="status"
data-testid="web-reconnect-banner"
className="flex items-center gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
<Spinner size={12} />
Connexion perdue reconnexion en cours
</div>
);
}
/** Live work-state + background + inbox for the opened project (F5). */
function LiveProjectPanel({ projectId, root }: { projectId: string; root: string | null }) {
const vm = useProjectWorkState(projectId);

View File

@ -0,0 +1,29 @@
/**
* `useLiveConnectionState` — ticket #13, lot F6. Web-only hook exposing the shared
* live WebSocket's current {@link ConnectionState} to the UI so a reconnection
* banner can be shown even when no terminal is open (the F3 xterm notice only
* covers terminal cells; live-only surfaces had no visible signal).
*
* Inert outside web transport (no client registered), so it is safe to mount
* unconditionally: it returns `null` on desktop.
*/
import { useEffect, useState } from "react";
import { getWebLiveClient, type ConnectionState } from "@/adapters/http";
export function useLiveConnectionState(): ConnectionState | null {
const [state, setState] = useState<ConnectionState | null>(
() => getWebLiveClient()?.getConnectionState() ?? null,
);
useEffect(() => {
const client = getWebLiveClient();
if (!client) return;
// Sync in case the state changed between the initial render and this effect.
setState(client.getConnectionState());
return client.onConnectionStateChange(setState);
}, []);
return state;
}