Files
IdeA/frontend/src/features/devices/DevicesScreen.tsx
Blomios 3f1e132e88 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>
2026-07-17 13:27:06 +02:00

318 lines
10 KiB
TypeScript

/**
* `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>
);
}