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

@ -32,6 +32,8 @@ import type {
MemoryLink,
MemoryType,
EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet,
Project,
ProjectPermissions,
@ -68,6 +70,7 @@ import type {
CreateMemoryInput,
CreateSkillInput,
DesktopServerGateway,
DeviceGateway,
EmbedderGateway,
CreateTemplateInput,
Gateways,
@ -1471,8 +1474,6 @@ export class MockDesktopServerGateway implements DesktopServerGateway {
? undefined
: this.settings.publicOrigin,
upstreamUrl: preview.upstreamUrl,
// Runtime-only, regenerated per start — never persisted.
pairingCode: "MOCK-PAIR-4242",
});
return structuredClone(this.current);
}
@ -2040,6 +2041,66 @@ export class MockInputGateway implements InputGateway {
}
}
/**
* In-memory paired-device gateway (ticket #77) — stands in for B1/B2/B3 while
* the backend lands, and drives the `VITE_USE_MOCK` dev surface.
*
* Models the parts of the frozen contract the UI can actually observe: a device
* list with exactly one `isCurrentDevice`, single-use codes with a 600 s TTL
* where generating invalidates the previous one, and revocation. It does **not**
* model the wire (no HTTP, no cookie): that is the HTTP gateway's job.
*/
export class MockDeviceGateway implements DeviceGateway {
private devices: PairedDevice[] = [];
private issued = 0;
/** Seeds the device list (tests/dev). */
_setDevices(devices: PairedDevice[]): void {
this.devices = structuredClone(devices);
}
/** Every code handed out so far, newest last — the previous ones are dead. */
_issuedCodes: string[] = [];
async listDevices(): Promise<PairedDevice[]> {
return structuredClone(this.devices);
}
async createPairingCode(): Promise<PairingCode> {
// Deterministic 8-char uppercase hex, same shape as the server's.
const code = (0x10000000 + this.issued++)
.toString(16)
.toUpperCase()
.slice(0, 8);
this._issuedCodes.push(code);
const ttlSeconds = 600;
return {
code,
// Epoch ms, exactly as the server sends it — a mock that invents a
// friendlier encoding is how the ISO/epoch mismatch stayed green (#77).
expiresAtMs: Date.now() + ttlSeconds * 1000,
ttlSeconds,
};
}
async renameDevice(deviceId: string, name: string): Promise<void> {
const device = this.devices.find((d) => d.deviceId === deviceId);
if (!device) {
const err: GatewayError = { code: "NOT_FOUND", message: `unknown device ${deviceId}` };
throw err;
}
device.name = name;
}
async revokeDevice(deviceId: string): Promise<void> {
this.devices = this.devices.filter((d) => d.deviceId !== deviceId);
}
async revokeAllDevices(): Promise<void> {
this.devices = [];
}
}
/** In-memory permissions gateway. */
export class MockPermissionGateway implements PermissionGateway {
private docs = new Map<string, ProjectPermissions>();
@ -2782,6 +2843,7 @@ export function createMockGateways(): Gateways {
skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(),
embedder: new MockEmbedderGateway(),
device: new MockDeviceGateway(),
permission: new MockPermissionGateway(),
workState: new MockWorkStateGateway(),
conversation: new MockConversationGateway(),