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:
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