feat(frontend): écran Appareils et parcours d'appairage nommé (#77 F1/F2)

Expose la gestion des appareils appairés introduite en B1-B4, sur une
surface unique partagée par le web et le desktop.

- Écran Appareils : liste, renommage, révocation unitaire ou globale,
  activité formatée, panneau de code éphémère.
- Le parcours d'appairage demande un nom d'appareil, pour qu'une
  révocation porte sur quelque chose d'identifiable par l'utilisateur.
- Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port
  restant le seul contrat connu de la feature.

Les erreurs sont mappées localement et le message du serveur n'est jamais
affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour
qui teste des codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:27:06 +02:00
parent 8fe93d1652
commit 3f1e132e88
27 changed files with 2013 additions and 135 deletions

View File

@ -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<PairedDevice[]> {
const dto = await invoke<DeviceListDto>("list_devices");
return dto.devices;
}
createPairingCode(): Promise<PairingCode> {
return invoke<PairingCode>("create_pairing_code");
}
renameDevice(deviceId: string, name: string): Promise<void> {
return invoke<void>("rename_device", { request: { deviceId, name } });
}
revokeDevice(deviceId: string): Promise<void> {
return invoke<void>("revoke_device", { request: { deviceId } });
}
revokeAllDevices(): Promise<void> {
return invoke<void>("revoke_all_devices");
}
}

View File

@ -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<PairedDevice[]> {
const body = await this.http.request<DeviceListResponse>("GET", "/api/devices");
return body.devices;
}
createPairingCode(): Promise<PairingCode> {
return this.http.request<PairingCode>("POST", "/api/pairing-code");
}
renameDevice(deviceId: string, name: string): Promise<void> {
return this.http.request<void>(
"POST",
`/api/devices/${encodeURIComponent(deviceId)}/rename`,
{ name },
);
}
revokeDevice(deviceId: string): Promise<void> {
return this.http.request<void>(
"POST",
`/api/devices/${encodeURIComponent(deviceId)}/revoke`,
);
}
revokeAllDevices(): Promise<void> {
return this.http.request<void>("POST", "/api/devices/revoke-all");
}
}

View File

