/** * `DevicesScreen` — the paired-device surface (ticket #77, lot F2). * * Mounted **identically** in the web UI and in the desktop app * (`Paramètres → Appareils`): same component, same vocabulary, same actions. It * is mobile-first by necessity, not by taste — a headless install has only the * web UI to generate a code from, and that is often reached from a phone. * * It shows a name, a badge, an activity phrase and a pairing date. It never * shows an IP or a User-Agent: this is an access-management surface for a * single-user instance, not an audit log. * * Transport-neutral (gateways via DI). The session consequence of revoking the * current device is *not* decided here: {@link onSessionEnded} hands it to the * mounting surface, which is the only layer that knows what "logged out" means. */ import { useEffect, useState } from "react"; import type { PairedDevice } from "@/domain"; import { Button, Field, Input, Panel, Spinner, cn } from "@/shared"; import { useDevices } from "./useDevices"; import { formatLastSeen, formatPairedAt } from "./formatActivity"; import { PairingCodePanel } from "./PairingCodePanel"; import { ConfirmDialog } from "./ConfirmDialog"; import { DEVICE_NAME_MAX } from "@/features/web/deviceName"; interface DevicesScreenProps { /** * Called when this device's own session has just been revoked (directly or by * "revoke all"). Web routes back to pairing; desktop leaves it unset — the * desktop app hosts the server and is never itself a paired device. */ onSessionEnded?: () => void; } export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) { const vm = useDevices(); const [confirmRevoke, setConfirmRevoke] = useState(null); const [confirmRevokeAll, setConfirmRevokeAll] = useState(false); const [renaming, setRenaming] = useState(null); useEffect(() => { if (vm.sessionEnded) onSessionEnded?.(); }, [vm.sessionEnded, onSessionEnded]); const devices = vm.devices; return (

Appareils

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

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

{vm.error}

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

Aucun appareil appairé.

) : (
    {devices.map((device) => ( setRenaming(device.deviceId)} onCancelRename={() => setRenaming(null)} onRename={async (name) => { await vm.rename(device.deviceId, name); setRenaming(null); }} onRevoke={() => setConfirmRevoke(device)} /> ))}
)} {devices !== null && devices.length > 0 && (
)} {confirmRevoke && ( { const target = confirmRevoke; setConfirmRevoke(null); await vm.revoke(target); }} onCancel={() => setConfirmRevoke(null)} /> )} {confirmRevokeAll && ( { setConfirmRevokeAll(false); await vm.revokeAll(); }} onCancel={() => setConfirmRevokeAll(false)} /> )}
); } /** One device: identity, activity, and the `⋯` actions. */ function DeviceRow({ device, fresh, renaming, onStartRename, onCancelRename, onRename, onRevoke, }: { device: PairedDevice; fresh: boolean; renaming: boolean; onStartRename: () => void; onCancelRename: () => void; onRename: (name: string) => Promise; onRevoke: () => void; }) { return (
  • {renaming ? ( ) : (
    {device.name} {device.isCurrentDevice && ( Cet appareil )} {formatLastSeen(device.lastSeenAtMs)} {formatPairedAt(device.pairedAtMs)}
    )}
  • ); } /** Inline rename (1-40 characters), submitted with Enter or the button. */ function RenameForm({ device, onSubmit, onCancel, }: { device: PairedDevice; onSubmit: (name: string) => Promise; onCancel: () => void; }) { const [name, setName] = useState(device.name); const trimmed = name.trim(); return (
    { e.preventDefault(); if (trimmed.length > 0) void onSubmit(trimmed); }} > {({ id }) => ( setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") onCancel(); }} /> )}
    ); } /** * The per-row `⋯` menu. Local to this feature rather than a shared primitive: * the kit has no dropdown yet, and inventing one is a design-system decision * (UX/Architect), not a side effect of this ticket. */ function RowMenu({ deviceName, onRename, onRevoke, }: { deviceName: string; onRename: () => void; onRevoke: () => void; }) { const [open, setOpen] = useState(false); useEffect(() => { if (!open) return; const close = () => setOpen(false); // Any click outside dismisses; capture so a click on another row's trigger // still opens that one (its handler runs after this closes us). window.addEventListener("click", close); return () => window.removeEventListener("click", close); }, [open]); return (
    e.stopPropagation()}> {open && (
    )}
    ); }