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:
@ -5,7 +5,7 @@
|
||||
* These pin the UX invariants the screen exists for, not its styling: the mode
|
||||
* is a radio choice with consequences, the authorized-proxy field is explicitly
|
||||
* *not* a listen address, addresses come from the backend, a refusal is
|
||||
* actionable, and the pairing code is runtime-only.
|
||||
* actionable, and no pairing code is ever shown here (#77).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
@ -142,30 +142,31 @@ describe("DeploymentSettings", () => {
|
||||
expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the pairing code runtime-only: absent until running, gone after stop", async () => {
|
||||
it("never shows a pairing code, running or not (#77)", async () => {
|
||||
// A code is no longer a property of a running server: it exists only when
|
||||
// asked for. Starting the server must not make one appear here — this is
|
||||
// what the old "runtime-only code" test asserted, and it can no longer be
|
||||
// true of any backend response.
|
||||
renderView();
|
||||
await settle();
|
||||
|
||||
expect(
|
||||
screen.getByText("Start the server to generate a pairing code."),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start" }));
|
||||
await waitFor(() => expect(screen.getByText("Running")).toBeTruthy());
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "copy pairing code" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Temporary code. It disappears when the server stops/),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
|
||||
expect(screen.queryByText(/generate a pairing code/i)).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("Start the server to generate a pairing code."),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
it("points to where pairing now lives instead of dead-ending", async () => {
|
||||
// Someone who just started the server from this screen needs to know where
|
||||
// to go next; silence would be a cul-de-sac.
|
||||
renderView();
|
||||
await settle();
|
||||
|
||||
const pairing = screen.getByText(/Pairing is managed in/);
|
||||
expect(within(pairing).getByText("Settings → Appareils")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts the server and reports the local URL", async () => {
|
||||
|
||||
@ -16,8 +16,11 @@
|
||||
* the whole reason this screen is worded the way it is; the help text under
|
||||
* the field says so explicitly.
|
||||
*
|
||||
* The pairing code is runtime-only: shown while running, never rendered into a
|
||||
* persisted field, never mixed with the upstream value.
|
||||
* This screen no longer shows a pairing code (#77). A code is not a property of
|
||||
* a running server — it exists only when someone asks for one — so it lives in
|
||||
* the Appareils surface next to the devices it authorises. What is left here is
|
||||
* a signpost: starting the server from this screen and finding no way to pair is
|
||||
* a dead end.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
@ -334,21 +337,12 @@ export function DeploymentSettings() {
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */}
|
||||
{/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
|
||||
<Panel title="Pairing">
|
||||
{running && status.pairingCode ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ReadOnlyValue value={status.pairingCode} copyLabel="copy pairing code" />
|
||||
<p className="text-xs text-muted">
|
||||
Temporary code. It disappears when the server stops. Do not save it
|
||||
in configuration files.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">
|
||||
Start the server to generate a pairing code.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Pairing is managed in <span className="text-content">Settings → Appareils</span>,
|
||||
where you can generate a code and revoke devices.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -17,19 +17,32 @@
|
||||
|
||||
import { Button, cn } from "@/shared";
|
||||
import { ProfilesSettings } from "@/features/first-run";
|
||||
import { DevicesScreen } from "@/features/devices";
|
||||
import { DeploymentSettings } from "./DeploymentSettings";
|
||||
|
||||
/** The Settings sections, in menu/nav order. */
|
||||
export type SettingsSection = "aiProfiles" | "deployment";
|
||||
export type SettingsSection = "aiProfiles" | "deployment" | "devices";
|
||||
|
||||
/** Human labels, shared by the nav column and the `Settings` menu. */
|
||||
/**
|
||||
* Human labels, shared by the nav column and the `Settings` menu.
|
||||
*
|
||||
* `Appareils` is French where its neighbours are English: the device vocabulary
|
||||
* is frozen by UX across web and desktop (carnet #77), and the web surface it
|
||||
* mirrors is French throughout. Aligning the whole Settings surface on one
|
||||
* language is a UX call beyond this ticket.
|
||||
*/
|
||||
export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
|
||||
aiProfiles: "AI Profiles",
|
||||
deployment: "Deployment",
|
||||
devices: "Appareils",
|
||||
};
|
||||
|
||||
/** Section order — the single source of truth for both nav and menu. */
|
||||
export const SETTINGS_SECTIONS: SettingsSection[] = ["aiProfiles", "deployment"];
|
||||
export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
"aiProfiles",
|
||||
"deployment",
|
||||
"devices",
|
||||
];
|
||||
|
||||
interface SettingsViewProps {
|
||||
section: SettingsSection;
|
||||
@ -77,7 +90,15 @@ export function SettingsView({
|
||||
|
||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||||
<div className="w-full max-w-2xl">
|
||||
{section === "aiProfiles" ? <ProfilesSettings /> : <DeploymentSettings />}
|
||||
{section === "aiProfiles" ? (
|
||||
<ProfilesSettings />
|
||||
) : section === "deployment" ? (
|
||||
<DeploymentSettings />
|
||||
) : (
|
||||
// No `onSessionEnded`: the desktop app hosts the server and is never
|
||||
// itself a paired device, so it cannot revoke its own session.
|
||||
<DevicesScreen />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user