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,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
* is a radio choice with consequences, the authorized-proxy field is explicitly
* *not* a listen address, addresses come from the backend, a refusal is
* actionable, and the pairing code is runtime-only.
* actionable, and no pairing code is ever shown here (#77).
*/
import { describe, it, expect, vi } from "vitest";
@ -142,30 +142,31 @@ describe("DeploymentSettings", () => {
expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull();
});
it("keeps the pairing code runtime-only: absent until running, gone after stop", async () => {
it("never shows a pairing code, running or not (#77)", async () => {
// A code is no longer a property of a running server: it exists only when
// asked for. Starting the server must not make one appear here — this is
// what the old "runtime-only code" test asserted, and it can no longer be
// true of any backend response.
renderView();
await settle();
expect(
screen.getByText("Start the server to generate a pairing code."),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(screen.getByText("Running")).toBeTruthy());
expect(
await screen.findByRole("button", { name: "copy pairing code" }),
).toBeTruthy();
expect(
screen.getByText(/Temporary code. It disappears when the server stops/),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
expect(screen.queryByText(/generate a pairing code/i)).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
await waitFor(() =>
expect(
screen.getByText("Start the server to generate a pairing code."),
).toBeTruthy(),
);
it("points to where pairing now lives instead of dead-ending", async () => {
// Someone who just started the server from this screen needs to know where
// to go next; silence would be a cul-de-sac.
renderView();
await settle();
const pairing = screen.getByText(/Pairing is managed in/);
expect(within(pairing).getByText("Settings → Appareils")).toBeTruthy();
});
it("starts the server and reports the local URL", async () => {

View File

@ -16,8 +16,11 @@
* the whole reason this screen is worded the way it is; the help text under
* the field says so explicitly.
*
* The pairing code is runtime-only: shown while running, never rendered into a
* persisted field, never mixed with the upstream value.
* This screen no longer shows a pairing code (#77). A code is not a property of
* a running server — it exists only when someone asks for one — so it lives in
* the Appareils surface next to the devices it authorises. What is left here is
* a signpost: starting the server from this screen and finding no way to pair is
* a dead end.
*/
import { useState } from "react";
@ -334,21 +337,12 @@ export function DeploymentSettings() {
</Panel>
)}
{/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */}
{/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
<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">
Start the server to generate a pairing code.
</p>
)}
<p className="text-sm text-muted">
Pairing is managed in <span className="text-content">Settings Appareils</span>,
where you can generate a code and revoke devices.
</p>
</Panel>
</div>
);

View File

@ -17,19 +17,32 @@
import { Button, cn } from "@/shared";
import { ProfilesSettings } from "@/features/first-run";
import { DevicesScreen } from "@/features/devices";
import { DeploymentSettings } from "./DeploymentSettings";
/** The Settings sections, in menu/nav order. */
export type SettingsSection = "aiProfiles" | "deployment";
export type SettingsSection = "aiProfiles" | "deployment" | "devices";
/** Human labels, shared by the nav column and the `Settings` menu. */
/**
* Human labels, shared by the nav column and the `Settings` menu.
*
* `Appareils` is French where its neighbours are English: the device vocabulary
* is frozen by UX across web and desktop (carnet #77), and the web surface it
* mirrors is French throughout. Aligning the whole Settings surface on one
* language is a UX call beyond this ticket.
*/
export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
aiProfiles: "AI Profiles",
deployment: "Deployment",
devices: "Appareils",
};
/** Section order — the single source of truth for both nav and menu. */
export const SETTINGS_SECTIONS: SettingsSection[] = ["aiProfiles", "deployment"];
export const SETTINGS_SECTIONS: SettingsSection[] = [
"aiProfiles",
"deployment",
"devices",
];
interface SettingsViewProps {
section: SettingsSection;
@ -77,7 +90,15 @@ export function SettingsView({
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<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>

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