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:
80
frontend/src/features/devices/ConfirmDialog.tsx
Normal file
80
frontend/src/features/devices/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
frontend/src/features/devices/DevicesScreen.test.tsx
Normal file
291
frontend/src/features/devices/DevicesScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
317
frontend/src/features/devices/DevicesScreen.tsx
Normal file
317
frontend/src/features/devices/DevicesScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/features/devices/PairingCodePanel.tsx
Normal file
118
frontend/src/features/devices/PairingCodePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/src/features/devices/formatActivity.test.ts
Normal file
77
frontend/src/features/devices/formatActivity.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
70
frontend/src/features/devices/formatActivity.ts
Normal file
70
frontend/src/features/devices/formatActivity.ts
Normal 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`;
|
||||
}
|
||||
14
frontend/src/features/devices/index.ts
Normal file
14
frontend/src/features/devices/index.ts
Normal 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";
|
||||
186
frontend/src/features/devices/useDevices.ts
Normal file
186
frontend/src/features/devices/useDevices.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user