feat(frontend): client web read-only pairing + snapshot état (#13)
Lot F2 du chantier server/client mode : client web read-only complétant le premier incrément livrable — pairing, liste des projets, ouverture et snapshot de l'état, sans PTY. - frontend/src/adapters/http/webSession.ts : session web (pairing/cookie). - frontend/src/features/web : PairingScreen, WebWorkspace, WebApp, index. - Câblage main.tsx et adaptations httpInvoker.ts / index.ts (cas 401). - Tests : webSession.test.ts, WebApp.test.tsx, cas 401 dans httpInvoker.test.ts. Validé : frontend 736 tests verts, build vert, garde no-direct-invoke verte, contrat B4↔F2 aligné, desktop non régressé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -77,6 +77,29 @@ describe("HttpInvoker", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fires onUnauthorized on a 401 (and still throws)", async () => {
|
||||||
|
const { fetchImpl } = fakeFetch(
|
||||||
|
{ code: "UNAUTHENTICATED", message: "no session" },
|
||||||
|
{ ok: false, status: 401 },
|
||||||
|
);
|
||||||
|
const onUnauthorized = vi.fn();
|
||||||
|
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized });
|
||||||
|
|
||||||
|
await expect(http.invoke("list_projects")).rejects.toMatchObject({
|
||||||
|
code: "UNAUTHENTICATED",
|
||||||
|
});
|
||||||
|
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire onUnauthorized on a non-401 error", async () => {
|
||||||
|
const { fetchImpl } = fakeFetch({ code: "NOT_FOUND", message: "x" }, { ok: false, status: 404 });
|
||||||
|
const onUnauthorized = vi.fn();
|
||||||
|
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized });
|
||||||
|
|
||||||
|
await expect(http.invoke("open_project", { projectId: "x" })).rejects.toBeTruthy();
|
||||||
|
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("wraps a network failure in a TRANSPORT_ERROR GatewayError", async () => {
|
it("wraps a network failure in a TRANSPORT_ERROR GatewayError", async () => {
|
||||||
const fetchImpl: FetchLike = async () => {
|
const fetchImpl: FetchLike = async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export type FetchLike = (
|
|||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/** Cookie policy — F2 uses `same-origin` so the session cookie rides along. */
|
||||||
|
credentials?: "same-origin" | "include" | "omit";
|
||||||
},
|
},
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@ -47,6 +49,12 @@ export interface HttpInvokerConfig {
|
|||||||
token?: string;
|
token?: string;
|
||||||
/** Injected fetch (defaults to global `fetch`). */
|
/** Injected fetch (defaults to global `fetch`). */
|
||||||
fetchImpl?: FetchLike;
|
fetchImpl?: FetchLike;
|
||||||
|
/**
|
||||||
|
* Called when the backend answers `401` (the session cookie is missing or
|
||||||
|
* expired). F2 wires this to the web session so the app routes back to the
|
||||||
|
* pairing screen. The `GatewayError` is still thrown to the caller.
|
||||||
|
*/
|
||||||
|
onUnauthorized?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds a {@link GatewayError} from an arbitrary thrown/parsed value. */
|
/** Builds a {@link GatewayError} from an arbitrary thrown/parsed value. */
|
||||||
@ -71,10 +79,12 @@ export class HttpInvoker {
|
|||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly token?: string;
|
private readonly token?: string;
|
||||||
private readonly fetchImpl: FetchLike;
|
private readonly fetchImpl: FetchLike;
|
||||||
|
private readonly onUnauthorized?: () => void;
|
||||||
|
|
||||||
constructor(config: HttpInvokerConfig) {
|
constructor(config: HttpInvokerConfig) {
|
||||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
this.token = config.token;
|
this.token = config.token;
|
||||||
|
this.onUnauthorized = config.onUnauthorized;
|
||||||
// `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it
|
// `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it
|
||||||
// to the minimal shape used here.
|
// to the minimal shape used here.
|
||||||
this.fetchImpl =
|
this.fetchImpl =
|
||||||
@ -98,6 +108,9 @@ export class HttpInvoker {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ command, args }),
|
body: JSON.stringify({ command, args }),
|
||||||
|
// Same-origin so the HttpOnly session cookie is sent automatically
|
||||||
|
// (ticket #13: no secret in the URL/headers from JS).
|
||||||
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
} catch (networkError) {
|
} catch (networkError) {
|
||||||
const err: GatewayError = {
|
const err: GatewayError = {
|
||||||
@ -108,6 +121,9 @@ export class HttpInvoker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
// A 401 means the session cookie is missing/expired: signal the app to
|
||||||
|
// route back to pairing (the error is still thrown to the caller).
|
||||||
|
if (res.status === 401) this.onUnauthorized?.();
|
||||||
// Preserve the backend `ErrorDto` when present; fall back to the status.
|
// Preserve the backend `ErrorDto` when present; fall back to the status.
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import type { Gateways } from "@/ports";
|
|||||||
import { LocalStorageUiPreferencesGateway } from "../uiPreferences";
|
import { LocalStorageUiPreferencesGateway } from "../uiPreferences";
|
||||||
import { HttpInvoker } from "./httpInvoker";
|
import { HttpInvoker } from "./httpInvoker";
|
||||||
import { WsLiveClient } from "./wsLiveClient";
|
import { WsLiveClient } from "./wsLiveClient";
|
||||||
|
import { getWebSession } from "./webSession";
|
||||||
import {
|
import {
|
||||||
HttpConversationGateway,
|
HttpConversationGateway,
|
||||||
HttpEmbedderGateway,
|
HttpEmbedderGateway,
|
||||||
@ -77,7 +78,13 @@ function resolveEndpoints(config: HttpWsGatewaysConfig): {
|
|||||||
*/
|
*/
|
||||||
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
|
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
|
||||||
const { baseUrl, wsUrl } = resolveEndpoints(config);
|
const { baseUrl, wsUrl } = resolveEndpoints(config);
|
||||||
const http = new HttpInvoker({ baseUrl, token: config.token });
|
// Route 401s back to the pairing screen via the shared web session (F2).
|
||||||
|
const session = getWebSession();
|
||||||
|
const http = new HttpInvoker({
|
||||||
|
baseUrl,
|
||||||
|
token: config.token,
|
||||||
|
onUnauthorized: () => session.notifyUnauthorized(),
|
||||||
|
});
|
||||||
const ws = new WsLiveClient({ wsUrl, token: config.token });
|
const ws = new WsLiveClient({ wsUrl, token: config.token });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -108,3 +115,4 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
|||||||
|
|
||||||
export { HttpInvoker } from "./httpInvoker";
|
export { HttpInvoker } from "./httpInvoker";
|
||||||
export { WsLiveClient } from "./wsLiveClient";
|
export { WsLiveClient } from "./wsLiveClient";
|
||||||
|
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";
|
||||||
|
|||||||
92
frontend/src/adapters/http/webSession.test.ts
Normal file
92
frontend/src/adapters/http/webSession.test.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* F2 — the web session: pairing handshake (success marks the flag; wrong code
|
||||||
|
* rejects with a clear message), and the 401 → unauthorized transition that
|
||||||
|
* clears the flag and notifies subscribers.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { FetchLike } from "./httpInvoker";
|
||||||
|
import { WebSession, type FlagStore } from "./webSession";
|
||||||
|
|
||||||
|
function memStore(): FlagStore {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (k) => map.get(k) ?? null,
|
||||||
|
setItem: (k, v) => void map.set(k, v),
|
||||||
|
removeItem: (k) => void map.delete(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchReturning(status: number, body: unknown): {
|
||||||
|
fetchImpl: FetchLike;
|
||||||
|
calls: { url: string; init: unknown }[];
|
||||||
|
} {
|
||||||
|
const calls: { url: string; init: unknown }[] = [];
|
||||||
|
const fetchImpl: FetchLike = async (url, init) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return { fetchImpl, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WebSession pairing", () => {
|
||||||
|
it("POSTs the code to /api/pair and marks the flag on success", async () => {
|
||||||
|
const store = memStore();
|
||||||
|
const { fetchImpl, calls } = fetchReturning(200, { ok: true });
|
||||||
|
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
|
||||||
|
|
||||||
|
expect(session.isPaired()).toBe(false);
|
||||||
|
await session.pair("4821-93");
|
||||||
|
|
||||||
|
expect(session.isPaired()).toBe(true);
|
||||||
|
expect(calls[0].url).toBe("https://host/api/pair");
|
||||||
|
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a wrong code with a clear message and stays unpaired", async () => {
|
||||||
|
const store = memStore();
|
||||||
|
const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" });
|
||||||
|
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
|
||||||
|
|
||||||
|
await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" });
|
||||||
|
expect(session.isPaired()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a default message when the server sends no body", async () => {
|
||||||
|
const { fetchImpl } = fetchReturning(403, undefined);
|
||||||
|
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
|
||||||
|
|
||||||
|
await expect(session.pair("x")).rejects.toMatchObject({
|
||||||
|
code: "INVALID_PAIRING_CODE",
|
||||||
|
message: "Code d'appairage invalide.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WebSession unauthorized handling", () => {
|
||||||
|
it("clears the flag and notifies subscribers on notifyUnauthorized", () => {
|
||||||
|
const store = memStore();
|
||||||
|
const session = new WebSession({ baseUrl: "https://host", store });
|
||||||
|
session.markPaired();
|
||||||
|
const handler = vi.fn();
|
||||||
|
session.onUnauthorized(handler);
|
||||||
|
|
||||||
|
session.notifyUnauthorized();
|
||||||
|
|
||||||
|
expect(session.isPaired()).toBe(false);
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forget() clears the flag (best-effort local sign-out)", () => {
|
||||||
|
const store = memStore();
|
||||||
|
const session = new WebSession({ baseUrl: "https://host", store });
|
||||||
|
session.markPaired();
|
||||||
|
session.forget();
|
||||||
|
expect(session.isPaired()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
175
frontend/src/adapters/http/webSession.ts
Normal file
175
frontend/src/adapters/http/webSession.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Web client session (pairing + auth routing) — ticket #13, lot F2.
|
||||||
|
*
|
||||||
|
* The real authentication material is a session cookie set by the server at
|
||||||
|
* pairing (`POST /api/pair {code}` → `Set-Cookie: … HttpOnly; Secure;
|
||||||
|
* SameSite=Strict`). Because it is **HttpOnly** the browser sends it
|
||||||
|
* automatically on same-origin requests but JS can never read it. So this module
|
||||||
|
* keeps only a lightweight, JS-visible **"paired" flag** (in `localStorage`) to
|
||||||
|
* decide which screen to render — never the secret itself.
|
||||||
|
*
|
||||||
|
* On a `401` from any `/api/invoke`, the {@link HttpInvoker} calls
|
||||||
|
* {@link WebSession.notifyUnauthorized}, which clears the flag and notifies
|
||||||
|
* subscribers so the app routes back to the pairing screen. "Forget" is a
|
||||||
|
* best-effort local clear (server-side cookie revocation is B8).
|
||||||
|
*
|
||||||
|
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GatewayError, Unsubscribe } from "@/domain";
|
||||||
|
import type { FetchLike } from "./httpInvoker";
|
||||||
|
|
||||||
|
const PAIRED_FLAG_KEY = "idea.web.paired";
|
||||||
|
|
||||||
|
/** Minimal `localStorage`-like surface, injectable for tests. */
|
||||||
|
export interface FlagStore {
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configuration for a {@link WebSession}. */
|
||||||
|
export interface WebSessionConfig {
|
||||||
|
/** Backend base URL (no trailing slash). Defaults to the page origin. */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** Injected fetch (defaults to global `fetch`, with same-origin credentials). */
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
/** Injected flag store (defaults to `window.localStorage`, else in-memory). */
|
||||||
|
store?: FlagStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory fallback when `localStorage` is unavailable (SSR/tests). */
|
||||||
|
function memoryStore(): FlagStore {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (k) => map.get(k) ?? null,
|
||||||
|
setItem: (k, v) => void map.set(k, v),
|
||||||
|
removeItem: (k) => void map.delete(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStore(): FlagStore {
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined" && window.localStorage) {
|
||||||
|
return window.localStorage;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* access can throw in a sandboxed iframe; fall through */
|
||||||
|
}
|
||||||
|
return memoryStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBaseUrl(): string {
|
||||||
|
return typeof window !== "undefined" && window.location
|
||||||
|
? window.location.origin
|
||||||
|
: "http://localhost";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The web session state machine. One instance is shared between the pairing UI
|
||||||
|
* and the HTTP invoker (via the module singleton below).
|
||||||
|
*/
|
||||||
|
export class WebSession {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly fetchImpl: FetchLike;
|
||||||
|
private readonly store: FlagStore;
|
||||||
|
private readonly unauthorizedHandlers = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(config: WebSessionConfig = {}) {
|
||||||
|
this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, "");
|
||||||
|
this.fetchImpl = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
||||||
|
this.store = config.store ?? defaultStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the client believes it is paired (a session cookie should exist). */
|
||||||
|
isPaired(): boolean {
|
||||||
|
return this.store.getItem(PAIRED_FLAG_KEY) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records the paired flag (called after a successful pair). */
|
||||||
|
markPaired(): void {
|
||||||
|
this.store.setItem(PAIRED_FLAG_KEY, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort local sign-out: clears the flag (server revocation is B8). */
|
||||||
|
forget(): void {
|
||||||
|
this.store.removeItem(PAIRED_FLAG_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the pairing handshake: `POST /api/pair {code}`. On success the
|
||||||
|
* server sets the session cookie and this records the paired flag. A wrong code
|
||||||
|
* rejects with a {@link GatewayError} carrying a clear message.
|
||||||
|
*/
|
||||||
|
async pair(code: string): Promise<void> {
|
||||||
|
let res: Awaited<ReturnType<FetchLike>>;
|
||||||
|
try {
|
||||||
|
res = await this.fetchImpl(`${this.baseUrl}/api/pair`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
} catch (networkError) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "TRANSPORT_ERROR",
|
||||||
|
message: `Impossible de joindre le serveur : ${String(networkError)}`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = await res.json();
|
||||||
|
} catch {
|
||||||
|
parsed = undefined;
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
parsed && typeof parsed === "object" && "message" in parsed
|
||||||
|
? String((parsed as GatewayError).message)
|
||||||
|
: res.status === 401 || res.status === 403
|
||||||
|
? "Code d'appairage invalide."
|
||||||
|
: `Échec de l'appairage (HTTP ${res.status}).`;
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: res.status === 401 || res.status === 403 ? "INVALID_PAIRING_CODE" : "PAIRING_FAILED",
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie is now set by the server (HttpOnly — not read here); record the flag.
|
||||||
|
this.markPaired();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the HTTP invoker on a `401`: the session cookie is missing/expired.
|
||||||
|
* Clears the flag and notifies subscribers so the app returns to pairing.
|
||||||
|
*/
|
||||||
|
notifyUnauthorized(): void {
|
||||||
|
this.forget();
|
||||||
|
for (const handler of this.unauthorizedHandlers) handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribes to unauthorized transitions (returns an unsubscribe). */
|
||||||
|
onUnauthorized(handler: () => void): Unsubscribe {
|
||||||
|
this.unauthorizedHandlers.add(handler);
|
||||||
|
return () => this.unauthorizedHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module singleton (shared between the invoker and the pairing UI)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let singleton: WebSession | null = null;
|
||||||
|
|
||||||
|
/** Returns the shared web session, creating it on first use. */
|
||||||
|
export function getWebSession(): WebSession {
|
||||||
|
if (!singleton) singleton = new WebSession();
|
||||||
|
return singleton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overrides the singleton (tests). Pass `null` to reset to a fresh default. */
|
||||||
|
export function setWebSessionForTests(session: WebSession | null): void {
|
||||||
|
singleton = session;
|
||||||
|
}
|
||||||
@ -5,7 +5,8 @@ import ReactDOM from "react-dom/client";
|
|||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||||
import { DIProvider } from "./di";
|
import { WebApp } from "@/features/web";
|
||||||
|
import { DIProvider, resolveTransport } from "./di";
|
||||||
import "@/shared/styles/theme.css";
|
import "@/shared/styles/theme.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
@ -18,10 +19,17 @@ if (!root) {
|
|||||||
// follows the main window's focused project; otherwise the full app.
|
// follows the main window's focused project; otherwise the full app.
|
||||||
const viewParams = parseViewWindowParams(window.location.search);
|
const viewParams = parseViewWindowParams(window.location.search);
|
||||||
|
|
||||||
|
// Ticket #13, F2: in web (HTTP) transport mode the browser client renders the
|
||||||
|
// pairing-gated, read-only `WebApp`. Desktop (Tauri) is unchanged — it renders
|
||||||
|
// the full `App` (or a detached view window). Detached windows are desktop-only.
|
||||||
|
const isWeb = resolveTransport() === "http";
|
||||||
|
|
||||||
ReactDOM.createRoot(root).render(
|
ReactDOM.createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<DIProvider>
|
<DIProvider>
|
||||||
{viewParams ? (
|
{isWeb ? (
|
||||||
|
<WebApp />
|
||||||
|
) : viewParams ? (
|
||||||
<ViewWindow panel={viewParams.panel} />
|
<ViewWindow panel={viewParams.panel} />
|
||||||
) : (
|
) : (
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
96
frontend/src/features/web/PairingScreen.tsx
Normal file
96
frontend/src/features/web/PairingScreen.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Web pairing screen — ticket #13, lot F2.
|
||||||
|
*
|
||||||
|
* Shown by {@link WebApp} when the client is not paired. The user types the code
|
||||||
|
* the server printed at first launch; on submit we `POST /api/pair {code}` via
|
||||||
|
* the {@link WebSession}. On success the server sets the HttpOnly session cookie
|
||||||
|
* and we advance to the workspace; a wrong code shows a clear message.
|
||||||
|
*
|
||||||
|
* Transport-neutral at the component seam: it talks to the injected
|
||||||
|
* {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke`
|
||||||
|
* stays green). Pure web-only UI — desktop never mounts it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import type { GatewayError } from "@/domain";
|
||||||
|
import { Button, Field, Input, Panel } from "@/shared";
|
||||||
|
import type { WebSession } from "@/adapters/http";
|
||||||
|
|
||||||
|
interface PairingScreenProps {
|
||||||
|
/** The shared web session performing the `POST /api/pair` handshake. */
|
||||||
|
session: WebSession;
|
||||||
|
/** Called once pairing succeeds (cookie set) so the app can advance. */
|
||||||
|
onPaired: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PairingScreen({ session, onPaired }: PairingScreenProps) {
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: FormEvent): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = code.trim();
|
||||||
|
if (!trimmed || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await session.pair(trimmed);
|
||||||
|
onPaired();
|
||||||
|
} catch (err) {
|
||||||
|
setError(describe(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-canvas p-6">
|
||||||
|
<Panel className="w-full max-w-sm">
|
||||||
|
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">Appairer cet appareil</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Saisissez le code affiché par le serveur IdeA pour connecter ce
|
||||||
|
navigateur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Code d'appairage">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="p. ex. 4821-93"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
disabled={busy}
|
||||||
|
invalid={!!error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" data-testid="pairing-error" className="text-sm text-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={busy || code.trim().length === 0}>
|
||||||
|
{busy ? "Appairage…" : "Appairer"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
frontend/src/features/web/WebApp.test.tsx
Normal file
120
frontend/src/features/web/WebApp.test.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* F2 — the web client routing + pairing flow:
|
||||||
|
* - not paired ⇒ pairing screen; wrong code ⇒ clear error, stays on pairing;
|
||||||
|
* - successful pair ⇒ read-only workspace (project list + open + snapshot);
|
||||||
|
* - a 401 (session.notifyUnauthorized) ⇒ back to pairing.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { createMockGateways, MockWorkStateGateway } from "@/adapters/mock";
|
||||||
|
import { WebSession } from "@/adapters/http";
|
||||||
|
import type { FetchLike } from "@/adapters/http/httpInvoker";
|
||||||
|
import type { FlagStore } from "@/adapters/http/webSession";
|
||||||
|
import { WebApp } from "./WebApp";
|
||||||
|
|
||||||
|
function memStore(): FlagStore {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (k) => map.get(k) ?? null,
|
||||||
|
setItem: (k, v) => void map.set(k, v),
|
||||||
|
removeItem: (k) => void map.delete(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const okFetch: FetchLike = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ ok: true }),
|
||||||
|
text: async () => "{}",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectFetch: FetchLike = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: async () => ({ code: "INVALID", message: "bad" }),
|
||||||
|
text: async () => "{}",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Mock gateways seeded with one project + a work-state snapshot. */
|
||||||
|
async function seededGateways(): Promise<Gateways> {
|
||||||
|
const gateways = createMockGateways();
|
||||||
|
const project = await gateways.project.createProject("Demo", "/srv/demo");
|
||||||
|
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, {
|
||||||
|
agents: [
|
||||||
|
{ agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" }, tickets: [] },
|
||||||
|
],
|
||||||
|
conversations: [],
|
||||||
|
});
|
||||||
|
return gateways;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWebApp(session: WebSession, gateways: Gateways) {
|
||||||
|
return render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<WebApp session={session} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WebApp pairing routing", () => {
|
||||||
|
it("shows the pairing screen when not paired", async () => {
|
||||||
|
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||||
|
renderWebApp(session, await seededGateways());
|
||||||
|
|
||||||
|
expect(screen.getByText("Appairer cet appareil")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Projets")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances to the read-only workspace after a successful pair", async () => {
|
||||||
|
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||||
|
renderWebApp(session, await seededGateways());
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "4821-93" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Projets")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("Demo")).toBeTruthy();
|
||||||
|
expect(session.isPaired()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a clear error on a wrong code and stays on pairing", async () => {
|
||||||
|
const session = new WebSession({ baseUrl: "https://h", fetchImpl: rejectFetch, store: memStore() });
|
||||||
|
renderWebApp(session, await seededGateways());
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "nope" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("pairing-error")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Projets")).toBeNull();
|
||||||
|
expect(session.isPaired()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a project read-only and renders the work-state snapshot", async () => {
|
||||||
|
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||||
|
session.markPaired();
|
||||||
|
renderWebApp(session, await seededGateways());
|
||||||
|
|
||||||
|
// Workspace is shown directly (already paired); open the seeded project.
|
||||||
|
fireEvent.click(await screen.findByText("Demo"));
|
||||||
|
|
||||||
|
const snapshot = await screen.findByTestId("web-workstate");
|
||||||
|
expect(snapshot.textContent).toContain("Archi");
|
||||||
|
expect(snapshot.textContent).toContain("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to pairing when the session reports unauthorized (401)", async () => {
|
||||||
|
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||||
|
session.markPaired();
|
||||||
|
renderWebApp(session, await seededGateways());
|
||||||
|
|
||||||
|
expect(await screen.findByText("Projets")).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => session.notifyUnauthorized());
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy());
|
||||||
|
expect(screen.queryByText("Projets")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/features/web/WebApp.tsx
Normal file
63
frontend/src/features/web/WebApp.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Web client root — ticket #13, lot F2. Mounted (instead of the desktop `App`)
|
||||||
|
* only when the HTTP transport is selected (`VITE_TRANSPORT="http"`).
|
||||||
|
*
|
||||||
|
* Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen};
|
||||||
|
* paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's
|
||||||
|
* `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing
|
||||||
|
* cookie) drops back to pairing automatically. A best-effort "Se déconnecter"
|
||||||
|
* clears the local flag (server-side cookie revocation is B8).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared";
|
||||||
|
import { getWebSession, type WebSession } from "@/adapters/http";
|
||||||
|
import { PairingScreen } from "./PairingScreen";
|
||||||
|
import { WebWorkspace } from "./WebWorkspace";
|
||||||
|
|
||||||
|
interface WebAppProps {
|
||||||
|
/** Injectable session (tests); defaults to the shared singleton. */
|
||||||
|
session?: WebSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebApp({ session }: WebAppProps = {}) {
|
||||||
|
const webSession = session ?? getWebSession();
|
||||||
|
const [paired, setPaired] = useState(() => webSession.isPaired());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// A 401 anywhere clears the flag and fires this: return to pairing.
|
||||||
|
return webSession.onUnauthorized(() => setPaired(false));
|
||||||
|
}, [webSession]);
|
||||||
|
|
||||||
|
function signOut(): void {
|
||||||
|
webSession.forget();
|
||||||
|
setPaired(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-canvas text-content">
|
||||||
|
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">IdeA</h1>
|
||||||
|
<span className="rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
|
||||||
|
web
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{paired && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={signOut}>
|
||||||
|
Se déconnecter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{paired ? (
|
||||||
|
<WebWorkspace />
|
||||||
|
) : (
|
||||||
|
<PairingScreen session={webSession} onPaired={() => setPaired(true)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
frontend/src/features/web/WebWorkspace.tsx
Normal file
156
frontend/src/features/web/WebWorkspace.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Read-only web workspace — ticket #13, lot F2 (first shippable increment).
|
||||||
|
*
|
||||||
|
* The minimal post-pairing surface: list the projects, open one **read-only**,
|
||||||
|
* and show a snapshot of its live/work state. No PTY (xterm over WS = F3), no
|
||||||
|
* mutation — every call goes through the existing transport-neutral gateways
|
||||||
|
* (`project`, `workState`) via DI, so no component touches `@tauri-apps/api`.
|
||||||
|
*
|
||||||
|
* It reuses the frozen read-model types (`ProjectWorkState`) and only calls the
|
||||||
|
* commands B4 puts on the read-only allowlist: `list_projects`, `open_project`,
|
||||||
|
* `get_project_work_state`. A deliberately small surface — the full IDE (layout,
|
||||||
|
* agents, terminals) is out of scope until the streaming lots.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { GatewayError, Project, ProjectWorkState } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebWorkspace() {
|
||||||
|
const { project, workState } = useGateways();
|
||||||
|
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [snapshot, setSnapshot] = useState<ProjectWorkState | null>(null);
|
||||||
|
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setProjects(await project.listProjects());
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
}
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const openReadOnly = useCallback(
|
||||||
|
async (projectId: string) => {
|
||||||
|
setError(null);
|
||||||
|
setLoadingSnapshot(true);
|
||||||
|
setOpenId(projectId);
|
||||||
|
setSnapshot(null);
|
||||||
|
try {
|
||||||
|
// Read-only: open resolves the project server-side, then we read the
|
||||||
|
// live/work-state snapshot. No layout/agents/PTY are mounted.
|
||||||
|
await project.openProject(projectId);
|
||||||
|
setSnapshot(await workState.getProjectWorkState(projectId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setLoadingSnapshot(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[project, workState],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
|
||||||
|
Rafraîchir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Panel className="border-danger/40">
|
||||||
|
<p className="text-sm text-danger">{error}</p>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projects === null ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
||||||
|
<Spinner size={12} /> Chargement des projets…
|
||||||
|
</span>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">Aucun projet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2" data-testid="web-project-list">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openReadOnly(p.id)}
|
||||||
|
aria-pressed={openId === p.id}
|
||||||
|
className="flex w-full flex-col items-start rounded-md border border-border bg-raised px-3 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-content">{p.name}</span>
|
||||||
|
<span className="text-xs text-faint">{p.root}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{openId && (
|
||||||
|
<Panel className="mt-2">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
État (lecture seule)
|
||||||
|
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
|
||||||
|
read-only
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
{loadingSnapshot && <Spinner size={12} />}
|
||||||
|
</div>
|
||||||
|
{snapshot ? (
|
||||||
|
<WorkStateSnapshot snapshot={snapshot} />
|
||||||
|
) : loadingSnapshot ? (
|
||||||
|
<p className="text-xs text-muted">Chargement de l'état…</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted">Aucun état disponible.</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure render of the read-only work-state snapshot. */
|
||||||
|
function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) {
|
||||||
|
if (snapshot.agents.length === 0) {
|
||||||
|
return <p className="text-xs text-muted">Aucun agent actif.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col gap-1.5" data-testid="web-workstate">
|
||||||
|
{snapshot.agents.map((a) => (
|
||||||
|
<li key={a.agentId} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-content">{a.name}</span>
|
||||||
|
<span className="flex items-center gap-2 text-faint">
|
||||||
|
<span>{a.live ? `live · ${a.live.kind}` : "offline"}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
a.busy.state === "busy" ? "text-warning" : "text-success"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{a.busy.state === "busy" ? "busy" : "idle"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/features/web/index.ts
Normal file
8
frontend/src/features/web/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Web client feature (ticket #13, lot F2) — the read-only browser surface used
|
||||||
|
* when the HTTP transport is selected. Public entry is {@link WebApp}.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { WebApp } from "./WebApp";
|
||||||
|
export { PairingScreen } from "./PairingScreen";
|
||||||
|
export { WebWorkspace } from "./WebWorkspace";
|
||||||
Reference in New Issue
Block a user