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

@ -3,8 +3,13 @@
* - the field must summon the text keyboard, not the numeric keypad;
* - a lowercase entry must reach `session.pair()` uppercased and space-free,
* because the server compares the code strictly.
*
* #77 lot F1 — the handshake becomes `{code, name}`:
* - dashes normalise away too, so a code retyped as `AB12-CD34` still pairs;
* - the device name is prefilled from a readable derivation, editable, and
* required; it is never a raw User-Agent.
*/
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { WebSession } from "@/adapters/http";
@ -28,50 +33,156 @@ const okFetch: FetchLike = async () => ({
text: async () => "{}",
});
function setup() {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
/** A `/api/pair` that always fails with the given wire code. */
function failingFetch(status: number, code: string): FetchLike {
return async () => ({
ok: false,
status,
json: async () => ({ code, message: "server wording" }),
text: async () => "{}",
});
}
/** Pins the UA so the name prefill is deterministic. */
function stubUserAgent(ua: string): void {
Object.defineProperty(window.navigator, "userAgent", {
value: ua,
configurable: true,
});
}
const ORIGINAL_UA = window.navigator.userAgent;
function setup(fetchImpl: FetchLike = okFetch) {
const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() });
const pair = vi.spyOn(session, "pair");
const onPaired = vi.fn();
render(<PairingScreen session={session} onPaired={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", () => {
it("asks for the text keyboard, not the numeric keypad", () => {
setup();
const field = screen.getByLabelText("Code d'appairage");
expect(field.getAttribute("inputmode")).toBe("text");
expect(field.getAttribute("autocapitalize")).toBe("characters");
expect(field.getAttribute("autocomplete")).toBe("one-time-code");
expect(codeField().getAttribute("inputmode")).toBe("text");
expect(codeField().getAttribute("autocapitalize")).toBe("characters");
expect(codeField().getAttribute("autocomplete")).toBe("one-time-code");
});
it("describes the code without lying about its shape", () => {
setup();
const field = screen.getByLabelText("Code d'appairage");
expect(field.getAttribute("placeholder")).toBe("p. ex. AB12CD34");
expect(codeField().getAttribute("placeholder")).toBe("p. ex. AB12CD34");
expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy();
});
it("uppercases and strips spaces before pairing", async () => {
const { pair, onPaired } = setup();
fireEvent.change(screen.getByLabelText("Code d'appairage"), {
target: { value: " ab12cd34 " },
});
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
fireEvent.change(codeField(), { target: { value: " ab12cd34 " } });
fireEvent.click(submit());
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34");
expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone");
});
it("keeps submit disabled for a blank code", () => {
setup();
const submit = screen.getByRole("button", { name: "Appairer" });
expect((submit as HTMLButtonElement).disabled).toBe(true);
it("strips a dash the user retyped from the grouped display (#77)", async () => {
const { pair, onPaired } = setup();
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: " " } });
expect((submit as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(codeField(), { target: { value: "AB12-CD34" } });
fireEvent.click(submit());
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone");
});
});
describe("PairingScreen device name", () => {
it("prefills a readable name, never the raw User-Agent", () => {
setup();
expect(nameField().value).toBe("iPhone");
expect(document.body.textContent).not.toContain("Mozilla/5.0");
});
it("sends the edited name with the code", async () => {
const { pair, onPaired } = setup();
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
fireEvent.change(nameField(), { target: { value: " Téléphone de Marie " } });
fireEvent.click(submit());
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34", "Téléphone de Marie");
});
it("caps the name at 40 characters", () => {
setup();
expect(nameField().maxLength).toBe(40);
});
it("requires both a code and a name", () => {
setup();
// A name alone (prefilled) is not enough.
expect(submit().disabled).toBe(true);
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
expect(submit().disabled).toBe(false);
// Clearing the name blocks it again: the list must never show a blank row.
fireEvent.change(nameField(), { target: { value: " " } });
expect(submit().disabled).toBe(true);
});
it("keeps submit disabled for a blank or separator-only code", () => {
setup();
fireEvent.change(codeField(), { target: { value: " " } });
expect(submit().disabled).toBe(true);
fireEvent.change(codeField(), { target: { value: " - " } });
expect(submit().disabled).toBe(true);
});
});
describe("PairingScreen error placement", () => {
async function submitAndFail(status: number, code: string) {
setup(failingFetch(status, code));
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
fireEvent.click(submit());
await screen.findByRole("alert");
}
it("shows a rejected name on the name field, not as a form error", async () => {
await submitAndFail(400, "invalid_name");
// Attached to the field: the fix is one edit away, and the code is still
// valid — a form-level error would read as "start over".
const message = screen.getByRole("alert");
expect(message.textContent).toBe(
"Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
);
expect(nameField().getAttribute("aria-describedby")).toBe(message.id);
expect(nameField().getAttribute("aria-invalid")).toBe("true");
expect(screen.queryByTestId("pairing-error")).toBeNull();
// The code was never consumed, so it must not be painted as the culprit.
expect(codeField().getAttribute("aria-invalid")).not.toBe("true");
});
it("keeps a rejected code as a form error", async () => {
await submitAndFail(401, "invalid_or_expired");
expect(screen.getByTestId("pairing-error").textContent).toBe("Code invalide ou expiré.");
// The name is not at fault, so it must not be marked as such.
expect(nameField().getAttribute("aria-invalid")).not.toBe("true");
});
});

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
* the server printed at first launch; on submit we `POST /api/pair {code}` via
* the {@link WebSession}. On success the server sets the HttpOnly session cookie
* and we advance to the workspace; a wrong code shows a clear message.
* generated from an already-paired device (or printed by `--new-code`) plus a
* name for *this* device; on submit we `POST /api/pair {code, name}` via the
* {@link WebSession}. On success the server sets the HttpOnly session cookie and
* we advance to the workspace.
*
* The device name is typed here, on the new device, because this is the only
* moment the person is holding it — the generating device cannot know what to
* call it. It is prefilled from a readable derivation (never a raw User-Agent).
*
* Transport-neutral at the component seam: it talks to the injected
* {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke`
@ -13,9 +18,10 @@
import { useState, type FormEvent } from "react";
import type { GatewayError } from "@/domain";
import { normalizePairingCode, type GatewayError } from "@/domain";
import { Button, Field, Input, Panel } from "@/shared";
import type { WebSession } from "@/adapters/http";
import { clampDeviceName, currentDeviceName, DEVICE_NAME_MAX } from "./deviceName";
interface PairingScreenProps {
/** The shared web session performing the `POST /api/pair` handshake. */
@ -24,34 +30,43 @@ interface PairingScreenProps {
onPaired: () => void;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
/** The failure as the form needs it: a message plus which field to blame. */
interface PairingFailure {
code: string;
message: string;
}
/**
* The server compares the code strictly against the uppercase hex it generated,
* so the UI uppercases (and drops stray spaces) before sending.
*/
function normalize(raw: string): string {
return raw.replace(/\s+/g, "").toUpperCase();
function describe(e: unknown): PairingFailure {
if (e && typeof e === "object" && "message" in e) {
const err = e as GatewayError;
return { code: String(err.code ?? "ERROR"), message: String(err.message) };
}
return { code: "ERROR", message: String(e) };
}
export function PairingScreen({ session, onPaired }: PairingScreenProps) {
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState(() => clampDeviceName(currentDeviceName()));
const [error, setError] = useState<PairingFailure | null>(null);
const [busy, setBusy] = useState(false);
const normalizedCode = normalizePairingCode(code);
const trimmedName = name.trim();
const canSubmit = normalizedCode.length > 0 && trimmedName.length > 0;
// A rejected name is the one failure the user fixes in the form rather than by
// fetching a new code (the server validates it before consuming the code, so
// the code is still live). Point at the field instead of the whole form.
const nameError = error?.code === "invalidName" ? error.message : null;
const formError = error && !nameError ? error.message : null;
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
const normalized = normalize(code);
if (!normalized || busy) return;
if (!canSubmit || busy) return;
setBusy(true);
setError(null);
try {
await session.pair(normalized);
await session.pair(normalizedCode, clampDeviceName(trimmedName));
onPaired();
} catch (err) {
setError(describe(err));
@ -89,18 +104,39 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
autoCorrect="off"
spellCheck={false}
disabled={busy}
invalid={!!error}
invalid={!!formError}
/>
)}
</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">
{error}
{formError}
</p>
)}
<Button type="submit" disabled={busy || normalize(code).length === 0}>
<Button type="submit" disabled={busy || !canSubmit}>
{busy ? "Appairage…" : "Appairer"}
</Button>
</form>

View File

@ -8,12 +8,18 @@
* cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the
* server session (`POST /api/logout`), tears down the live WS singleton, and
* returns to pairing.
*
* "Appareils" (#77) mounts the shared {@link DevicesScreen} — the same component
* the desktop shows under `Paramètres → Appareils`. It is what makes a headless
* install usable: generating a pairing code from an already-paired phone instead
* of restarting the server with `--new-code`.
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/shared";
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
import { DevicesScreen } from "@/features/devices";
import { PairingScreen } from "./PairingScreen";
import { WebWorkspace } from "./WebWorkspace";
@ -26,11 +32,17 @@ export function WebApp({ session }: WebAppProps = {}) {
const webSession = session ?? getWebSession();
const [paired, setPaired] = useState(() => webSession.isPaired());
const [signingOut, setSigningOut] = useState(false);
const [showDevices, setShowDevices] = useState(false);
useEffect(() => {
// A 401 anywhere clears the flag and fires this: return to pairing. The live
// WS is torn down by the composition-root 401 handler (F6).
return webSession.onUnauthorized(() => setPaired(false));
// WS is torn down by the composition-root 401 handler (F6). This is also the
// path for a *suffered* revocation (#77): another device revoked us, the
// server rejects the next call, and we land back on pairing.
return webSession.onUnauthorized(() => {
setShowDevices(false);
setPaired(false);
});
}, [webSession]);
async function signOut(): Promise<void> {
@ -43,10 +55,24 @@ export function WebApp({ session }: WebAppProps = {}) {
} finally {
disconnectWebLive();
setSigningOut(false);
setShowDevices(false);
setPaired(false);
}
}
/**
* This device just revoked itself (#77) — a nominal action, not an edge case.
* The cookie is already dead server-side; drop the local flag and the live
* socket so nothing keeps retrying, and land on pairing immediately rather
* than waiting for the next request to 401.
*/
const onSessionEnded = useCallback(() => {
webSession.forget();
disconnectWebLive();
setShowDevices(false);
setPaired(false);
}, [webSession]);
return (
<div className="flex h-full flex-col bg-canvas text-content">
<header
@ -60,17 +86,31 @@ export function WebApp({ session }: WebAppProps = {}) {
</span>
</div>
{paired && (
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}>
Se déconnecter
</Button>
<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()}>
Se déconnecter
</Button>
</div>
)}
</header>
<div className="flex flex-1 flex-col overflow-hidden">
{paired ? (
<WebWorkspace />
) : (
{!paired ? (
<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>

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