diff --git a/frontend/src/adapters/device.ts b/frontend/src/adapters/device.ts new file mode 100644 index 0000000..3492865 --- /dev/null +++ b/frontend/src/adapters/device.ts @@ -0,0 +1,42 @@ +/** + * Tauri-backed device gateway (ticket #77, lot F2). + * + * The desktop app hosts the embedded server, so these commands reach the same + * device use cases through the composition root — **never** over HTTP to its own + * server (carnet #77, cadrage Architecture). + * + * Command names and envelopes are those B3 landed (`commands.rs`). + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { PairedDevice, PairingCode } from "@/domain"; +import type { DeviceGateway } from "@/ports"; + +/** Backend `DeviceListDto` — the list is wrapped, not returned bare. */ +interface DeviceListDto { + devices: PairedDevice[]; +} + +export class TauriDeviceGateway implements DeviceGateway { + async listDevices(): Promise { + const dto = await invoke("list_devices"); + return dto.devices; + } + + createPairingCode(): Promise { + return invoke("create_pairing_code"); + } + + renameDevice(deviceId: string, name: string): Promise { + return invoke("rename_device", { request: { deviceId, name } }); + } + + revokeDevice(deviceId: string): Promise { + return invoke("revoke_device", { request: { deviceId } }); + } + + revokeAllDevices(): Promise { + return invoke("revoke_all_devices"); + } +} diff --git a/frontend/src/adapters/http/deviceGateway.ts b/frontend/src/adapters/http/deviceGateway.ts new file mode 100644 index 0000000..1baacdc --- /dev/null +++ b/frontend/src/adapters/http/deviceGateway.ts @@ -0,0 +1,51 @@ +/** + * HTTP device gateway (ticket #77, lot F2) — the web sibling of + * `TauriDeviceGateway`, behind the unchanged {@link DeviceGateway} port. + * + * Routes live outside the generic `/api/invoke` RPC, next to the pre-existing + * `/api/pair` and `/api/logout`: the RPC endpoint is auth-gated, so the surface + * that manages auth cannot ride on it (carnet #77). + * + * Paths are those B3 landed (`web-server/src/lib.rs`). + */ + +import type { PairedDevice, PairingCode } from "@/domain"; +import type { DeviceGateway } from "@/ports"; +import type { HttpInvoker } from "./httpInvoker"; + +/** Backend shape of `GET /api/devices`: the list is wrapped, never bare. */ +interface DeviceListResponse { + devices: PairedDevice[]; +} + +export class HttpDeviceGateway implements DeviceGateway { + constructor(private readonly http: HttpInvoker) {} + + async listDevices(): Promise { + const body = await this.http.request("GET", "/api/devices"); + return body.devices; + } + + createPairingCode(): Promise { + return this.http.request("POST", "/api/pairing-code"); + } + + renameDevice(deviceId: string, name: string): Promise { + return this.http.request( + "POST", + `/api/devices/${encodeURIComponent(deviceId)}/rename`, + { name }, + ); + } + + revokeDevice(deviceId: string): Promise { + return this.http.request( + "POST", + `/api/devices/${encodeURIComponent(deviceId)}/revoke`, + ); + } + + revokeAllDevices(): Promise { + return this.http.request("POST", "/api/devices/revoke-all"); + } +} diff --git a/frontend/src/adapters/http/httpInvoker.ts b/frontend/src/adapters/http/httpInvoker.ts index bca9db2..11476fd 100644 --- a/frontend/src/adapters/http/httpInvoker.ts +++ b/frontend/src/adapters/http/httpInvoker.ts @@ -117,7 +117,25 @@ export class HttpInvoker { * Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the * parsed result, or rejects with a {@link GatewayError}. */ - async invoke(command: string, args: Record = {}): Promise { + invoke(command: string, args: Record = {}): Promise { + return this.request("POST", "/api/invoke", { command, args }, `command '${command}'`); + } + + /** + * Sends one authenticated REST request and maps the response exactly like + * {@link invoke} (401 routing + `ErrorDto` preservation). + * + * The device/auth surface of ticket #77 lives on dedicated routes rather than + * the generic RPC, alongside the pre-existing `/api/pair` and `/api/logout` — + * the RPC endpoint is itself auth-gated, so the surface that *manages* auth + * cannot ride on it. `what` names the call in fallback error messages. + */ + async request( + method: string, + path: string, + body?: unknown, + what = `${method} ${path}`, + ): Promise { const headers: Record = { "Content-Type": "application/json", }; @@ -125,10 +143,10 @@ export class HttpInvoker { let res: Awaited>; try { - res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, { - method: "POST", + res = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, headers, - body: JSON.stringify({ command, args }), + body: body === undefined ? undefined : JSON.stringify(body), // Same-origin so the HttpOnly session cookie is sent automatically // (ticket #13: no secret in the URL/headers from JS). credentials: "same-origin", @@ -136,7 +154,7 @@ export class HttpInvoker { } catch (networkError) { const err: GatewayError = { code: "TRANSPORT_ERROR", - message: `HTTP request for '${command}' failed: ${String(networkError)}`, + message: `HTTP request for ${what} failed: ${String(networkError)}`, }; throw err; } @@ -152,16 +170,16 @@ export class HttpInvoker { } catch { parsed = undefined; } - throw toGatewayError(parsed, `command '${command}' failed (HTTP ${res.status})`); + throw toGatewayError(parsed, `${what} failed (HTTP ${res.status})`); } // A 204 / empty body maps to `undefined` (the void commands). - let body: unknown; + let parsed: unknown; try { - body = await res.json(); + parsed = await res.json(); } catch { - body = undefined; + parsed = undefined; } - return body as T; + return parsed as T; } } diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 7873f36..41f0b6f 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -20,6 +20,7 @@ import { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; import { getWebSession } from "./webSession"; import { setWebLiveClient } from "./webLive"; +import { HttpDeviceGateway } from "./deviceGateway"; import { HttpConversationGateway, HttpEmbedderGateway, @@ -129,6 +130,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway skill: new HttpSkillGateway(http), memory: new HttpMemoryGateway(http), embedder: new HttpEmbedderGateway(http), + device: new HttpDeviceGateway(http), permission: new HttpPermissionGateway(http), workState: new HttpWorkStateGateway(http), conversation: new HttpConversationGateway(http), diff --git a/frontend/src/adapters/http/webSession.test.ts b/frontend/src/adapters/http/webSession.test.ts index bfbc76a..749d1ef 100644 --- a/frontend/src/adapters/http/webSession.test.ts +++ b/frontend/src/adapters/http/webSession.test.ts @@ -1,7 +1,12 @@ /** - * F2 — the web session: pairing handshake (success marks the flag; wrong code + * F2 — the web session: pairing handshake (success marks the flag; a failure * rejects with a clear message), and the 401 → unauthorized transition that * clears the flag and notifies subscribers. + * + * #77 extends the handshake to `{code, name}` and freezes the two exposed + * failure codes. The "no oracle" cases below are a **security** contract, not a + * wording preference: if they start distinguishing wrong from expired from + * already-used, the API is leaking to an attacker. */ import { describe, it, expect, vi } from "vitest"; @@ -35,35 +40,106 @@ function fetchReturning(status: number, body: unknown): { } describe("WebSession pairing", () => { - it("POSTs the code to /api/pair and marks the flag on success", async () => { + it("POSTs {code, name} to /api/pair and marks the flag on success", async () => { const store = memStore(); const { fetchImpl, calls } = fetchReturning(200, { ok: true }); const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); expect(session.isPaired()).toBe(false); - await session.pair("4821-93"); + await session.pair("AB12CD34", "iPhone"); expect(session.isPaired()).toBe(true); expect(calls[0].url).toBe("https://host/api/pair"); - expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" }); + expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ + code: "AB12CD34", + name: "iPhone", + }); }); - it("rejects a wrong code with a clear message and stays unpaired", async () => { + it("maps invalid_or_expired to the single frozen message", async () => { const store = memStore(); - const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" }); + const { fetchImpl } = fetchReturning(401, { + code: "invalid_or_expired", + message: "whatever the server says", + }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); - await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" }); + await expect(session.pair("nope", "iPhone")).rejects.toMatchObject({ + code: "invalidOrExpired", + message: "Code invalide ou expiré.", + }); expect(session.isPaired()).toBe(false); }); - it("falls back to a default message when the server sends no body", async () => { + it("maps invalid_name to its own actionable message", async () => { + // B3 validates the name *before* consuming the code, so this failure leaves + // the code alive — the message must not send the user regenerating one. + const { fetchImpl } = fetchReturning(400, { + code: "invalid_name", + message: "device name must be 40 characters or fewer", + }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", "x".repeat(60))).rejects.toMatchObject({ + code: "invalidName", + message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", + }); + }); + + it("does not blame the name for an unrelated 400", async () => { + // Only the wire code may point at the name; a bare bad request must not send + // the user editing a field that was fine. + const { fetchImpl } = fetchReturning(400, undefined); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ + code: "pairingFailed", + }); + }); + + it("maps rate_limited to its own message", async () => { + const { fetchImpl } = fetchReturning(429, { code: "rate_limited", message: "slow down" }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ + code: "rateLimited", + message: "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.", + }); + }); + + it("never forwards the server's own wording, invalid_name included", async () => { + const { fetchImpl } = fetchReturning(400, { + code: "invalid_name", + message: "device name is required", + }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", " ")).rejects.toMatchObject({ + message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", + }); + }); + + it("never forwards a server message that could distinguish failure causes", async () => { + // Even if a future backend leaks the distinction in its message, the UI must + // not repeat it: an attacker learns nothing about *why* a code was refused. + const { fetchImpl } = fetchReturning(401, { + code: "invalid_or_expired", + message: "code already used at 14:02 by device iPhone", + }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ + message: "Code invalide ou expiré.", + }); + }); + + it("falls back to the invalid/expired message when the server sends no body", async () => { const { fetchImpl } = fetchReturning(403, undefined); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); - await expect(session.pair("x")).rejects.toMatchObject({ - code: "INVALID_PAIRING_CODE", - message: "Code d'appairage invalide.", + await expect(session.pair("x", "iPhone")).rejects.toMatchObject({ + code: "invalidOrExpired", + message: "Code invalide ou expiré.", }); }); }); @@ -113,7 +189,7 @@ describe("WebSession default fetch receiver", () => { try { // No `fetchImpl` injected ⇒ the global fallback is used. const session = new WebSession({ baseUrl: "https://host", store: memStore() }); - await session.pair("4821-93"); + await session.pair("AB12CD34", "iPhone"); } finally { globalThis.fetch = original; } diff --git a/frontend/src/adapters/http/webSession.ts b/frontend/src/adapters/http/webSession.ts index d31dd17..cf9e387 100644 --- a/frontend/src/adapters/http/webSession.ts +++ b/frontend/src/adapters/http/webSession.ts @@ -65,6 +65,63 @@ function defaultBaseUrl(): string { : "http://localhost"; } +/** + * The failure codes `POST /api/pair` exposes (#77). + * + * `invalidOrExpired` deliberately covers wrong, expired, superseded **and + * already-used** codes: the API gives an attacker no oracle to tell them apart, + * so neither may the UI. Do not add a case here without changing that decision. + * + * `invalidName` is *not* an exception to that rule: the name is the caller's own + * input, so rejecting it reveals nothing about the code. The server validates it + * **before** consuming the code (B3), which is what makes the distinction safe + * to surface — and what lets the user retry with the same code. + */ +export type PairingErrorCode = + | "invalidOrExpired" + | "invalidName" + | "rateLimited" + | "pairingFailed"; + +/** User-facing labels, frozen by UX in the #77 carnet. */ +const PAIRING_ERROR_MESSAGE: Record = { + invalidOrExpired: "Code invalide ou expiré.", + // Says which field is wrong and, just as importantly, that the code survived: + // without that clause the reflex is to close the panel and burn a new code. + invalidName: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", + rateLimited: + "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.", + pairingFailed: "Échec de l'appairage.", +}; + +/** + * Maps a `/api/pair` failure onto one of the exposed codes. + * + * The server's own `message` is **not** forwarded: it is mapped through the + * frozen labels above so a future backend wording can never reintroduce the + * wrong/expired/used distinction in the UI. The wire code wins; the HTTP status + * is the fallback for an unparseable body. + */ +function toPairingError(parsed: unknown, status: number): GatewayError { + const wire = + parsed && typeof parsed === "object" && "code" in parsed + ? String((parsed as { code: unknown }).code) + : undefined; + + let code: PairingErrorCode; + if (wire === "invalid_or_expired") code = "invalidOrExpired"; + else if (wire === "invalid_name") code = "invalidName"; + else if (wire === "rate_limited") code = "rateLimited"; + else if (status === 429) code = "rateLimited"; + // A bare 400 is not mapped to `invalidName`: only the wire code may claim the + // name is at fault, or an unrelated bad request would send the user editing a + // field that was fine. + else if (status === 401 || status === 403) code = "invalidOrExpired"; + else code = "pairingFailed"; + + return { code, message: PAIRING_ERROR_MESSAGE[code] }; +} + /** * The web session state machine. One instance is shared between the pairing UI * and the HTTP invoker (via the module singleton below). @@ -118,17 +175,20 @@ export class WebSession { } /** - * 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 - * rejects with a {@link GatewayError} carrying a clear message. + * Performs the pairing handshake: `POST /api/pair {code, name}` (#77). `name` + * is the human label for *this* device, typed on it at pairing time. On success + * the server sets the session cookie and this records the paired flag. + * + * Failures reject with a {@link GatewayError} carrying a user-facing message + * from {@link pairingErrorMessage}. */ - async pair(code: string): Promise { + async pair(code: string, name: string): Promise { let res: Awaited>; try { res = await this.fetchImpl(`${this.baseUrl}/api/pair`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code }), + body: JSON.stringify({ code, name }), }); } catch (networkError) { const err: GatewayError = { @@ -145,17 +205,7 @@ export class WebSession { } catch { parsed = undefined; } - const message = - parsed && typeof parsed === "object" && "message" in parsed - ? String((parsed as GatewayError).message) - : res.status === 401 || res.status === 403 - ? "Code d'appairage invalide." - : `Échec de l'appairage (HTTP ${res.status}).`; - const err: GatewayError = { - code: res.status === 401 || res.status === 403 ? "INVALID_PAIRING_CODE" : "PAIRING_FAILED", - message, - }; - throw err; + throw toPairingError(parsed, res.status); } // Cookie is now set by the server (HttpOnly — not read here); record the flag. diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 0d5dcb2..873cb48 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -25,6 +25,7 @@ import { TauriTemplateGateway } from "./template"; import { TauriSkillGateway } from "./skill"; import { TauriMemoryGateway } from "./memory"; import { TauriEmbedderGateway } from "./embedder"; +import { TauriDeviceGateway } from "./device"; import { TauriGitGateway } from "./git"; import { TauriPermissionGateway } from "./permission"; import { TauriWorkStateGateway } from "./workState"; @@ -66,6 +67,7 @@ export function createTauriGateways(): Gateways { skill: new TauriSkillGateway(), memory: new TauriMemoryGateway(), embedder: new TauriEmbedderGateway(), + device: new TauriDeviceGateway(), permission: new TauriPermissionGateway(), workState: new TauriWorkStateGateway(), conversation: new TauriConversationGateway(), @@ -90,6 +92,7 @@ export { TauriSkillGateway, TauriMemoryGateway, TauriEmbedderGateway, + TauriDeviceGateway, TauriGitGateway, TauriPermissionGateway, TauriWorkStateGateway, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3e377cc..f275d7c 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -32,6 +32,8 @@ import type { MemoryLink, MemoryType, EffectivePermissions, + PairedDevice, + PairingCode, PermissionSet, Project, ProjectPermissions, @@ -68,6 +70,7 @@ import type { CreateMemoryInput, CreateSkillInput, DesktopServerGateway, + DeviceGateway, EmbedderGateway, CreateTemplateInput, Gateways, @@ -1471,8 +1474,6 @@ export class MockDesktopServerGateway implements DesktopServerGateway { ? undefined : this.settings.publicOrigin, upstreamUrl: preview.upstreamUrl, - // Runtime-only, regenerated per start — never persisted. - pairingCode: "MOCK-PAIR-4242", }); return structuredClone(this.current); } @@ -2040,6 +2041,66 @@ export class MockInputGateway implements InputGateway { } } +/** + * In-memory paired-device gateway (ticket #77) — stands in for B1/B2/B3 while + * the backend lands, and drives the `VITE_USE_MOCK` dev surface. + * + * Models the parts of the frozen contract the UI can actually observe: a device + * list with exactly one `isCurrentDevice`, single-use codes with a 600 s TTL + * where generating invalidates the previous one, and revocation. It does **not** + * model the wire (no HTTP, no cookie): that is the HTTP gateway's job. + */ +export class MockDeviceGateway implements DeviceGateway { + private devices: PairedDevice[] = []; + private issued = 0; + + /** Seeds the device list (tests/dev). */ + _setDevices(devices: PairedDevice[]): void { + this.devices = structuredClone(devices); + } + + /** Every code handed out so far, newest last — the previous ones are dead. */ + _issuedCodes: string[] = []; + + async listDevices(): Promise { + return structuredClone(this.devices); + } + + async createPairingCode(): Promise { + // Deterministic 8-char uppercase hex, same shape as the server's. + const code = (0x10000000 + this.issued++) + .toString(16) + .toUpperCase() + .slice(0, 8); + this._issuedCodes.push(code); + const ttlSeconds = 600; + return { + code, + // Epoch ms, exactly as the server sends it — a mock that invents a + // friendlier encoding is how the ISO/epoch mismatch stayed green (#77). + expiresAtMs: Date.now() + ttlSeconds * 1000, + ttlSeconds, + }; + } + + async renameDevice(deviceId: string, name: string): Promise { + const device = this.devices.find((d) => d.deviceId === deviceId); + if (!device) { + const err: GatewayError = { code: "NOT_FOUND", message: `unknown device ${deviceId}` }; + throw err; + } + device.name = name; + } + + async revokeDevice(deviceId: string): Promise { + this.devices = this.devices.filter((d) => d.deviceId !== deviceId); + } + + async revokeAllDevices(): Promise { + this.devices = []; + } +} + /** In-memory permissions gateway. */ export class MockPermissionGateway implements PermissionGateway { private docs = new Map(); @@ -2782,6 +2843,7 @@ export function createMockGateways(): Gateways { skill: new MockSkillGateway(agentGateway), memory: new MockMemoryGateway(), embedder: new MockEmbedderGateway(), + device: new MockDeviceGateway(), permission: new MockPermissionGateway(), workState: new MockWorkStateGateway(), conversation: new MockConversationGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 693f254..e56ca7a 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -17,6 +17,7 @@ describe("createMockGateways", () => { "agent", "conversation", "desktopServer", + "device", "embedder", "focusedProject", "git", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index aeb14a9..e30aa82 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -190,9 +190,11 @@ export type EmbeddedServerState = | "failed"; /** - * Embedded-server status (mirror of `EmbeddedServerStatusDto`). `pairingCode` - * is a runtime-only secret: it exists while the server runs, is never - * persisted, and must never be written into a settings field. + * Embedded-server status (mirror of `EmbeddedServerStatusDto`). + * + * Carries **no pairing code** (#77): a code no longer exists while the server + * runs, only when someone asks for one. Generating is a device-management action + * ({@link PairingCode}), not a property of the server's status. */ export interface EmbeddedServerStatus { state: EmbeddedServerState; @@ -202,8 +204,6 @@ export interface EmbeddedServerStatus { publicUrl?: string; /** Upstream URL to hand to the reverse proxy. */ upstreamUrl?: string; - /** Runtime pairing code — present only while running. */ - pairingCode?: string; /** Last failure, when `state` is `failed`. */ error?: GatewayError; } @@ -1321,3 +1321,52 @@ export type ReplyChunk = | { kind: "toolActivity"; label: string } | { kind: "final"; content: string } | { kind: "error"; message: string }; + +// --------------------------------------------------------------------------- +// Paired devices + pairing code (ticket #77) +// --------------------------------------------------------------------------- + +/** + * One device paired with this IdeA instance (mirror of the backend device DTO). + * + * Deliberately carries **no IP and no User-Agent**: the design is mono-user and + * the list is an access-management surface, not a forensics log. `name` is the + * human label typed on the device itself at pairing time. + */ +export interface PairedDevice { + deviceId: string; + name: string; + /** Epoch milliseconds the device was paired. */ + pairedAtMs: number; + /** Epoch milliseconds of the device's last authenticated request. */ + lastSeenAtMs: number; + /** True for the device rendering this list (never true on desktop). */ + isCurrentDevice: boolean; +} + +/** + * A freshly generated, single-use pairing code (mirror of the backend DTO). + * + * `code` is the **canonical** value — uppercase hex, no separator. Any grouping + * shown to the user is presentation only; see {@link normalizePairingCode}. + */ +export interface PairingCode { + code: string; + /** Epoch milliseconds the code stops being accepted. */ + expiresAtMs: number; + /** Lifetime granted at generation (600 s today). */ + ttlSeconds: number; +} + +/** + * Canonical form of a pairing code as the server compares it: uppercase, with + * spaces **and dashes** removed. + * + * Both separators matter. The UI groups the code visually (`AB12 CD34`) and a + * user may retype it with a dash out of habit, so a code copied by eye must + * still pair. The server normalises too (#76) — this keeps the client honest + * rather than being the only line of defence. + */ +export function normalizePairingCode(raw: string): string { + return raw.replace(/[\s-]+/g, "").toUpperCase(); +} diff --git a/frontend/src/features/devices/ConfirmDialog.tsx b/frontend/src/features/devices/ConfirmDialog.tsx new file mode 100644 index 0000000..1c2f14d --- /dev/null +++ b/frontend/src/features/devices/ConfirmDialog.tsx @@ -0,0 +1,80 @@ +/** + * A small modal confirmation, local to the devices feature (ticket #77, lot F2). + * + * Not `FloatingWindow`: that is a draggable desktop window, and this surface is + * mobile-first. Not a shared primitive either — adding a dialog to the kit is a + * design-system decision for UX/Architect, not a side effect of this ticket. + */ + +import { useEffect, useRef } from "react"; + +import { Button, zIndex } from "@/shared"; + +interface ConfirmDialogProps { + title: string; + body: string; + confirmLabel: string; + /** Paints the confirm action as destructive (revoke-all). */ + danger?: boolean; + busy?: boolean; + onConfirm: () => void | Promise; + onCancel: () => void; +} + +export function ConfirmDialog({ + title, + body, + confirmLabel, + danger = false, + busy = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const cancelRef = useRef(null); + + // Mount-only: a confirmation opens focused on the safe choice. Depending on + // `onCancel` here would re-steal focus whenever the parent re-renders (#17). + useEffect(() => { + cancelRef.current?.focus(); + }, []); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onCancel(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onCancel]); + + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl" + > +

{title}

+

{body}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/devices/DevicesScreen.test.tsx b/frontend/src/features/devices/DevicesScreen.test.tsx new file mode 100644 index 0000000..c169f9f --- /dev/null +++ b/frontend/src/features/devices/DevicesScreen.test.tsx @@ -0,0 +1,291 @@ +/** + * #77 lot F2 — the shared devices surface, driven through the mock gateway + * (B1/B2/B3 are not landed; the mock models the frozen DTO contract). + * + * The load-bearing assertions: + * - the copied code carries **no separator**, whatever the display shows; + * - no IP / User-Agent ever reaches the DOM; + * - revoking the current device ends the session instead of refreshing a list + * we are no longer allowed to read. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import type { PairedDevice } from "@/domain"; +import { DIProvider } from "@/app/di"; +import { createMockGateways, MockDeviceGateway } from "@/adapters/mock"; +import { DevicesScreen } from "./DevicesScreen"; + +/** + * Epoch-ms fixtures — the encoding the backend actually sends (`*AtMs: number`). + * These were ISO strings, which let the suite pass while the real screen + * rendered "—" on every row. + */ +const DEVICES: PairedDevice[] = [ + { + deviceId: "d1", + name: "iPhone", + pairedAtMs: new Date(2026, 6, 12, 10, 0).getTime(), + lastSeenAtMs: Date.now(), + isCurrentDevice: true, + }, + { + deviceId: "d2", + name: "Chrome sur Windows", + pairedAtMs: new Date(2026, 5, 1, 10, 0).getTime(), + lastSeenAtMs: new Date(2026, 6, 12, 14, 32).getTime(), + isCurrentDevice: false, + }, +]; + +function setup(devices: PairedDevice[] = DEVICES) { + const gateways: Gateways = createMockGateways(); + const device = gateways.device as MockDeviceGateway; + device._setDevices(devices); + const onSessionEnded = vi.fn(); + render( + + + , + ); + return { device, onSessionEnded }; +} + +const writeText = vi.fn(async (_text: string) => {}); + +beforeEach(() => { + writeText.mockClear(); + Object.defineProperty(window.navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); +}); + +const rowFor = async (name: string) => + (await screen.findByText(name)).closest("li") as HTMLElement; + +describe("DevicesScreen list", () => { + it("lists devices with their name, activity and pairing date", async () => { + setup(); + + const row = await rowFor("Chrome sur Windows"); + expect(within(row).getByText("Appairé le 1 juin")).toBeTruthy(); + expect(screen.getAllByTestId("device-row")).toHaveLength(2); + }); + + it("badges the current device", async () => { + setup(); + + const current = await rowFor("iPhone"); + expect(within(current).getByText("Cet appareil")).toBeTruthy(); + const other = await rowFor("Chrome sur Windows"); + expect(within(other).queryByText("Cet appareil")).toBeNull(); + }); + + it("never renders an IP or a User-Agent", async () => { + setup(); + await screen.findByTestId("device-list"); + + const text = screen.getByTestId("devices-screen").textContent ?? ""; + expect(text).not.toMatch(/Mozilla|AppleWebKit|\d+\.\d+\.\d+\.\d+/); + }); + + it("says so plainly when nothing is paired", async () => { + setup([]); + expect(await screen.findByText("Aucun appareil appairé.")).toBeTruthy(); + }); +}); + +describe("DevicesScreen pairing code", () => { + it("copies the canonical code, with no separator in the clipboard value", async () => { + setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + const panel = await screen.findByTestId("pairing-code-panel"); + fireEvent.click(within(panel).getByRole("button", { name: "Copier" })); + + await waitFor(() => expect(writeText).toHaveBeenCalled()); + const copied = writeText.mock.calls[0][0]; + // The whole point of the arbitration: a dash here would be retyped by the + // user and refused by the server (#75). + expect(copied).toMatch(/^[0-9A-F]{8}$/); + expect(copied).not.toContain("-"); + expect(copied).not.toContain(" "); + }); + + it("groups the code visually while keeping the value intact for readers", async () => { + setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + const value = await screen.findByTestId("pairing-code-value"); + + // Two blocks of 4 on screen… + const blocks = value.querySelectorAll("span"); + expect(blocks).toHaveLength(2); + expect(blocks[0].textContent).toHaveLength(4); + expect(blocks[1].textContent).toHaveLength(4); + // …but a screen reader hears the code it must type, unseparated. + expect(value.getAttribute("aria-label")).toMatch(/^[0-9A-F]{8}$/); + }); + + it("shows the instruction and a countdown", async () => { + setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + + expect(await screen.findByText("Saisissez ce code sur le nouvel appareil.")).toBeTruthy(); + expect((await screen.findByTestId("pairing-code-countdown")).textContent).toBe( + "Expire dans 10 min", + ); + }); + + it("does not declare a freshly generated code expired", async () => { + // The visible symptom of the ISO/epoch mismatch (#77): `new Date()` + // was Invalid Date ⇒ the countdown read null ⇒ every brand-new code showed + // "Ce code a expiré." the instant it appeared. Guard the whole failure, not + // just the formatter. + setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + await screen.findByTestId("pairing-code-panel"); + + expect(screen.queryByText("Ce code a expiré.")).toBeNull(); + expect(screen.queryByRole("button", { name: "Générer un nouveau code" })).toBeNull(); + expect(screen.getByTestId("pairing-code-countdown").textContent).toMatch( + /^Expire dans \d+ (min|s)$/, + ); + }); + + it("replaces the code rather than stacking panels when regenerating", async () => { + const { device } = setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + await screen.findByTestId("pairing-code-panel"); + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + + await waitFor(() => expect(device._issuedCodes).toHaveLength(2)); + expect(screen.getAllByTestId("pairing-code-panel")).toHaveLength(1); + // Generating invalidates the previous code server-side; the UI shows only + // the live one so nobody reads a dead code aloud. + const shown = (await screen.findByTestId("pairing-code-value")).getAttribute("aria-label"); + expect(shown).toBe(device._issuedCodes[1]); + }); + + it("announces expiry without alarmism and offers a new code", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + setup(); + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + await screen.findByTestId("pairing-code-panel"); + + await vi.advanceTimersByTimeAsync(601_000); + + expect(await screen.findByText("Ce code a expiré.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Générer un nouveau code" })).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("DevicesScreen rename", () => { + it("renames through the ⋯ menu", async () => { + const { device } = setup(); + + const row = await rowFor("Chrome sur Windows"); + fireEvent.click(within(row).getByRole("button", { name: "Actions pour Chrome sur Windows" })); + fireEvent.click(within(row).getByRole("menuitem", { name: "Renommer" })); + + fireEvent.change(screen.getByDisplayValue("Chrome sur Windows"), { + target: { value: "PC du bureau" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Renommer" })); + + expect(await screen.findByText("PC du bureau")).toBeTruthy(); + expect((await device.listDevices()).find((d) => d.deviceId === "d2")?.name).toBe( + "PC du bureau", + ); + }); +}); + +describe("DevicesScreen revocation", () => { + async function openRevoke(name: string) { + const row = await rowFor(name); + fireEvent.click(within(row).getByRole("button", { name: `Actions pour ${name}` })); + fireEvent.click(within(row).getByRole("menuitem", { name: "Révoquer" })); + return screen.findByRole("dialog"); + } + + it("confirms before revoking another device", async () => { + const { device, onSessionEnded } = setup(); + + const dialog = await openRevoke("Chrome sur Windows"); + expect(within(dialog).getByText("Révoquer cet appareil ?")).toBeTruthy(); + expect( + within(dialog).getByText("Il devra être appairé à nouveau pour accéder à IdeA."), + ).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Révoquer" })); + + await waitFor(() => expect(screen.queryByText("Chrome sur Windows")).toBeNull()); + expect(await device.listDevices()).toHaveLength(1); + // Revoking someone else must not touch our own session. + expect(onSessionEnded).not.toHaveBeenCalled(); + }); + + it("cancelling leaves the device alone", async () => { + const { device } = setup(); + + const dialog = await openRevoke("Chrome sur Windows"); + fireEvent.click(within(dialog).getByRole("button", { name: "Annuler" })); + + expect(screen.getByText("Chrome sur Windows")).toBeTruthy(); + expect(await device.listDevices()).toHaveLength(2); + }); + + it("warns about immediate sign-out when revoking the current device", async () => { + setup(); + + const dialog = await openRevoke("iPhone"); + expect(within(dialog).getByText(/Vous serez déconnecté immédiatement\./)).toBeTruthy(); + }); + + it("ends the session when the current device revokes itself (nominal case)", async () => { + const { onSessionEnded } = setup(); + + const dialog = await openRevoke("iPhone"); + fireEvent.click(within(dialog).getByRole("button", { name: "Révoquer" })); + + await waitFor(() => expect(onSessionEnded).toHaveBeenCalled()); + }); + + it("revokes everything, current device included, behind a strong confirmation", async () => { + const { device, onSessionEnded } = setup(); + await screen.findByTestId("device-list"); + + fireEvent.click(screen.getByRole("button", { name: "Révoquer tous les appareils" })); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("Révoquer tous les appareils ?")).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Tout révoquer" })); + + await waitFor(() => expect(onSessionEnded).toHaveBeenCalled()); + expect(await device.listDevices()).toHaveLength(0); + }); + + it("does not end the session when revoke-all excludes the current device", async () => { + // Desktop: no device is ever `isCurrentDevice`, so wiping the list must not + // pretend the app just logged itself out. + const { onSessionEnded } = setup(DEVICES.map((d) => ({ ...d, isCurrentDevice: false }))); + await screen.findByTestId("device-list"); + + fireEvent.click(screen.getByRole("button", { name: "Révoquer tous les appareils" })); + fireEvent.click( + within(await screen.findByRole("dialog")).getByRole("button", { name: "Tout révoquer" }), + ); + + await waitFor(() => expect(screen.getByText("Aucun appareil appairé.")).toBeTruthy()); + expect(onSessionEnded).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/devices/DevicesScreen.tsx b/frontend/src/features/devices/DevicesScreen.tsx new file mode 100644 index 0000000..967b56b --- /dev/null +++ b/frontend/src/features/devices/DevicesScreen.tsx @@ -0,0 +1,317 @@ +/** + * `DevicesScreen` — the paired-device surface (ticket #77, lot F2). + * + * Mounted **identically** in the web UI and in the desktop app + * (`Paramètres → Appareils`): same component, same vocabulary, same actions. It + * is mobile-first by necessity, not by taste — a headless install has only the + * web UI to generate a code from, and that is often reached from a phone. + * + * It shows a name, a badge, an activity phrase and a pairing date. It never + * shows an IP or a User-Agent: this is an access-management surface for a + * single-user instance, not an audit log. + * + * Transport-neutral (gateways via DI). The session consequence of revoking the + * current device is *not* decided here: {@link onSessionEnded} hands it to the + * mounting surface, which is the only layer that knows what "logged out" means. + */ + +import { useEffect, useState } from "react"; + +import type { PairedDevice } from "@/domain"; +import { Button, Field, Input, Panel, Spinner, cn } from "@/shared"; +import { useDevices } from "./useDevices"; +import { formatLastSeen, formatPairedAt } from "./formatActivity"; +import { PairingCodePanel } from "./PairingCodePanel"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { DEVICE_NAME_MAX } from "@/features/web/deviceName"; + +interface DevicesScreenProps { + /** + * Called when this device's own session has just been revoked (directly or by + * "revoke all"). Web routes back to pairing; desktop leaves it unset — the + * desktop app hosts the server and is never itself a paired device. + */ + onSessionEnded?: () => void; +} + +export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) { + const vm = useDevices(); + const [confirmRevoke, setConfirmRevoke] = useState(null); + const [confirmRevokeAll, setConfirmRevokeAll] = useState(false); + const [renaming, setRenaming] = useState(null); + + useEffect(() => { + if (vm.sessionEnded) onSessionEnded?.(); + }, [vm.sessionEnded, onSessionEnded]); + + const devices = vm.devices; + + return ( +
+
+
+

Appareils

+

+ Les appareils autorisés à accéder à cette instance IdeA. +

+
+ +
+ + {vm.code && ( + void vm.generateCode()} + onDismiss={vm.dismissCode} + /> + )} + + {vm.error && ( + +

+ {vm.error} +

+
+ )} + + {devices === null ? ( + + Chargement des appareils… + + ) : devices.length === 0 ? ( +

Aucun appareil appairé.

+ ) : ( +
    + {devices.map((device) => ( + setRenaming(device.deviceId)} + onCancelRename={() => setRenaming(null)} + onRename={async (name) => { + await vm.rename(device.deviceId, name); + setRenaming(null); + }} + onRevoke={() => setConfirmRevoke(device)} + /> + ))} +
+ )} + + {devices !== null && devices.length > 0 && ( +
+ +
+ )} + + {confirmRevoke && ( + { + const target = confirmRevoke; + setConfirmRevoke(null); + await vm.revoke(target); + }} + onCancel={() => setConfirmRevoke(null)} + /> + )} + + {confirmRevokeAll && ( + { + setConfirmRevokeAll(false); + await vm.revokeAll(); + }} + onCancel={() => setConfirmRevokeAll(false)} + /> + )} +
+ ); +} + +/** One device: identity, activity, and the `⋯` actions. */ +function DeviceRow({ + device, + fresh, + renaming, + onStartRename, + onCancelRename, + onRename, + onRevoke, +}: { + device: PairedDevice; + fresh: boolean; + renaming: boolean; + onStartRename: () => void; + onCancelRename: () => void; + onRename: (name: string) => Promise; + onRevoke: () => void; +}) { + return ( +
  • + {renaming ? ( + + ) : ( +
    +
    + + {device.name} + {device.isCurrentDevice && ( + + Cet appareil + + )} + + {formatLastSeen(device.lastSeenAtMs)} + {formatPairedAt(device.pairedAtMs)} +
    + +
    + )} +
  • + ); +} + +/** Inline rename (1-40 characters), submitted with Enter or the button. */ +function RenameForm({ + device, + onSubmit, + onCancel, +}: { + device: PairedDevice; + onSubmit: (name: string) => Promise; + onCancel: () => void; +}) { + const [name, setName] = useState(device.name); + const trimmed = name.trim(); + + return ( +
    { + e.preventDefault(); + if (trimmed.length > 0) void onSubmit(trimmed); + }} + > + + {({ id }) => ( + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") onCancel(); + }} + /> + )} + +
    + + +
    +
    + ); +} + +/** + * The per-row `⋯` menu. Local to this feature rather than a shared primitive: + * the kit has no dropdown yet, and inventing one is a design-system decision + * (UX/Architect), not a side effect of this ticket. + */ +function RowMenu({ + deviceName, + onRename, + onRevoke, +}: { + deviceName: string; + onRename: () => void; + onRevoke: () => void; +}) { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!open) return; + const close = () => setOpen(false); + // Any click outside dismisses; capture so a click on another row's trigger + // still opens that one (its handler runs after this closes us). + window.addEventListener("click", close); + return () => window.removeEventListener("click", close); + }, [open]); + + return ( +
    e.stopPropagation()}> + + {open && ( +
    + + +
    + )} +
    + ); +} diff --git a/frontend/src/features/devices/PairingCodePanel.tsx b/frontend/src/features/devices/PairingCodePanel.tsx new file mode 100644 index 0000000..77e0674 --- /dev/null +++ b/frontend/src/features/devices/PairingCodePanel.tsx @@ -0,0 +1,118 @@ +/** + * The generated pairing code panel (ticket #77, lot F2). + * + * **Grouping is presentation only.** The code is rendered as two blocks of four + * for readability, but the blocks are separate elements with CSS spacing — there + * is no separator character anywhere in the value, and `Copier` puts the exact + * canonical code (`AB12CD34`) on the clipboard. A dash rendered here would be + * retyped by the user and would reintroduce the very failure #75 just fixed + * (arbitrage Main, carnet #77). + * + * The countdown is honest about a code that is already dead: at expiry the panel + * switches to the expired state rather than letting someone type a code the + * server will refuse. + */ + +import { useEffect, useState } from "react"; + +import type { PairingCode } from "@/domain"; +import { Button, Panel } from "@/shared"; +import { formatExpiresIn } from "./formatActivity"; + +interface PairingCodePanelProps { + code: PairingCode; + onRegenerate: () => void; + onDismiss: () => void; +} + +/** Splits the code into fixed blocks of 4 for display. Never mutates the value. */ +export function codeBlocks(code: string, size = 4): string[] { + const blocks: string[] = []; + for (let i = 0; i < code.length; i += size) blocks.push(code.slice(i, i + size)); + return blocks; +} + +/** Copies text, preferring the async clipboard API and degrading silently. */ +async function copyToClipboard(text: string): Promise { + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Denied permission / insecure context: fall through to the failure notice. + } + return false; +} + +export function PairingCodePanel({ code, onRegenerate, onDismiss }: PairingCodePanelProps) { + const [remaining, setRemaining] = useState(() => formatExpiresIn(code.expiresAtMs)); + const [copied, setCopied] = useState(false); + + useEffect(() => { + setRemaining(formatExpiresIn(code.expiresAtMs)); + const timer = setInterval(() => setRemaining(formatExpiresIn(code.expiresAtMs)), 1000); + return () => clearInterval(timer); + }, [code.expiresAtMs]); + + useEffect(() => { + setCopied(false); + }, [code.code]); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + const expired = remaining === null; + + return ( + + {expired ? ( + <> +

    Ce code a expiré.

    +
    + +
    + + ) : ( + <> +

    Saisissez ce code sur le nouvel appareil.

    +

    + {codeBlocks(code.code).map((block, i) => ( + + ))} +

    +
    + + + {remaining} + + +
    + + )} +
    + ); +} diff --git a/frontend/src/features/devices/formatActivity.test.ts b/frontend/src/features/devices/formatActivity.test.ts new file mode 100644 index 0000000..61de426 --- /dev/null +++ b/frontend/src/features/devices/formatActivity.test.ts @@ -0,0 +1,77 @@ +/** + * #77 lot F2 — the activity phrasing. Boundaries are calendar-based, so the + * interesting cases are the ones a 24-hour-span implementation gets wrong. + * + * Fixtures are **epoch milliseconds**, the encoding the backend actually sends. + * They used to be ISO strings, which is precisely why the whole surface passed + * its tests while rendering "—" against a real server. + */ +import { describe, it, expect } from "vitest"; + +import { formatExpiresIn, formatLastSeen, formatPairedAt } from "./formatActivity"; + +/** 2026-07-17 at 09:00 local. */ +const NOW = new Date(2026, 6, 17, 9, 0, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0, s = 0) => + new Date(y, m, d, h, min, s).getTime(); + +describe("formatLastSeen", () => { + it("reads as 'now' within the last couple of minutes", () => { + expect(formatLastSeen(at(2026, 6, 17, 8, 59), NOW)).toBe("Actif à l'instant"); + }); + + it("shows today's time once it is no longer 'now'", () => { + expect(formatLastSeen(at(2026, 6, 17, 6, 32), NOW)).toBe("Aujourd'hui à 06:32"); + }); + + it("says 'Hier' for the previous calendar day, however few hours ago", () => { + // 23:59 yesterday is 9 hours back: a 24h-window implementation would call + // this "today", which reads as a lie next to the clock. + expect(formatLastSeen(at(2026, 6, 16, 23, 59), NOW)).toBe("Hier"); + expect(formatLastSeen(at(2026, 6, 16, 0, 1), NOW)).toBe("Hier"); + }); + + it("falls back to a short date beyond yesterday", () => { + expect(formatLastSeen(at(2026, 6, 12, 14, 0), NOW)).toBe("12 juil."); + }); + + it("adds the year once it is not the current one", () => { + expect(formatLastSeen(at(2025, 11, 3, 14, 0), NOW)).toBe("3 déc. 2025"); + }); + + it("treats a slightly-ahead server clock as 'now', not as the future", () => { + expect(formatLastSeen(at(2026, 6, 17, 9, 0, 30), NOW)).toBe("Actif à l'instant"); + }); + + it("reads a raw epoch-ms number, the way the backend sends it", () => { + // The regression that started this: a `1784286814274`-shaped value must + // render a real date, not the "—" an ISO-only parser produced. + const ms = new Date(2026, 6, 12, 14, 0).getTime(); + expect(Number.isInteger(ms)).toBe(true); + expect(formatLastSeen(ms, NOW)).toBe("12 juil."); + }); +}); + +describe("formatPairedAt", () => { + it("reads as a sentence, not a timestamp", () => { + expect(formatPairedAt(at(2026, 6, 12), NOW)).toBe("Appairé le 12 juil."); + }); +}); + +describe("formatExpiresIn", () => { + const inSeconds = (s: number) => NOW.getTime() + s * 1000; + + it("counts down in minutes over the code's lifetime", () => { + expect(formatExpiresIn(inSeconds(600), NOW)).toBe("Expire dans 10 min"); + expect(formatExpiresIn(inSeconds(61), NOW)).toBe("Expire dans 2 min"); + }); + + it("switches to seconds in the last minute", () => { + expect(formatExpiresIn(inSeconds(30), NOW)).toBe("Expire dans 30 s"); + }); + + it("returns null once expired, so the panel can say so", () => { + expect(formatExpiresIn(inSeconds(0), NOW)).toBeNull(); + expect(formatExpiresIn(inSeconds(-5), NOW)).toBeNull(); + }); +}); diff --git a/frontend/src/features/devices/formatActivity.ts b/frontend/src/features/devices/formatActivity.ts new file mode 100644 index 0000000..5216201 --- /dev/null +++ b/frontend/src/features/devices/formatActivity.ts @@ -0,0 +1,70 @@ +/** + * Human phrasing of device timestamps (ticket #77, lot F2). + * + * The list answers "is this thing still being used?", not "when exactly?", so + * recent activity degrades from a phrase to a time to a date as it ages. Pure + * and `now`-injectable so the boundaries are testable without faking the clock. + * + * Instants are **epoch milliseconds** (`*AtMs`, `number`) — the codebase-wide + * convention, aligned with the backend store. The format is unambiguous, so + * these functions parse nothing and tolerate no alternative encoding. + */ + +/** Below this, activity reads as "now" rather than a timestamp. */ +const JUST_NOW_MS = 2 * 60 * 1000; + +function startOfDay(d: Date): number { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} + +/** `14:32` in 24-hour form. */ +function timeOfDay(d: Date): string { + return d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }); +} + +/** `12 juil.`, with the year appended once it is no longer the current one. */ +export function shortDate(ms: number, now: Date = new Date()): string { + const d = new Date(ms); + const sameYear = d.getFullYear() === now.getFullYear(); + return d.toLocaleDateString("fr-FR", { + day: "numeric", + month: "short", + ...(sameYear ? {} : { year: "numeric" }), + }); +} + +/** + * Last-activity label: `Actif à l'instant`, `Aujourd'hui à 14:32`, `Hier`, then + * a short date. Days are compared as calendar days, not 24-hour spans — 23:59 + * yesterday reads "Hier", not "Aujourd'hui". + */ +export function formatLastSeen(ms: number, now: Date = new Date()): string { + const delta = now.getTime() - ms; + // A clock skew that puts the server slightly ahead reads as "now", not as a + // date in the future. + if (delta < JUST_NOW_MS) return "Actif à l'instant"; + + const today = startOfDay(now); + const day = startOfDay(new Date(ms)); + if (day === today) return `Aujourd'hui à ${timeOfDay(new Date(ms))}`; + if (day === today - 86_400_000) return "Hier"; + return shortDate(ms, now); +} + +/** Secondary line: `Appairé le 12 juil.` */ +export function formatPairedAt(ms: number, now: Date = new Date()): string { + return `Appairé le ${shortDate(ms, now)}`; +} + +/** + * Remaining lifetime of a pairing code: `Expire dans 10 min`, then seconds in + * the last minute so the panel stays honest as it runs out. `null` once expired + * — the caller shows the expiry state instead. + */ +export function formatExpiresIn(expiresAtMs: number, now: Date = new Date()): string | null { + const remainingMs = expiresAtMs - now.getTime(); + if (remainingMs <= 0) return null; + const seconds = Math.ceil(remainingMs / 1000); + if (seconds < 60) return `Expire dans ${seconds} s`; + return `Expire dans ${Math.ceil(seconds / 60)} min`; +} diff --git a/frontend/src/features/devices/index.ts b/frontend/src/features/devices/index.ts new file mode 100644 index 0000000..820ab28 --- /dev/null +++ b/frontend/src/features/devices/index.ts @@ -0,0 +1,14 @@ +/** + * Paired-device management (ticket #77, lot F2) — one surface, mounted in both + * the web UI and the desktop app under `Paramètres → Appareils`. + */ + +export { DevicesScreen } from "./DevicesScreen"; +export { useDevices } from "./useDevices"; +export type { UseDevices } from "./useDevices"; +export { + formatLastSeen, + formatPairedAt, + formatExpiresIn, + shortDate, +} from "./formatActivity"; diff --git a/frontend/src/features/devices/useDevices.ts b/frontend/src/features/devices/useDevices.ts new file mode 100644 index 0000000..5ff5144 --- /dev/null +++ b/frontend/src/features/devices/useDevices.ts @@ -0,0 +1,186 @@ +/** + * Device-management state (ticket #77, lot F2). + * + * Owns the list, the ephemeral pairing code and the revoke/rename actions, so + * {@link DevicesScreen} stays a rendering concern. Transport-neutral: every call + * goes through the injected {@link DeviceGateway}, which is the Tauri adapter on + * desktop and the HTTP one on web. + * + * Revoking the **current** device ends this session, so the screen must not try + * to refresh afterwards — the hook reports it through `sessionEnded` and the + * mounting surface decides what that means (web: back to pairing; desktop: never + * happens, no device is ever `isCurrentDevice`). + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { GatewayError, PairedDevice, PairingCode } from "@/domain"; +import { useGateways } from "@/app/di"; + +/** How often the list is re-read while a code is live, to catch a new pairing. */ +const PAIRING_POLL_MS = 3000; +/** How long a freshly-paired row stays highlighted. */ +const HIGHLIGHT_MS = 2500; + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +export interface UseDevices { + devices: PairedDevice[] | null; + error: string | null; + /** An action is in flight; drives the confirm dialogs' pending state. */ + busy: boolean; + /** Narrower than `busy`: only a code generation, so `Appairer` alone spins. */ + generating: boolean; + /** The live code, or `null` when no code has been generated (or it was dismissed). */ + code: PairingCode | null; + /** Device ids to highlight — the ones that appeared while a code was live. */ + freshDeviceIds: string[]; + /** Set once the current device's own session has been revoked. */ + sessionEnded: boolean; + refresh(): Promise; + generateCode(): Promise; + dismissCode(): void; + rename(deviceId: string, name: string): Promise; + revoke(device: PairedDevice): Promise; + revokeAll(): Promise; +} + +export function useDevices(): UseDevices { + const { device: gateway } = useGateways(); + const [devices, setDevices] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [generating, setGenerating] = useState(false); + const [code, setCode] = useState(null); + const [freshDeviceIds, setFreshDeviceIds] = useState([]); + const [sessionEnded, setSessionEnded] = useState(false); + // Read inside the poll callback without making it a dependency (which would + // restart the interval on every list change). + const knownIds = useRef | null>(null); + + const load = useCallback(async (): Promise => { + try { + const list = await gateway.listDevices(); + setDevices(list); + setError(null); + return list; + } catch (e) { + setError(describe(e)); + return null; + } + }, [gateway]); + + const refresh = useCallback(async () => { + const list = await load(); + if (list) knownIds.current = new Set(list.map((d) => d.deviceId)); + }, [load]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // While a code is live, a new device may pair at any moment and the backend + // pushes no event for it (B3 scope), so poll. The interval exists only for the + // few minutes the code is valid, never in steady state. + useEffect(() => { + if (!code) return; + const timer = setInterval(() => { + void (async () => { + const list = await load(); + if (!list) return; + const known = knownIds.current; + knownIds.current = new Set(list.map((d) => d.deviceId)); + if (!known) return; + const fresh = list.filter((d) => !known.has(d.deviceId)).map((d) => d.deviceId); + if (fresh.length > 0) setFreshDeviceIds(fresh); + })(); + }, PAIRING_POLL_MS); + return () => clearInterval(timer); + }, [code, load]); + + // The highlight is an acknowledgement, not a state: let it fade on its own. + useEffect(() => { + if (freshDeviceIds.length === 0) return; + const timer = setTimeout(() => setFreshDeviceIds([]), HIGHLIGHT_MS); + return () => clearTimeout(timer); + }, [freshDeviceIds]); + + const run = useCallback( + async (action: () => Promise): Promise => { + setBusy(true); + setError(null); + try { + await action(); + return true; + } catch (e) { + setError(describe(e)); + return false; + } finally { + setBusy(false); + } + }, + [], + ); + + const generateCode = useCallback(async () => { + setGenerating(true); + try { + await run(async () => { + // Generating invalidates the previous code server-side; mirror that by + // replacing it here rather than stacking panels. + setCode(await gateway.createPairingCode()); + }); + } finally { + setGenerating(false); + } + }, [gateway, run]); + + const dismissCode = useCallback(() => setCode(null), []); + + const rename = useCallback( + async (deviceId: string, name: string) => { + if (await run(() => gateway.renameDevice(deviceId, name))) await refresh(); + }, + [gateway, run, refresh], + ); + + const revoke = useCallback( + async (target: PairedDevice) => { + // Read `isCurrentDevice` *before* the call: afterwards the session may be + // gone and the list unreadable. + const wasCurrent = target.isCurrentDevice; + if (!(await run(() => gateway.revokeDevice(target.deviceId)))) return; + if (wasCurrent) setSessionEnded(true); + else await refresh(); + }, + [gateway, run, refresh], + ); + + const revokeAll = useCallback(async () => { + const includedCurrent = devices?.some((d) => d.isCurrentDevice) ?? false; + if (!(await run(() => gateway.revokeAllDevices()))) return; + if (includedCurrent) setSessionEnded(true); + else await refresh(); + }, [devices, gateway, run, refresh]); + + return { + devices, + error, + busy, + generating, + code, + freshDeviceIds, + sessionEnded, + refresh, + generateCode, + dismissCode, + rename, + revoke, + revokeAll, + }; +} diff --git a/frontend/src/features/settings/DeploymentSettings.test.tsx b/frontend/src/features/settings/DeploymentSettings.test.tsx index 937f974..a5fb94c 100644 --- a/frontend/src/features/settings/DeploymentSettings.test.tsx +++ b/frontend/src/features/settings/DeploymentSettings.test.tsx @@ -5,7 +5,7 @@ * These pin the UX invariants the screen exists for, not its styling: the mode * is a radio choice with consequences, the authorized-proxy field is explicitly * *not* a listen address, addresses come from the backend, a refusal is - * actionable, and the pairing code is runtime-only. + * actionable, and no pairing code is ever shown here (#77). */ import { describe, it, expect, vi } from "vitest"; @@ -142,30 +142,31 @@ describe("DeploymentSettings", () => { expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull(); }); - it("keeps the pairing code runtime-only: absent until running, gone after stop", async () => { + it("never shows a pairing code, running or not (#77)", async () => { + // A code is no longer a property of a running server: it exists only when + // asked for. Starting the server must not make one appear here — this is + // what the old "runtime-only code" test asserted, and it can no longer be + // true of any backend response. renderView(); await settle(); - expect( - screen.getByText("Start the server to generate a pairing code."), - ).toBeTruthy(); expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Start" })); + await waitFor(() => expect(screen.getByText("Running")).toBeTruthy()); - expect( - await screen.findByRole("button", { name: "copy pairing code" }), - ).toBeTruthy(); - expect( - screen.getByText(/Temporary code. It disappears when the server stops/), - ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull(); + expect(screen.queryByText(/generate a pairing code/i)).toBeNull(); + }); - fireEvent.click(screen.getByRole("button", { name: "Stop" })); - await waitFor(() => - expect( - screen.getByText("Start the server to generate a pairing code."), - ).toBeTruthy(), - ); + it("points to where pairing now lives instead of dead-ending", async () => { + // Someone who just started the server from this screen needs to know where + // to go next; silence would be a cul-de-sac. + renderView(); + await settle(); + + const pairing = screen.getByText(/Pairing is managed in/); + expect(within(pairing).getByText("Settings → Appareils")).toBeTruthy(); }); it("starts the server and reports the local URL", async () => { diff --git a/frontend/src/features/settings/DeploymentSettings.tsx b/frontend/src/features/settings/DeploymentSettings.tsx index cf7130b..e85706f 100644 --- a/frontend/src/features/settings/DeploymentSettings.tsx +++ b/frontend/src/features/settings/DeploymentSettings.tsx @@ -16,8 +16,11 @@ * the whole reason this screen is worded the way it is; the help text under * the field says so explicitly. * - * The pairing code is runtime-only: shown while running, never rendered into a - * persisted field, never mixed with the upstream value. + * This screen no longer shows a pairing code (#77). A code is not a property of + * a running server — it exists only when someone asks for one — so it lives in + * the Appareils surface next to the devices it authorises. What is left here is + * a signpost: starting the server from this screen and finding no way to pair is + * a dead end. */ import { useState } from "react"; @@ -334,21 +337,12 @@ export function DeploymentSettings() { )} - {/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */} + {/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */} - {running && status.pairingCode ? ( -
    - -

    - Temporary code. It disappears when the server stops. Do not save it - in configuration files. -

    -
    - ) : ( -

    - Start the server to generate a pairing code. -

    - )} +

    + Pairing is managed in Settings → Appareils, + where you can generate a code and revoke devices. +

    ); diff --git a/frontend/src/features/settings/SettingsView.tsx b/frontend/src/features/settings/SettingsView.tsx index bfe7613..645ae06 100644 --- a/frontend/src/features/settings/SettingsView.tsx +++ b/frontend/src/features/settings/SettingsView.tsx @@ -17,19 +17,32 @@ import { Button, cn } from "@/shared"; import { ProfilesSettings } from "@/features/first-run"; +import { DevicesScreen } from "@/features/devices"; import { DeploymentSettings } from "./DeploymentSettings"; /** The Settings sections, in menu/nav order. */ -export type SettingsSection = "aiProfiles" | "deployment"; +export type SettingsSection = "aiProfiles" | "deployment" | "devices"; -/** Human labels, shared by the nav column and the `Settings` menu. */ +/** + * Human labels, shared by the nav column and the `Settings` menu. + * + * `Appareils` is French where its neighbours are English: the device vocabulary + * is frozen by UX across web and desktop (carnet #77), and the web surface it + * mirrors is French throughout. Aligning the whole Settings surface on one + * language is a UX call beyond this ticket. + */ export const SETTINGS_SECTION_LABEL: Record = { aiProfiles: "AI Profiles", deployment: "Deployment", + devices: "Appareils", }; /** Section order — the single source of truth for both nav and menu. */ -export const SETTINGS_SECTIONS: SettingsSection[] = ["aiProfiles", "deployment"]; +export const SETTINGS_SECTIONS: SettingsSection[] = [ + "aiProfiles", + "deployment", + "devices", +]; interface SettingsViewProps { section: SettingsSection; @@ -77,7 +90,15 @@ export function SettingsView({
    - {section === "aiProfiles" ? : } + {section === "aiProfiles" ? ( + + ) : section === "deployment" ? ( + + ) : ( + // No `onSessionEnded`: the desktop app hosts the server and is never + // itself a paired device, so it cannot revoke its own session. + + )}
    diff --git a/frontend/src/features/web/PairingScreen.test.tsx b/frontend/src/features/web/PairingScreen.test.tsx index 4aab76a..c0080a8 100644 --- a/frontend/src/features/web/PairingScreen.test.tsx +++ b/frontend/src/features/web/PairingScreen.test.tsx @@ -3,8 +3,13 @@ * - the field must summon the text keyboard, not the numeric keypad; * - a lowercase entry must reach `session.pair()` uppercased and space-free, * because the server compares the code strictly. + * + * #77 lot F1 — the handshake becomes `{code, name}`: + * - dashes normalise away too, so a code retyped as `AB12-CD34` still pairs; + * - the device name is prefilled from a readable derivation, editable, and + * required; it is never a raw User-Agent. */ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { WebSession } from "@/adapters/http"; @@ -28,50 +33,156 @@ const okFetch: FetchLike = async () => ({ text: async () => "{}", }); -function setup() { - const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); +/** A `/api/pair` that always fails with the given wire code. */ +function failingFetch(status: number, code: string): FetchLike { + return async () => ({ + ok: false, + status, + json: async () => ({ code, message: "server wording" }), + text: async () => "{}", + }); +} + +/** Pins the UA so the name prefill is deterministic. */ +function stubUserAgent(ua: string): void { + Object.defineProperty(window.navigator, "userAgent", { + value: ua, + configurable: true, + }); +} + +const ORIGINAL_UA = window.navigator.userAgent; + +function setup(fetchImpl: FetchLike = okFetch) { + const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() }); const pair = vi.spyOn(session, "pair"); const onPaired = vi.fn(); render(); return { pair, onPaired }; } +const codeField = () => screen.getByLabelText("Code d'appairage"); +const nameField = () => screen.getByLabelText("Nom de cet appareil") as HTMLInputElement; +const submit = () => screen.getByRole("button", { name: "Appairer" }) as HTMLButtonElement; + +beforeEach(() => stubUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)")); +afterEach(() => stubUserAgent(ORIGINAL_UA)); + describe("PairingScreen code field", () => { it("asks for the text keyboard, not the numeric keypad", () => { setup(); - const field = screen.getByLabelText("Code d'appairage"); - expect(field.getAttribute("inputmode")).toBe("text"); - expect(field.getAttribute("autocapitalize")).toBe("characters"); - expect(field.getAttribute("autocomplete")).toBe("one-time-code"); + expect(codeField().getAttribute("inputmode")).toBe("text"); + expect(codeField().getAttribute("autocapitalize")).toBe("characters"); + expect(codeField().getAttribute("autocomplete")).toBe("one-time-code"); }); it("describes the code without lying about its shape", () => { setup(); - const field = screen.getByLabelText("Code d'appairage"); - expect(field.getAttribute("placeholder")).toBe("p. ex. AB12CD34"); + expect(codeField().getAttribute("placeholder")).toBe("p. ex. AB12CD34"); expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy(); }); it("uppercases and strips spaces before pairing", async () => { const { pair, onPaired } = setup(); - fireEvent.change(screen.getByLabelText("Code d'appairage"), { - target: { value: " ab12cd34 " }, - }); - fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + fireEvent.change(codeField(), { target: { value: " ab12cd34 " } }); + fireEvent.click(submit()); await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); - expect(pair).toHaveBeenCalledWith("AB12CD34"); + expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone"); }); - it("keeps submit disabled for a blank code", () => { - setup(); - const submit = screen.getByRole("button", { name: "Appairer" }); - expect((submit as HTMLButtonElement).disabled).toBe(true); + it("strips a dash the user retyped from the grouped display (#77)", async () => { + const { pair, onPaired } = setup(); - fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: " " } }); - expect((submit as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(codeField(), { target: { value: "AB12-CD34" } }); + fireEvent.click(submit()); + + await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); + expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone"); + }); +}); + +describe("PairingScreen device name", () => { + it("prefills a readable name, never the raw User-Agent", () => { + setup(); + + expect(nameField().value).toBe("iPhone"); + expect(document.body.textContent).not.toContain("Mozilla/5.0"); + }); + + it("sends the edited name with the code", async () => { + const { pair, onPaired } = setup(); + + fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); + fireEvent.change(nameField(), { target: { value: " Téléphone de Marie " } }); + fireEvent.click(submit()); + + await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); + expect(pair).toHaveBeenCalledWith("AB12CD34", "Téléphone de Marie"); + }); + + it("caps the name at 40 characters", () => { + setup(); + expect(nameField().maxLength).toBe(40); + }); + + it("requires both a code and a name", () => { + setup(); + + // A name alone (prefilled) is not enough. + expect(submit().disabled).toBe(true); + + fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); + expect(submit().disabled).toBe(false); + + // Clearing the name blocks it again: the list must never show a blank row. + fireEvent.change(nameField(), { target: { value: " " } }); + expect(submit().disabled).toBe(true); + }); + + it("keeps submit disabled for a blank or separator-only code", () => { + setup(); + + fireEvent.change(codeField(), { target: { value: " " } }); + expect(submit().disabled).toBe(true); + + fireEvent.change(codeField(), { target: { value: " - " } }); + expect(submit().disabled).toBe(true); + }); +}); + +describe("PairingScreen error placement", () => { + async function submitAndFail(status: number, code: string) { + setup(failingFetch(status, code)); + fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); + fireEvent.click(submit()); + await screen.findByRole("alert"); + } + + it("shows a rejected name on the name field, not as a form error", async () => { + await submitAndFail(400, "invalid_name"); + + // Attached to the field: the fix is one edit away, and the code is still + // valid — a form-level error would read as "start over". + const message = screen.getByRole("alert"); + expect(message.textContent).toBe( + "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", + ); + expect(nameField().getAttribute("aria-describedby")).toBe(message.id); + expect(nameField().getAttribute("aria-invalid")).toBe("true"); + expect(screen.queryByTestId("pairing-error")).toBeNull(); + // The code was never consumed, so it must not be painted as the culprit. + expect(codeField().getAttribute("aria-invalid")).not.toBe("true"); + }); + + it("keeps a rejected code as a form error", async () => { + await submitAndFail(401, "invalid_or_expired"); + + expect(screen.getByTestId("pairing-error").textContent).toBe("Code invalide ou expiré."); + // The name is not at fault, so it must not be marked as such. + expect(nameField().getAttribute("aria-invalid")).not.toBe("true"); }); }); diff --git a/frontend/src/features/web/PairingScreen.tsx b/frontend/src/features/web/PairingScreen.tsx index 89edf46..9f6ad7e 100644 --- a/frontend/src/features/web/PairingScreen.tsx +++ b/frontend/src/features/web/PairingScreen.tsx @@ -1,10 +1,15 @@ /** - * Web pairing screen — ticket #13, lot F2. + * Web pairing screen — ticket #13 lot F2, extended by #75 and #77 lot F1. * * Shown by {@link WebApp} when the client is not paired. The user types the code - * the server printed at first launch; on submit we `POST /api/pair {code}` via - * the {@link WebSession}. On success the server sets the HttpOnly session cookie - * and we advance to the workspace; a wrong code shows a clear message. + * generated from an already-paired device (or printed by `--new-code`) plus a + * name for *this* device; on submit we `POST /api/pair {code, name}` via the + * {@link WebSession}. On success the server sets the HttpOnly session cookie and + * we advance to the workspace. + * + * The device name is typed here, on the new device, because this is the only + * moment the person is holding it — the generating device cannot know what to + * call it. It is prefilled from a readable derivation (never a raw User-Agent). * * Transport-neutral at the component seam: it talks to the injected * {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke` @@ -13,9 +18,10 @@ import { useState, type FormEvent } from "react"; -import type { GatewayError } from "@/domain"; +import { normalizePairingCode, type GatewayError } from "@/domain"; import { Button, Field, Input, Panel } from "@/shared"; import type { WebSession } from "@/adapters/http"; +import { clampDeviceName, currentDeviceName, DEVICE_NAME_MAX } from "./deviceName"; interface PairingScreenProps { /** The shared web session performing the `POST /api/pair` handshake. */ @@ -24,34 +30,43 @@ interface PairingScreenProps { onPaired: () => void; } -function describe(e: unknown): string { - if (e && typeof e === "object" && "message" in e) { - return String((e as GatewayError).message); - } - return String(e); +/** The failure as the form needs it: a message plus which field to blame. */ +interface PairingFailure { + code: string; + message: string; } -/** - * The server compares the code strictly against the uppercase hex it generated, - * so the UI uppercases (and drops stray spaces) before sending. - */ -function normalize(raw: string): string { - return raw.replace(/\s+/g, "").toUpperCase(); +function describe(e: unknown): PairingFailure { + if (e && typeof e === "object" && "message" in e) { + const err = e as GatewayError; + return { code: String(err.code ?? "ERROR"), message: String(err.message) }; + } + return { code: "ERROR", message: String(e) }; } export function PairingScreen({ session, onPaired }: PairingScreenProps) { const [code, setCode] = useState(""); - const [error, setError] = useState(null); + const [name, setName] = useState(() => clampDeviceName(currentDeviceName())); + const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const normalizedCode = normalizePairingCode(code); + const trimmedName = name.trim(); + const canSubmit = normalizedCode.length > 0 && trimmedName.length > 0; + + // A rejected name is the one failure the user fixes in the form rather than by + // fetching a new code (the server validates it before consuming the code, so + // the code is still live). Point at the field instead of the whole form. + const nameError = error?.code === "invalidName" ? error.message : null; + const formError = error && !nameError ? error.message : null; + async function submit(e: FormEvent): Promise { e.preventDefault(); - const normalized = normalize(code); - if (!normalized || busy) return; + if (!canSubmit || busy) return; setBusy(true); setError(null); try { - await session.pair(normalized); + await session.pair(normalizedCode, clampDeviceName(trimmedName)); onPaired(); } catch (err) { setError(describe(err)); @@ -89,18 +104,39 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) { autoCorrect="off" spellCheck={false} disabled={busy} - invalid={!!error} + invalid={!!formError} /> )} - {error && ( + + {({ id, describedBy }) => ( + setName(e.target.value)} + maxLength={DEVICE_NAME_MAX} + autoComplete="off" + autoCorrect="off" + spellCheck={false} + disabled={busy} + invalid={!!nameError} + /> + )} + + + {formError && (

    - {error} + {formError}

    )} - diff --git a/frontend/src/features/web/WebApp.tsx b/frontend/src/features/web/WebApp.tsx index c222a49..d9cb050 100644 --- a/frontend/src/features/web/WebApp.tsx +++ b/frontend/src/features/web/WebApp.tsx @@ -8,12 +8,18 @@ * 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. + * + * "Appareils" (#77) mounts the shared {@link DevicesScreen} — the same component + * the desktop shows under `Paramètres → Appareils`. It is what makes a headless + * install usable: generating a pairing code from an already-paired phone instead + * of restarting the server with `--new-code`. */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/shared"; import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; +import { DevicesScreen } from "@/features/devices"; import { PairingScreen } from "./PairingScreen"; import { WebWorkspace } from "./WebWorkspace"; @@ -26,11 +32,17 @@ export function WebApp({ session }: WebAppProps = {}) { const webSession = session ?? getWebSession(); const [paired, setPaired] = useState(() => webSession.isPaired()); const [signingOut, setSigningOut] = useState(false); + const [showDevices, setShowDevices] = useState(false); useEffect(() => { // 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)); + // WS is torn down by the composition-root 401 handler (F6). This is also the + // path for a *suffered* revocation (#77): another device revoked us, the + // server rejects the next call, and we land back on pairing. + return webSession.onUnauthorized(() => { + setShowDevices(false); + setPaired(false); + }); }, [webSession]); async function signOut(): Promise { @@ -43,10 +55,24 @@ export function WebApp({ session }: WebAppProps = {}) { } finally { disconnectWebLive(); setSigningOut(false); + setShowDevices(false); setPaired(false); } } + /** + * This device just revoked itself (#77) — a nominal action, not an edge case. + * The cookie is already dead server-side; drop the local flag and the live + * socket so nothing keeps retrying, and land on pairing immediately rather + * than waiting for the next request to 401. + */ + const onSessionEnded = useCallback(() => { + webSession.forget(); + disconnectWebLive(); + setShowDevices(false); + setPaired(false); + }, [webSession]); + return (
    {paired && ( - +
    + + +
    )}
    - {paired ? ( - - ) : ( + {!paired ? ( setPaired(true)} /> + ) : showDevices ? ( +
    + +
    + ) : ( + )}
    diff --git a/frontend/src/features/web/deviceName.test.ts b/frontend/src/features/web/deviceName.test.ts new file mode 100644 index 0000000..cb913fc --- /dev/null +++ b/frontend/src/features/web/deviceName.test.ts @@ -0,0 +1,63 @@ +/** + * #77 lot F1 — the device-name prefill. The contract that matters is the + * negative one: whatever the UA says, the derived name is something a human + * would have typed, and never a fragment of the User-Agent itself. + */ +import { describe, it, expect } from "vitest"; + +import { clampDeviceName, deriveDeviceName } from "./deviceName"; + +const UA = { + iphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + ipad: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + androidChrome: + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + chromeWindows: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + firefoxLinux: "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", + safariMac: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + edgeWindows: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", +}; + +describe("deriveDeviceName", () => { + it("names phones and tablets by the device, not the browser", () => { + expect(deriveDeviceName(UA.iphone)).toBe("iPhone"); + expect(deriveDeviceName(UA.ipad)).toBe("iPad"); + expect(deriveDeviceName(UA.androidChrome)).toBe("Android"); + }); + + it("names desktops by browser and OS", () => { + expect(deriveDeviceName(UA.chromeWindows)).toBe("Chrome sur Windows"); + expect(deriveDeviceName(UA.firefoxLinux)).toBe("Firefox sur Linux"); + expect(deriveDeviceName(UA.safariMac)).toBe("Safari sur Mac"); + }); + + it("prefers the more specific browser when a UA claims several", () => { + // Edge's UA also contains "Chrome/" and "Safari/"; naming it "Chrome" would + // make two different browsers on one machine indistinguishable in the list. + expect(deriveDeviceName(UA.edgeWindows)).toBe("Edge sur Windows"); + }); + + it("falls back to a usable label rather than leaking the UA", () => { + expect(deriveDeviceName("")).toBe("Cet appareil"); + expect(deriveDeviceName("SomeBot/1.0 (compatible)")).toBe("Cet appareil"); + }); + + it("never returns a raw User-Agent fragment", () => { + for (const ua of Object.values(UA)) { + const name = deriveDeviceName(ua); + expect(name).not.toContain("Mozilla"); + expect(name).not.toContain("/"); + expect(name.length).toBeLessThanOrEqual(40); + } + }); +}); + +describe("clampDeviceName", () => { + it("trims and caps at 40 characters", () => { + expect(clampDeviceName(" iPhone ")).toBe("iPhone"); + expect(clampDeviceName("x".repeat(60))).toHaveLength(40); + }); +}); diff --git a/frontend/src/features/web/deviceName.ts b/frontend/src/features/web/deviceName.ts new file mode 100644 index 0000000..e43c21e --- /dev/null +++ b/frontend/src/features/web/deviceName.ts @@ -0,0 +1,77 @@ +/** + * Readable device-name derivation for the pairing screen (ticket #77, lot F1). + * + * The name is only a **prefill**: the user sees it, can rewrite it, and it is + * what the device list will show forever after. So it must read like something a + * human would type — `iPhone`, `Chrome sur Windows` — never a raw User-Agent. + * The UA string is an implementation detail of this derivation and must not + * reach any surface: no tooltip, no placeholder, no fallback text. + * + * Pure and UA-string-driven (no `navigator` access) so the mapping is testable + * without a browser; {@link currentDeviceName} is the thin impure wrapper. + */ + +/** Bounds enforced by the pairing form and the rename action. */ +export const DEVICE_NAME_MAX = 40; +export const DEVICE_NAME_MIN = 1; + +/** Last-resort label when nothing recognisable is in the UA. */ +const FALLBACK = "Cet appareil"; + +/** Browsers, most specific first: Edge/Opera also claim "Chrome"/"Safari". */ +const BROWSERS: ReadonlyArray<[RegExp, string]> = [ + [/\bEdg(?:e|A|iOS)?\//, "Edge"], + [/\bOPR\/|\bOpera\//, "Opera"], + [/\bFirefox\/|\bFxiOS\//, "Firefox"], + [/\bChrome\/|\bCriOS\//, "Chrome"], + [/\bSafari\//, "Safari"], +]; + +/** Desktop OSes only — phones/tablets are named by the device, not the OS. */ +const DESKTOP_OS: ReadonlyArray<[RegExp, string]> = [ + [/\bWindows\b/, "Windows"], + [/\bMac OS X\b|\bMacintosh\b/, "Mac"], + [/\bCrOS\b/, "ChromeOS"], + [/\bLinux\b|\bX11\b/, "Linux"], +]; + +/** + * Derives a human label from a User-Agent string. + * + * Phones and tablets are named by the device alone (`iPhone`, `Android`) because + * that is how people refer to them; on desktop the browser is the distinguishing + * fact when several browsers share one machine, hence `Chrome sur Windows`. + */ +export function deriveDeviceName(userAgent: string): string { + const ua = userAgent ?? ""; + + if (/\biPhone\b/.test(ua)) return "iPhone"; + if (/\biPad\b/.test(ua)) return "iPad"; + // iPadOS 13+ masquerades as a Mac; the touch points give it away, but that is + // a `navigator` fact, not a UA one — a plain "Mac" here is an honest miss. + if (/\bAndroid\b/.test(ua)) return "Android"; + + const browser = BROWSERS.find(([re]) => re.test(ua))?.[1]; + const os = DESKTOP_OS.find(([re]) => re.test(ua))?.[1]; + + if (browser && os) return `${browser} sur ${os}`; + return browser ?? os ?? FALLBACK; +} + +/** + * Clamps a derived or typed name to the contract's 1-40 characters. + * + * Counts **code points**, not UTF-16 units, to match the server's + * `DeviceName::new` (`chars().count()`). `slice(0, 40)` would both disagree with + * that bound on astral characters and be able to cut a surrogate pair in half, + * putting a lone surrogate on the wire. + */ +export function clampDeviceName(name: string): string { + return Array.from(name.trim()).slice(0, DEVICE_NAME_MAX).join(""); +} + +/** Derives the current browser's device name; `Cet appareil` outside a browser. */ +export function currentDeviceName(): string { + if (typeof navigator === "undefined" || !navigator.userAgent) return FALLBACK; + return deriveDeviceName(navigator.userAgent); +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 6dc481f..31caca6 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -34,6 +34,8 @@ import type { MemoryType, OpenCodeConfig, EffectivePermissions, + PairedDevice, + PairingCode, PermissionSet, PageDirection, Project, @@ -750,6 +752,31 @@ export interface EmbedderGateway { describeEmbedderEngines(): Promise; } +/** + * Paired-device management (ticket #77) — the access surface of the instance. + * + * Mounted identically on web and desktop, so it is a **port**, not a web + * concern: the desktop adapter reaches the same use cases through the Tauri + * composition root, the web adapter over HTTP. Revoking is authoritative + * server-side; revoking the *current* device ends this session, which callers + * detect from {@link PairedDevice.isCurrentDevice} before the call. + */ +export interface DeviceGateway { + /** Lists every paired device, most recently active first (backend order). */ + listDevices(): Promise; + /** + * Generates a fresh single-use pairing code, invalidating any previous one. + * The code exists only in server memory until it is consumed or expires. + */ + createPairingCode(): Promise; + /** Renames one device (1-40 characters). */ + renameDevice(deviceId: string, name: string): Promise; + /** Revokes one device; its live WebSockets are closed server-side (B3). */ + revokeDevice(deviceId: string): Promise; + /** Revokes every device, the current one included. */ + revokeAllDevices(): Promise; +} + /** Project and agent permission management (LP1). */ export interface PermissionGateway { /** Reads the full project permission document. */ @@ -1130,6 +1157,7 @@ export interface Gateways { skill: SkillGateway; memory: MemoryGateway; embedder: EmbedderGateway; + device: DeviceGateway; permission: PermissionGateway; workState: WorkStateGateway; conversation: ConversationGateway;