@ -117,7 +117,25 @@ export class HttpInvoker {
* Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the * Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the
* parsed result, or rejects with a {@link GatewayError}. * parsed result, or rejects with a {@link GatewayError}.
*/ */
async invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> { invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
return this.request<T>("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<T>(
method: string,
path: string,
body?: unknown,
what = `${method} ${path}`,
): Promise<T> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
@ -125,10 +143,10 @@ export class HttpInvoker {
let res: Awaited<ReturnType<FetchLike>>; let res: Awaited<ReturnType<FetchLike>>;
try { try {
res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, { res = await this.fetchImpl(`${this.baseUrl}${path}`, {
method: "POST", method,
headers, headers,
body: JSON.stringify({ command, args }), body: body === undefined ? undefined : JSON.stringify(body),
// Same-origin so the HttpOnly session cookie is sent automatically // Same-origin so the HttpOnly session cookie is sent automatically
// (ticket #13: no secret in the URL/headers from JS). // (ticket #13: no secret in the URL/headers from JS).
credentials: "same-origin", credentials: "same-origin",
@ -136,7 +154,7 @@ export class HttpInvoker {
} catch (networkError) { } catch (networkError) {
const err: GatewayError = { const err: GatewayError = {
code: "TRANSPORT_ERROR", code: "TRANSPORT_ERROR",
message: `HTTP request for '${command}' failed: ${String(networkError)}`, message: `HTTP request for ${what} failed: ${String(networkError)}`,
}; };
throw err; throw err;
} }
@ -152,16 +170,16 @@ export class HttpInvoker {
} catch { } catch {
parsed = undefined; 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). // A 204 / empty body maps to `undefined` (the void commands).
let body: unknown; let parsed: unknown;
try { try {
body = await res.json(); parsed = await res.json();
} catch { } catch {
body = undefined; parsed = undefined;
} }
return body as T; return parsed as T;
} }
} }

View File

@ -20,6 +20,7 @@ import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient"; import { WsLiveClient } from "./wsLiveClient";
import { getWebSession } from "./webSession"; import { getWebSession } from "./webSession";
import { setWebLiveClient } from "./webLive"; import { setWebLiveClient } from "./webLive";
import { HttpDeviceGateway } from "./deviceGateway";
import { import {
HttpConversationGateway, HttpConversationGateway,
HttpEmbedderGateway, HttpEmbedderGateway,
@ -129,6 +130,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
skill: new HttpSkillGateway(http), skill: new HttpSkillGateway(http),
memory: new HttpMemoryGateway(http), memory: new HttpMemoryGateway(http),
embedder: new HttpEmbedderGateway(http), embedder: new HttpEmbedderGateway(http),
device: new HttpDeviceGateway(http),
permission: new HttpPermissionGateway(http), permission: new HttpPermissionGateway(http),
workState: new HttpWorkStateGateway(http), workState: new HttpWorkStateGateway(http),
conversation: new HttpConversationGateway(http), conversation: new HttpConversationGateway(http),

View File

@ -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 * rejects with a clear message), and the 401 → unauthorized transition that
* clears the flag and notifies subscribers. * 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"; import { describe, it, expect, vi } from "vitest";
@ -35,35 +40,106 @@ function fetchReturning(status: number, body: unknown): {
} }
describe("WebSession pairing", () => { 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 store = memStore();
const { fetchImpl, calls } = fetchReturning(200, { ok: true }); const { fetchImpl, calls } = fetchReturning(200, { ok: true });
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
expect(session.isPaired()).toBe(false); expect(session.isPaired()).toBe(false);
await session.pair("4821-93"); await session.pair("AB12CD34", "iPhone");
expect(session.isPaired()).toBe(true); expect(session.isPaired()).toBe(true);
expect(calls[0].url).toBe("https://host/api/pair"); 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 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 }); 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); 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 { fetchImpl } = fetchReturning(403, undefined);
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("x")).rejects.toMatchObject({ await expect(session.pair("x", "iPhone")).rejects.toMatchObject({
code: "INVALID_PAIRING_CODE", code: "invalidOrExpired",
message: "Code d'appairage invalide.", message: "Code invalide ou expiré.",
}); });
}); });
}); });
@ -113,7 +189,7 @@ describe("WebSession default fetch receiver", () => {
try { try {
// No `fetchImpl` injected ⇒ the global fallback is used. // No `fetchImpl` injected ⇒ the global fallback is used.
const session = new WebSession({ baseUrl: "https://host", store: memStore() }); const session = new WebSession({ baseUrl: "https://host", store: memStore() });
await session.pair("4821-93"); await session.pair("AB12CD34", "iPhone");
} finally { } finally {
globalThis.fetch = original; globalThis.fetch = original;
} }

View File

@ -65,6 +65,63 @@ function defaultBaseUrl(): string {
: "http://localhost"; : "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<PairingErrorCode, string> = {
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 * The web session state machine. One instance is shared between the pairing UI
* and the HTTP invoker (via the module singleton below). * 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 * Performs the pairing handshake: `POST /api/pair {code, name}` (#77). `name`
* server sets the session cookie and this records the paired flag. A wrong code * is the human label for *this* device, typed on it at pairing time. On success
* rejects with a {@link GatewayError} carrying a clear message. * 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<void> { async pair(code: string, name: string): Promise<void> {
let res: Awaited<ReturnType<FetchLike>>; let res: Awaited<ReturnType<FetchLike>>;
try { try {
res = await this.fetchImpl(`${this.baseUrl}/api/pair`, { res = await this.fetchImpl(`${this.baseUrl}/api/pair`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }), body: JSON.stringify({ code, name }),
}); });
} catch (networkError) { } catch (networkError) {
const err: GatewayError = { const err: GatewayError = {
@ -145,17 +205,7 @@ export class WebSession {
} catch { } catch {
parsed = undefined; parsed = undefined;
} }
const message = throw toPairingError(parsed, res.status);
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;
} }
// Cookie is now set by the server (HttpOnly — not read here); record the flag. // Cookie is now set by the server (HttpOnly — not read here); record the flag.

View File

@ -25,6 +25,7 @@ import { TauriTemplateGateway } from "./template";
import { TauriSkillGateway } from "./skill"; import { TauriSkillGateway } from "./skill";
import { TauriMemoryGateway } from "./memory"; import { TauriMemoryGateway } from "./memory";
import { TauriEmbedderGateway } from "./embedder"; import { TauriEmbedderGateway } from "./embedder";
import { TauriDeviceGateway } from "./device";
import { TauriGitGateway } from "./git"; import { TauriGitGateway } from "./git";
import { TauriPermissionGateway } from "./permission"; import { TauriPermissionGateway } from "./permission";
import { TauriWorkStateGateway } from "./workState"; import { TauriWorkStateGateway } from "./workState";
@ -66,6 +67,7 @@ export function createTauriGateways(): Gateways {
skill: new TauriSkillGateway(), skill: new TauriSkillGateway(),
memory: new TauriMemoryGateway(), memory: new TauriMemoryGateway(),
embedder: new TauriEmbedderGateway(), embedder: new TauriEmbedderGateway(),
device: new TauriDeviceGateway(),
permission: new TauriPermissionGateway(), permission: new TauriPermissionGateway(),
workState: new TauriWorkStateGateway(), workState: new TauriWorkStateGateway(),
conversation: new TauriConversationGateway(), conversation: new TauriConversationGateway(),
@ -90,6 +92,7 @@ export {
TauriSkillGateway, TauriSkillGateway,
TauriMemoryGateway, TauriMemoryGateway,
TauriEmbedderGateway, TauriEmbedderGateway,
TauriDeviceGateway,
TauriGitGateway, TauriGitGateway,
TauriPermissionGateway, TauriPermissionGateway,
TauriWorkStateGateway, TauriWorkStateGateway,

View File

@ -32,6 +32,8 @@ import type {
MemoryLink, MemoryLink,
MemoryType, MemoryType,
EffectivePermissions, EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet, PermissionSet,
Project, Project,
ProjectPermissions, ProjectPermissions,
@ -68,6 +70,7 @@ import type {
CreateMemoryInput, CreateMemoryInput,
CreateSkillInput, CreateSkillInput,
DesktopServerGateway, DesktopServerGateway,
DeviceGateway,
EmbedderGateway, EmbedderGateway,
CreateTemplateInput, CreateTemplateInput,
Gateways, Gateways,
@ -1471,8 +1474,6 @@ export class MockDesktopServerGateway implements DesktopServerGateway {
? undefined ? undefined
: this.settings.publicOrigin, : this.settings.publicOrigin,
upstreamUrl: preview.upstreamUrl, upstreamUrl: preview.upstreamUrl,
// Runtime-only, regenerated per start — never persisted.
pairingCode: "MOCK-PAIR-4242",
}); });
return structuredClone(this.current); 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<PairedDevice[]> {
return structuredClone(this.devices);
}
async createPairingCode(): Promise<PairingCode> {
// 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<void> {
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<void> {
this.devices = this.devices.filter((d) => d.deviceId !== deviceId);
}
async revokeAllDevices(): Promise<void> {
this.devices = [];
}
}
/** In-memory permissions gateway. */ /** In-memory permissions gateway. */
export class MockPermissionGateway implements PermissionGateway { export class MockPermissionGateway implements PermissionGateway {
private docs = new Map<string, ProjectPermissions>(); private docs = new Map<string, ProjectPermissions>();
@ -2782,6 +2843,7 @@ export function createMockGateways(): Gateways {
skill: new MockSkillGateway(agentGateway), skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(), memory: new MockMemoryGateway(),
embedder: new MockEmbedderGateway(), embedder: new MockEmbedderGateway(),
device: new MockDeviceGateway(),
permission: new MockPermissionGateway(), permission: new MockPermissionGateway(),
workState: new MockWorkStateGateway(), workState: new MockWorkStateGateway(),
conversation: new MockConversationGateway(), conversation: new MockConversationGateway(),

View File

@ -17,6 +17,7 @@ describe("createMockGateways", () => {
"agent", "agent",
"conversation", "conversation",
"desktopServer", "desktopServer",
"device",
"embedder", "embedder",
"focusedProject", "focusedProject",
"git", "git",

View File

@ -190,9 +190,11 @@ export type EmbeddedServerState =
| "failed"; | "failed";
/** /**
* Embedded-server status (mirror of `EmbeddedServerStatusDto`). `pairingCode` * Embedded-server status (mirror of `EmbeddedServerStatusDto`).
* is a runtime-only secret: it exists while the server runs, is never *
* persisted, and must never be written into a settings field. * 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 { export interface EmbeddedServerStatus {
state: EmbeddedServerState; state: EmbeddedServerState;
@ -202,8 +204,6 @@ export interface EmbeddedServerStatus {
publicUrl?: string; publicUrl?: string;
/** Upstream URL to hand to the reverse proxy. */ /** Upstream URL to hand to the reverse proxy. */
upstreamUrl?: string; upstreamUrl?: string;
/** Runtime pairing code — present only while running. */
pairingCode?: string;
/** Last failure, when `state` is `failed`. */ /** Last failure, when `state` is `failed`. */
error?: GatewayError; error?: GatewayError;
} }
@ -1321,3 +1321,52 @@ export type ReplyChunk =
| { kind: "toolActivity"; label: string } | { kind: "toolActivity"; label: string }
| { kind: "final"; content: string } | { kind: "final"; content: string }
| { kind: "error"; message: 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();
}

View File

@ -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<void>;
onCancel: () => void;
}
export function ConfirmDialog({
title,
body,
confirmLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(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 (
<div
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
style={{ zIndex: zIndex.floatingWindow }}
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
>
<h3 className="text-sm font-semibold text-content">{title}</h3>
<p className="text-sm text-muted">{body}</p>
<div className="flex justify-end gap-2">
<Button ref={cancelRef} size="sm" variant="ghost" onClick={onCancel}>
Annuler
</Button>
<Button
size="sm"
variant={danger ? "danger" : "primary"}
loading={busy}
onClick={() => void onConfirm()}
>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}

View File

@ -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(
<DIProvider gateways={gateways}>
<DevicesScreen onSessionEnded={onSessionEnded} />
</DIProvider>,
);
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(<epoch-ms>)`
// 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();
});
});

View File

@ -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<PairedDevice | null>(null);
const [confirmRevokeAll, setConfirmRevokeAll] = useState(false);
const [renaming, setRenaming] = useState<string | null>(null);
useEffect(() => {
if (vm.sessionEnded) onSessionEnded?.();
}, [vm.sessionEnded, onSessionEnded]);
const devices = vm.devices;
return (
<section data-testid="devices-screen" className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-col gap-0.5">
<h2 className="text-base font-semibold tracking-tight">Appareils</h2>
<p className="text-sm text-muted">
Les appareils autorisés à accéder à cette instance IdeA.
</p>
</div>
<Button onClick={() => void vm.generateCode()} loading={vm.generating}>
Appairer
</Button>
</div>
{vm.code && (
<PairingCodePanel
code={vm.code}
onRegenerate={() => void vm.generateCode()}
onDismiss={vm.dismissCode}
/>
)}
{vm.error && (
<Panel className="border-danger/40">
<p role="alert" className="text-sm text-danger">
{vm.error}
</p>
</Panel>
)}
{devices === null ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
<Spinner size={12} /> Chargement des appareils
</span>
) : devices.length === 0 ? (
<p className="text-sm text-muted">Aucun appareil appairé.</p>
) : (
<ul className="flex flex-col divide-y divide-border" data-testid="device-list">
{devices.map((device) => (
<DeviceRow
key={device.deviceId}
device={device}
fresh={vm.freshDeviceIds.includes(device.deviceId)}
renaming={renaming === device.deviceId}
onStartRename={() => setRenaming(device.deviceId)}
onCancelRename={() => setRenaming(null)}
onRename={async (name) => {
await vm.rename(device.deviceId, name);
setRenaming(null);
}}
onRevoke={() => setConfirmRevoke(device)}
/>
))}
</ul>
)}
{devices !== null && devices.length > 0 && (
<div className="pt-1">
<Button variant="ghost" size="sm" onClick={() => setConfirmRevokeAll(true)}>
Révoquer tous les appareils
</Button>
</div>
)}
{confirmRevoke && (
<ConfirmDialog
title="Révoquer cet appareil ?"
body={
confirmRevoke.isCurrentDevice
? "Il devra être appairé à nouveau pour accéder à IdeA. Vous serez déconnecté immédiatement."
: "Il devra être appairé à nouveau pour accéder à IdeA."
}
confirmLabel="Révoquer"
busy={vm.busy}
onConfirm={async () => {
const target = confirmRevoke;
setConfirmRevoke(null);
await vm.revoke(target);
}}
onCancel={() => setConfirmRevoke(null)}
/>
)}
{confirmRevokeAll && (
<ConfirmDialog
title="Révoquer tous les appareils ?"
// Stated without needing the list: the point of this action is the lost
// phone you cannot enumerate calmly.
body="Tous les appareils, y compris celui-ci, devront être appairés à nouveau. Vous serez déconnecté immédiatement."
confirmLabel="Tout révoquer"
danger
busy={vm.busy}
onConfirm={async () => {
setConfirmRevokeAll(false);
await vm.revokeAll();
}}
onCancel={() => setConfirmRevokeAll(false)}
/>
)}
</section>
);
}
/** 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<void>;
onRevoke: () => void;
}) {
return (
<li
data-testid="device-row"
data-fresh={fresh || undefined}
className={cn(
"py-3 transition-colors first:pt-0 last:pb-0",
fresh && "bg-success/10",
)}
>
{renaming ? (
<RenameForm device={device} onSubmit={onRename} onCancel={onCancelRename} />
) : (
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-content">{device.name}</span>
{device.isCurrentDevice && (
<span className="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
Cet appareil
</span>
)}
</span>
<span className="text-xs text-muted">{formatLastSeen(device.lastSeenAtMs)}</span>
<span className="text-[0.7rem] text-faint">{formatPairedAt(device.pairedAtMs)}</span>
</div>
<RowMenu deviceName={device.name} onRename={onStartRename} onRevoke={onRevoke} />
</div>
)}
</li>
);
}
/** Inline rename (1-40 characters), submitted with Enter or the button. */
function RenameForm({
device,
onSubmit,
onCancel,
}: {
device: PairedDevice;
onSubmit: (name: string) => Promise<void>;
onCancel: () => void;
}) {
const [name, setName] = useState(device.name);
const trimmed = name.trim();
return (
<form
className="flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
if (trimmed.length > 0) void onSubmit(trimmed);
}}
>
<Field label="Nom de l'appareil" hideLabel>
{({ id }) => (
<Input
id={id}
value={name}
autoFocus
maxLength={DEVICE_NAME_MAX}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
/>
)}
</Field>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={trimmed.length === 0}>
Renommer
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>
Annuler
</Button>
</div>
</form>
);
}
/**
* 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 (
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={`Actions pour ${deviceName}`}
onClick={() => setOpen((v) => !v)}
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-raised hover:text-content"
>
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full z-10 mt-1 flex min-w-36 flex-col rounded-md border border-border bg-raised py-1 shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
onRename();
}}
className="px-3 py-1.5 text-left text-sm text-content transition-colors hover:bg-canvas"
>
Renommer
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
onRevoke();
}}
className="px-3 py-1.5 text-left text-sm text-danger transition-colors hover:bg-canvas"
>
Révoquer
</button>
</div>
)}
</div>
);
}

View File

@ -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<boolean> {
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 (
<Panel data-testid="pairing-code-panel" className="flex flex-col gap-3">
{expired ? (
<>
<p className="text-sm text-muted">Ce code a expiré.</p>
<div>
<Button size="sm" onClick={onRegenerate}>
Générer un nouveau code
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-muted">Saisissez ce code sur le nouvel appareil.</p>
<p
data-testid="pairing-code-value"
// The accessible name is the plain code: a screen reader must hear
// the value to type, not the visual blocks.
aria-label={code.code}
className="flex gap-3 font-mono text-2xl font-semibold tracking-[0.2em] text-content sm:text-3xl"
>
{codeBlocks(code.code).map((block, i) => (
<span key={i} aria-hidden="true">
{block}
</span>
))}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() =>
void copyToClipboard(code.code).then((ok) => setCopied(ok))
}
>
{copied ? "Copié" : "Copier"}
</Button>
<span className="text-xs text-faint" data-testid="pairing-code-countdown">
{remaining}
</span>
<Button size="sm" variant="ghost" className="ml-auto" onClick={onDismiss}>
Fermer
</Button>
</div>
</>
)}
</Panel>
);
}

View File

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

View File

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

View File

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

View File

@ -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<void>;
generateCode(): Promise<void>;
dismissCode(): void;
rename(deviceId: string, name: string): Promise<void>;
revoke(device: PairedDevice): Promise<void>;
revokeAll(): Promise<void>;
}
export function useDevices(): UseDevices {
const { device: gateway } = useGateways();
const [devices, setDevices] = useState<PairedDevice[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [generating, setGenerating] = useState(false);
const [code, setCode] = useState<PairingCode | null>(null);
const [freshDeviceIds, setFreshDeviceIds] = useState<string[]>([]);
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<Set<string> | null>(null);
const load = useCallback(async (): Promise<PairedDevice[] | null> => {
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<void>): Promise<boolean> => {
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,
};
}

View File

@ -5,7 +5,7 @@
* These pin the UX invariants the screen exists for, not its styling: the mode * 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 * is a radio choice with consequences, the authorized-proxy field is explicitly
* *not* a listen address, addresses come from the backend, a refusal is * *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"; import { describe, it, expect, vi } from "vitest";
@ -142,30 +142,31 @@ describe("DeploymentSettings", () => {
expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull(); 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(); renderView();
await settle(); await settle();
expect(
screen.getByText("Start the server to generate a pairing code."),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull(); expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Start" })); fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(screen.getByText("Running")).toBeTruthy());
expect( expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
await screen.findByRole("button", { name: "copy pairing code" }), expect(screen.queryByText(/generate a pairing code/i)).toBeNull();
).toBeTruthy(); });
expect(
screen.getByText(/Temporary code. It disappears when the server stops/),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Stop" })); it("points to where pairing now lives instead of dead-ending", async () => {
await waitFor(() => // Someone who just started the server from this screen needs to know where
expect( // to go next; silence would be a cul-de-sac.
screen.getByText("Start the server to generate a pairing code."), renderView();
).toBeTruthy(), 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 () => { it("starts the server and reports the local URL", async () => {

View File

@ -16,8 +16,11 @@
* the whole reason this screen is worded the way it is; the help text under * the whole reason this screen is worded the way it is; the help text under
* the field says so explicitly. * the field says so explicitly.
* *
* The pairing code is runtime-only: shown while running, never rendered into a * This screen no longer shows a pairing code (#77). A code is not a property of
* persisted field, never mixed with the upstream value. * 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"; import { useState } from "react";
@ -334,21 +337,12 @@ export function DeploymentSettings() {
</Panel> </Panel>
)} )}
{/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */} {/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
<Panel title="Pairing"> <Panel title="Pairing">
{running && status.pairingCode ? (
<div className="flex flex-col gap-2">
<ReadOnlyValue value={status.pairingCode} copyLabel="copy pairing code" />
<p className="text-xs text-muted">
Temporary code. It disappears when the server stops. Do not save it
in configuration files.
</p>
</div>
) : (
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Start the server to generate a pairing code. Pairing is managed in <span className="text-content">Settings Appareils</span>,
where you can generate a code and revoke devices.
</p> </p>
)}
</Panel> </Panel>
</div> </div>
); );

View File

@ -17,19 +17,32 @@
import { Button, cn } from "@/shared"; import { Button, cn } from "@/shared";
import { ProfilesSettings } from "@/features/first-run"; import { ProfilesSettings } from "@/features/first-run";
import { DevicesScreen } from "@/features/devices";
import { DeploymentSettings } from "./DeploymentSettings"; import { DeploymentSettings } from "./DeploymentSettings";
/** The Settings sections, in menu/nav order. */ /** 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<SettingsSection, string> = { export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
aiProfiles: "AI Profiles", aiProfiles: "AI Profiles",
deployment: "Deployment", deployment: "Deployment",
devices: "Appareils",
}; };
/** Section order — the single source of truth for both nav and menu. */ /** 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 { interface SettingsViewProps {
section: SettingsSection; section: SettingsSection;
@ -77,7 +90,15 @@ export function SettingsView({
<div className="flex flex-1 justify-center overflow-y-auto p-6"> <div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
{section === "aiProfiles" ? <ProfilesSettings /> : <DeploymentSettings />} {section === "aiProfiles" ? (
<ProfilesSettings />
) : section === "deployment" ? (
<DeploymentSettings />
) : (
// No `onSessionEnded`: the desktop app hosts the server and is never
// itself a paired device, so it cannot revoke its own session.
<DevicesScreen />
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,8 +3,13 @@
* - the field must summon the text keyboard, not the numeric keypad; * - the field must summon the text keyboard, not the numeric keypad;
* - a lowercase entry must reach `session.pair()` uppercased and space-free, * - a lowercase entry must reach `session.pair()` uppercased and space-free,
* because the server compares the code strictly. * 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 { fireEvent, render, screen } from "@testing-library/react";
import { WebSession } from "@/adapters/http"; import { WebSession } from "@/adapters/http";
@ -28,50 +33,156 @@ const okFetch: FetchLike = async () => ({
text: async () => "{}", text: async () => "{}",
}); });
function setup() { /** A `/api/pair` that always fails with the given wire code. */
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); 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 pair = vi.spyOn(session, "pair");
const onPaired = vi.fn(); const onPaired = vi.fn();
render(<PairingScreen session={session} onPaired={onPaired} />); render(<PairingScreen session={session} onPaired={onPaired} />);
return { pair, onPaired }; 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", () => { describe("PairingScreen code field", () => {
it("asks for the text keyboard, not the numeric keypad", () => { it("asks for the text keyboard, not the numeric keypad", () => {
setup(); setup();
const field = screen.getByLabelText("Code d'appairage");
expect(field.getAttribute("inputmode")).toBe("text"); expect(codeField().getAttribute("inputmode")).toBe("text");
expect(field.getAttribute("autocapitalize")).toBe("characters"); expect(codeField().getAttribute("autocapitalize")).toBe("characters");
expect(field.getAttribute("autocomplete")).toBe("one-time-code"); expect(codeField().getAttribute("autocomplete")).toBe("one-time-code");
}); });
it("describes the code without lying about its shape", () => { it("describes the code without lying about its shape", () => {
setup(); 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(); expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy();
}); });
it("uppercases and strips spaces before pairing", async () => { it("uppercases and strips spaces before pairing", async () => {
const { pair, onPaired } = setup(); const { pair, onPaired } = setup();
fireEvent.change(screen.getByLabelText("Code d'appairage"), { fireEvent.change(codeField(), { target: { value: " ab12cd34 " } });
target: { value: " ab12cd34 " }, fireEvent.click(submit());
});
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34"); expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone");
}); });
it("keeps submit disabled for a blank code", () => { it("strips a dash the user retyped from the grouped display (#77)", async () => {
setup(); const { pair, onPaired } = setup();
const submit = screen.getByRole("button", { name: "Appairer" });
expect((submit as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: " " } }); fireEvent.change(codeField(), { target: { value: "AB12-CD34" } });
expect((submit as HTMLButtonElement).disabled).toBe(true); 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");
}); });
}); });

View File

@ -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 * 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 * generated from an already-paired device (or printed by `--new-code`) plus a
* the {@link WebSession}. On success the server sets the HttpOnly session cookie * name for *this* device; on submit we `POST /api/pair {code, name}` via the
* and we advance to the workspace; a wrong code shows a clear message. * {@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 * Transport-neutral at the component seam: it talks to the injected
* {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke` * {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke`
@ -13,9 +18,10 @@
import { useState, type FormEvent } from "react"; 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 { Button, Field, Input, Panel } from "@/shared";
import type { WebSession } from "@/adapters/http"; import type { WebSession } from "@/adapters/http";
import { clampDeviceName, currentDeviceName, DEVICE_NAME_MAX } from "./deviceName";
interface PairingScreenProps { interface PairingScreenProps {
/** The shared web session performing the `POST /api/pair` handshake. */ /** The shared web session performing the `POST /api/pair` handshake. */
@ -24,34 +30,43 @@ interface PairingScreenProps {
onPaired: () => void; onPaired: () => void;
} }
function describe(e: unknown): string { /** The failure as the form needs it: a message plus which field to blame. */
if (e && typeof e === "object" && "message" in e) { interface PairingFailure {
return String((e as GatewayError).message); code: string;
} message: string;
return String(e);
} }
/** function describe(e: unknown): PairingFailure {
* The server compares the code strictly against the uppercase hex it generated, if (e && typeof e === "object" && "message" in e) {
* so the UI uppercases (and drops stray spaces) before sending. const err = e as GatewayError;
*/ return { code: String(err.code ?? "ERROR"), message: String(err.message) };
function normalize(raw: string): string { }
return raw.replace(/\s+/g, "").toUpperCase(); return { code: "ERROR", message: String(e) };
} }
export function PairingScreen({ session, onPaired }: PairingScreenProps) { export function PairingScreen({ session, onPaired }: PairingScreenProps) {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null); const [name, setName] = useState(() => clampDeviceName(currentDeviceName()));
const [error, setError] = useState<PairingFailure | null>(null);
const [busy, setBusy] = useState(false); 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<void> { async function submit(e: FormEvent): Promise<void> {
e.preventDefault(); e.preventDefault();
const normalized = normalize(code); if (!canSubmit || busy) return;
if (!normalized || busy) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
await session.pair(normalized); await session.pair(normalizedCode, clampDeviceName(trimmedName));
onPaired(); onPaired();
} catch (err) { } catch (err) {
setError(describe(err)); setError(describe(err));
@ -89,18 +104,39 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
autoCorrect="off" autoCorrect="off"
spellCheck={false} spellCheck={false}
disabled={busy} disabled={busy}
invalid={!!error} invalid={!!formError}
/> />
)} )}
</Field> </Field>
{error && ( <Field
label="Nom de cet appareil"
hint="Pour le reconnaître dans la liste des appareils."
error={nameError ?? undefined}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={DEVICE_NAME_MAX}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
disabled={busy}
invalid={!!nameError}
/>
)}
</Field>
{formError && (
<p role="alert" data-testid="pairing-error" className="text-sm text-danger"> <p role="alert" data-testid="pairing-error" className="text-sm text-danger">
{error} {formError}
</p> </p>
)} )}
<Button type="submit" disabled={busy || normalize(code).length === 0}> <Button type="submit" disabled={busy || !canSubmit}>
{busy ? "Appairage…" : "Appairer"} {busy ? "Appairage…" : "Appairer"}
</Button> </Button>
</form> </form>

View File

@ -8,12 +8,18 @@
* cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the * cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the
* server session (`POST /api/logout`), tears down the live WS singleton, and * server session (`POST /api/logout`), tears down the live WS singleton, and
* returns to pairing. * 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 { Button } from "@/shared";
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
import { DevicesScreen } from "@/features/devices";
import { PairingScreen } from "./PairingScreen"; import { PairingScreen } from "./PairingScreen";
import { WebWorkspace } from "./WebWorkspace"; import { WebWorkspace } from "./WebWorkspace";
@ -26,11 +32,17 @@ export function WebApp({ session }: WebAppProps = {}) {
const webSession = session ?? getWebSession(); const webSession = session ?? getWebSession();
const [paired, setPaired] = useState(() => webSession.isPaired()); const [paired, setPaired] = useState(() => webSession.isPaired());
const [signingOut, setSigningOut] = useState(false); const [signingOut, setSigningOut] = useState(false);
const [showDevices, setShowDevices] = useState(false);
useEffect(() => { useEffect(() => {
// A 401 anywhere clears the flag and fires this: return to pairing. The live // 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). // WS is torn down by the composition-root 401 handler (F6). This is also the
return webSession.onUnauthorized(() => setPaired(false)); // 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]); }, [webSession]);
async function signOut(): Promise<void> { async function signOut(): Promise<void> {
@ -43,10 +55,24 @@ export function WebApp({ session }: WebAppProps = {}) {
} finally { } finally {
disconnectWebLive(); disconnectWebLive();
setSigningOut(false); setSigningOut(false);
setShowDevices(false);
setPaired(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 ( return (
<div className="flex h-full flex-col bg-canvas text-content"> <div className="flex h-full flex-col bg-canvas text-content">
<header <header
@ -60,17 +86,31 @@ export function WebApp({ session }: WebAppProps = {}) {
</span> </span>
</div> </div>
{paired && ( {paired && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
aria-pressed={showDevices}
onClick={() => setShowDevices((v) => !v)}
>
{showDevices ? "Projets" : "Appareils"}
</Button>
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}> <Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}>
Se déconnecter Se déconnecter
</Button> </Button>
</div>
)} )}
</header> </header>
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{paired ? ( {!paired ? (
<WebWorkspace />
) : (
<PairingScreen session={webSession} onPaired={() => setPaired(true)} /> <PairingScreen session={webSession} onPaired={() => setPaired(true)} />
) : showDevices ? (
<div className="h-full overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6">
<DevicesScreen onSessionEnded={onSessionEnded} />
</div>
) : (
<WebWorkspace />
)} )}
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -34,6 +34,8 @@ import type {
MemoryType, MemoryType,
OpenCodeConfig, OpenCodeConfig,
EffectivePermissions, EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet, PermissionSet,
PageDirection, PageDirection,
Project, Project,
@ -750,6 +752,31 @@ export interface EmbedderGateway {
describeEmbedderEngines(): Promise<EmbedderEngines>; describeEmbedderEngines(): Promise<EmbedderEngines>;
} }
/**
* 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<PairedDevice[]>;
/**
* 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<PairingCode>;
/** Renames one device (1-40 characters). */
renameDevice(deviceId: string, name: string): Promise<void>;
/** Revokes one device; its live WebSockets are closed server-side (B3). */
revokeDevice(deviceId: string): Promise<void>;
/** Revokes every device, the current one included. */
revokeAllDevices(): Promise<void>;
}
/** Project and agent permission management (LP1). */ /** Project and agent permission management (LP1). */
export interface PermissionGateway { export interface PermissionGateway {
/** Reads the full project permission document. */ /** Reads the full project permission document. */
@ -1130,6 +1157,7 @@ export interface Gateways {
skill: SkillGateway; skill: SkillGateway;
memory: MemoryGateway; memory: MemoryGateway;
embedder: EmbedderGateway; embedder: EmbedderGateway;
device: DeviceGateway;
permission: PermissionGateway; permission: PermissionGateway;
workState: WorkStateGateway; workState: WorkStateGateway;
conversation: ConversationGateway; conversation: ConversationGateway;