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

@ -117,7 +117,25 @@ export class HttpInvoker {
* Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the
* parsed result, or rejects with a {@link GatewayError}.
*/
async invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
return this.request<T>("POST", "/api/invoke", { command, args }, `command '${command}'`);
}
/**
* Sends one authenticated REST request and maps the response exactly like
* {@link invoke} (401 routing + `ErrorDto` preservation).
*
* The device/auth surface of ticket #77 lives on dedicated routes rather than
* the generic RPC, alongside the pre-existing `/api/pair` and `/api/logout` —
* the RPC endpoint is itself auth-gated, so the surface that *manages* auth
* cannot ride on it. `what` names the call in fallback error messages.
*/
async request<T>(
method: string,
path: string,
body?: unknown,
what = `${method} ${path}`,
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
@ -125,10 +143,10 @@ export class HttpInvoker {
let res: Awaited<ReturnType<FetchLike>>;
try {
res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, {
method: "POST",
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
method,
headers,
body: JSON.stringify({ command, args }),
body: body === undefined ? undefined : JSON.stringify(body),
// Same-origin so the HttpOnly session cookie is sent automatically
// (ticket #13: no secret in the URL/headers from JS).
credentials: "same-origin",
@ -136,7 +154,7 @@ export class HttpInvoker {
} catch (networkError) {
const err: GatewayError = {
code: "TRANSPORT_ERROR",
message: `HTTP request for '${command}' failed: ${String(networkError)}`,
message: `HTTP request for ${what} failed: ${String(networkError)}`,
};
throw err;
}
@ -152,16 +170,16 @@ export class HttpInvoker {
} catch {
parsed = undefined;
}
throw toGatewayError(parsed, `command '${command}' failed (HTTP ${res.status})`);
throw toGatewayError(parsed, `${what} failed (HTTP ${res.status})`);
}
// A 204 / empty body maps to `undefined` (the void commands).
let body: unknown;
let parsed: unknown;
try {
body = await res.json();
parsed = await res.json();
} catch {
body = undefined;
parsed = undefined;
}
return body as T;
return parsed as T;
}
}