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:
@ -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();
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
|
||||
29
frontend/src/features/web/useLiveConnectionState.ts
Normal file
29
frontend/src/features/web/useLiveConnectionState.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user