feat(auth): add remembered local login to the frontend

This commit is contained in:
2026-08-17 04:52:39 +02:00
parent 8c67cb75dc
commit 202822f3ba
36 changed files with 2649 additions and 163 deletions
+210
View File
@@ -0,0 +1,210 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AppShell } from "./AppShell";
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState, useAuthGeneration, useAuthUser } from "../auth/authState";
import { server } from "../test/msw";
import { useSessionStore } from "../store/sessionStore";
function renderShell(user: {
subject: string;
isAdmin: boolean;
roles: readonly ("user" | "admin")[];
permissions: readonly string[];
}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAuthState({
issuer: "local", ...user, csrfToken: null, session: null,
});
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
}
function KeyedAuthenticatedShell() {
const user = useAuthUser();
const generation = useAuthGeneration();
return user ? <AppShell key={`${user.issuer}:${user.subject}:${generation}`} /> : null;
}
beforeEach(() => {
clearAuthState();
useSessionStore.getState().resetSession();
server.use(
http.get("/api/sessions", () => HttpResponse.json([])),
http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/workspace-registry/status", () => HttpResponse.json({
branch: "main", ahead: 0, behind: 0, degraded: false,
})),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true })),
);
});
describe("authenticated shell permissions", () => {
test("shows only read-safe workspace chrome to a session user", async () => {
renderShell({ subject: "user-1", isAdmin: false, roles: ["user"], permissions: ["session.use"] });
expect(await screen.findByRole("button", { name: "Workspace management" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
});
test("shows management and all-session chrome only for exact permissions", async () => {
renderShell({
subject: "admin-1",
isAdmin: true,
roles: ["admin"],
permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "pi.manage"],
});
expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument();
});
test("logout revokes the cookie session and clears in-memory auth", async () => {
const user = {
issuer: "local" as const,
subject: "admin-1",
roles: ["admin"] as const,
permissions: ["session.use", "pi.manage"],
isAdmin: true,
csrfToken: "c".repeat(43),
session: null,
};
setAuthState(user);
let logoutCalls = 0;
server.use(http.post("/api/auth/logout", () => {
logoutCalls += 1;
return new HttpResponse(null, { status: 204 });
}));
renderShell(user);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await vi.waitFor(() => expect(logoutCalls).toBe(1));
expect(getAuthState()).toBeNull();
});
test("a stale logout continuation cannot scrub user B after the logout response settles", async () => {
const userA = {
issuer: "local" as const, subject: "user-a", roles: ["user"] as const,
permissions: ["session.use"] as const, isAdmin: false,
csrfToken: "a".repeat(43), session: null,
};
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
let releaseLogout!: () => void;
let logoutStarted!: () => void;
let logoutSettled!: () => void;
const logoutGate = new Promise<void>((resolve) => { releaseLogout = resolve; });
const started = new Promise<void>((resolve) => { logoutStarted = resolve; });
const settled = new Promise<void>((resolve) => { logoutSettled = resolve; });
server.use(http.post("/api/auth/logout", async () => {
logoutStarted();
try {
await logoutGate;
return new HttpResponse(null, { status: 204 });
} finally {
logoutSettled();
}
}));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAuthState(userA);
render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await started;
act(() => setAuthState(userB));
act(() => {
client.setQueryData(["b-only"], { owner: "user-b" });
useSessionStore.getState().applyEvent({ type: "text_delta", text: "B transcript" });
});
releaseLogout();
await act(async () => { await settled; });
expect(getAuthState()).toMatchObject({ subject: "user-b" });
expect(client.getQueryData(["b-only"])).toEqual({ owner: "user-b" });
expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B transcript" }]);
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
});
test("handles a failed shell logout without an unhandled rejection", async () => {
const user = {
issuer: "local" as const, subject: "user-a", roles: ["user"] as const,
permissions: ["session.use"] as const, isAdmin: false,
csrfToken: "a".repeat(43), session: null,
};
const rejection = vi.fn();
process.on("unhandledRejection", rejection);
server.use(http.post("/api/auth/logout", () => HttpResponse.json(
{ code: "auth_unavailable" }, { status: 503 },
)));
try {
renderShell(user);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await waitFor(() => expect(getAuthState()).toBeNull());
await new Promise((resolve) => setImmediate(resolve));
expect(rejection).not.toHaveBeenCalled();
} finally {
process.off("unhandledRejection", rejection);
}
});
test("permission chrome follows current auth state after a stale admin identity disappears", async () => {
renderShell({
subject: "admin-1", isAdmin: true, roles: ["admin"],
permissions: ["session.use", "session.read_all", "workspace.manage", "pi.manage"],
});
expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument();
act(() => clearAuthState());
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update workspace repository" })).not.toBeInTheDocument();
});
test("remounts the real shell so user-A panel and transcript state cannot survive user-B", async () => {
const userA = {
issuer: "local" as const,
subject: "user-a",
roles: ["user"] as const,
permissions: ["session.use"] as const,
isAdmin: false,
};
const userB = { ...userA, subject: "user-b" };
const panelSession = {
id: "panel-a", status: "finalized", question: "User A governed question", summary: null,
created_at: "2026-08-17T10:00:00Z", updated_at: null, author: "user-a", name: null,
group: null, archived: false,
};
server.use(
http.get("/api/sessions", () => HttpResponse.json([panelSession])),
http.get("/api/sessions/panel-a/documents", () => HttpResponse.json([
{ key: "question", title: "Question", phase: "F1", format: "markdown", content: "A-private-document" },
])),
);
setAuthState({ ...userA, csrfToken: null, session: null });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderUserShell() {
return render(<QueryClientProvider client={client}><KeyedAuthenticatedShell /></QueryClientProvider>);
}
renderUserShell();
await userEvent.click(await screen.findByTestId("session-item-panel-a"));
expect(await screen.findByText("A-private-document")).toBeInTheDocument();
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "A-private-transcript" }));
expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "A-private-transcript" }]);
act(() => setAuthState({ ...userB, csrfToken: null, session: null }));
await waitFor(() => expect(screen.queryByText("A-private-document")).not.toBeInTheDocument());
expect(screen.queryByRole("heading", { name: "User A governed question" })).not.toBeInTheDocument();
expect(useSessionStore.getState().transcript).toEqual([]);
expect(getAuthGeneration()).toBeGreaterThan(0);
});
});
@@ -6,6 +6,7 @@ import { server } from "../test/msw";
import { FakeEventSource } from "../test/fakeEventSource";
import { useSessionStore } from "../store/sessionStore";
import { AppShell } from "./AppShell";
import { clearAuthState, setAuthState } from "../auth/authState";
import { workspacePreferences } from "../workspaces/preferences";
function renderShell() {
@@ -14,6 +15,11 @@ function renderShell() {
}
beforeEach(() => {
clearAuthState();
setAuthState({
issuer: "test", subject: "test", roles: ["user"], permissions: ["session.use"],
isAdmin: false, csrfToken: null, session: null,
});
localStorage.clear();
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
@@ -18,6 +18,7 @@ beforeEach(() => {
});
test("session errors queued in the store become visible notifications", async () => {
render(<App />);
await screen.findByTestId("app-shell");
act(() => useSessionStore.getState().pushToast({
level: "error",
@@ -5,10 +5,22 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { FakeEventSource } from "../test/fakeEventSource";
import { AppShell } from "./AppShell";
import type { AuthenticatedUser } from "../api/types";
import { clearAuthState, setAuthState } from "../auth/authState";
import { useSessionStore } from "../store/sessionStore";
import { ACTIVITY_PANEL_STORAGE_KEY } from "./useActivityPanelResize";
function wrap() {
const regularUser: AuthenticatedUser = {
issuer: "portal", subject: "alice", displayName: "Alice", roles: ["user"] as const,
permissions: ["session.use", "pi.manage"] as const, isAdmin: false, csrfToken: null, session: null,
};
const adminUser: AuthenticatedUser = {
...regularUser, subject: "alice-id", roles: ["admin"] as const,
permissions: ["session.use", "session.read_all", "pi.manage"] as const, isAdmin: true,
};
function wrap(user: AuthenticatedUser = regularUser) {
if (user !== regularUser) setAuthState(user);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
}
@@ -47,6 +59,8 @@ function deferred() {
}
beforeEach(() => {
clearAuthState();
setAuthState(regularUser);
localStorage.clear();
FakeEventSource.instances = [];
ControlledResizeObserver.instances = [];
@@ -57,13 +71,18 @@ beforeEach(() => {
useSessionStore.getState().resetSession();
server.use(
http.get("/api/me", () =>
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }),
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false, permissions: ["session.use", "pi.manage"] }),
),
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/sessions", () => HttpResponse.json(LIST)),
http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
http.get("/api/sessions/:id/documents", () => HttpResponse.json([
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
])),
http.post("/api/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })),
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
);
});
@@ -135,7 +154,7 @@ test("administrators can explicitly switch to all sessions and see owners", asyn
]);
}),
);
wrap();
wrap(adminUser);
await screen.findByRole("button", { name: "All sessions" });
expect(screen.getByRole("button", { name: "My sessions" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "All sessions" })).toHaveAttribute("aria-pressed", "false");
@@ -162,7 +181,7 @@ test("administrator confirms before deleting a same-named user's session", async
return new HttpResponse(null, { status: 204 });
}),
);
wrap();
wrap(adminUser);
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
await screen.findByText("Owner: Alice");
await userEvent.click(screen.getByRole("checkbox", { name: "Select Attiva uno" }));
@@ -188,7 +207,7 @@ test("administrator confirms before archiving a same-named user's session", asyn
return new HttpResponse(null, { status: 204 });
}),
);
wrap();
wrap(adminUser);
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
await screen.findByText("Owner: Alice");
await userEvent.click(screen.getByRole("button", { name: "Session actions" }));
@@ -735,6 +754,63 @@ test("starting a new question invalidates a pending Resume intent", async () =>
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
});
test("a delayed Resume from user A cannot activate a session after user B logs in", async () => {
const resumeGate = deferred();
const resumeStarted = deferred();
const resumeSettled = deferred();
server.use(
http.post("/api/sessions/:id/resume", async () => {
resumeStarted.resolve();
try {
await resumeGate.promise;
return resumeResult("s1");
} finally {
resumeSettled.resolve();
}
}),
http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await resumeStarted.promise;
act(() => setAuthState({ ...regularUser, subject: "bob", displayName: "Bob" }));
resumeGate.resolve();
await act(async () => { await resumeSettled.promise; });
expect(FakeEventSource.instances).toHaveLength(0);
expect(useSessionStore.getState().currentPhase).toBeNull();
expect(screen.queryByRole("button", { name: "Hide model activity" })).not.toBeInTheDocument();
});
test("a delayed session deletion from user A cannot refresh or toast into user B", async () => {
const deleteGate = deferred();
const deleteStarted = deferred();
const deleteSettled = deferred();
server.use(http.delete("/api/sessions/:id", async () => {
deleteStarted.resolve();
try {
await deleteGate.promise;
return new HttpResponse(null, { status: 204 });
} finally {
deleteSettled.resolve();
}
}));
wrap();
await screen.findByText("Attiva uno");
screen.getByRole("checkbox", { name: "Select Attiva uno" }).focus();
await userEvent.keyboard(" ");
await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" }));
await deleteStarted.promise;
act(() => setAuthState({ ...regularUser, subject: "bob", displayName: "Bob" }));
deleteGate.resolve();
await act(async () => { await deleteSettled.promise; });
expect(useSessionStore.getState().toasts).toEqual([]);
expect(screen.queryByText("Deleted 1 of 1 sessions.")).not.toBeInTheDocument();
expect(screen.getByTestId("session-item-s1")).toBeInTheDocument();
});
test("a successful Delete invalidates an earlier pending Resume for the same target", async () => {
const resumeGate = deferred();
const resumeStarted = deferred();
+142 -44
View File
@@ -21,19 +21,23 @@ import { Checkbox } from "../components/ui/checkbox";
import { Toaster } from "../components/ui/sonner";
import { toast } from "sonner";
import {
closeSession, getMe, listSessions, resumeSession, getSession,
closeSession, listSessions, resumeSession, getSession,
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
checkDwhHealth,
} from "../api/sessions";
import { logout as logoutUser } from "../api/auth";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "../components/ui/dialog";
import type { Principal, SessionScope, SessionSummary } from "../api/types";
import type { SessionScope, SessionSummary } from "../api/types";
import { useAuthGeneration, useAuthUser } from "../auth/authState";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
export function AppShell() {
const authenticatedUser = useAuthUser();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
const {
containerRef,
@@ -81,14 +85,29 @@ export function AppShell() {
const resumeInFlightRef = useRef(new Map<string, {
latestToken: number;
promise: Promise<void>;
guard: AuthOperationGuard;
}>());
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
const [sessionScope, setSessionScope] = useState<SessionScope>("mine");
const { data: principal } = useQuery<Principal>({ queryKey: ["me"], queryFn: getMe, staleTime: Infinity });
const principal = authenticatedUser;
const permissions = authenticatedUser?.permissions ?? [];
const canReadAllSessions = permissions.includes("session.read_all");
const canManageWorkspace = permissions.includes("workspace.manage");
const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage");
const canManagePi = permissions.includes("pi.manage");
const authGeneration = useAuthGeneration();
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions", sessionScope], queryFn: () => listSessions(sessionScope), refetchInterval: 10_000,
queryKey: ["sessions", sessionScope],
queryFn: async () => {
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!guard) throw new StaleAuthOperationError();
const result = await listSessions(sessionScope);
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) throw new StaleAuthOperationError();
return result;
},
refetchInterval: 10_000,
});
const composerRef = useRef<HTMLTextAreaElement>(null);
@@ -107,11 +126,15 @@ export function AppShell() {
const [dwhDown, setDwhDown] = useState(false);
const [dwhChecking, setDwhChecking] = useState(true);
const [dwhCheckEpoch, setDwhCheckEpoch] = useState(0);
const operationEpochRef = useRef(0);
useEffect(() => () => { operationEpochRef.current += 1; }, []);
useEffect(() => {
let cancelled = false;
const operation = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!operation) return () => { cancelled = true; };
setDwhChecking(true);
checkDwhHealth().then((r) => {
if (cancelled) return;
if (cancelled || !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return;
setDwhDown(!r.ok);
setDwhChecking(false);
});
@@ -125,7 +148,10 @@ export function AppShell() {
const activeList = sessions.filter((s) => !s.archived);
const ungroupedActive = activeList.filter((s) => !s.group);
const archivedList = sessions.filter((s) => s.archived);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] });
const refresh = (operation?: AuthOperationGuard | null) => {
if (operation && !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return;
void queryClient.invalidateQueries({ queryKey: ["sessions"] });
};
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
const finalized = activeSession?.status === "finalized";
const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id));
@@ -158,6 +184,10 @@ export function AppShell() {
});
}, [sessions]);
useEffect(() => {
if (!canReadAllSessions && sessionScope === "all") setSessionScope("mine");
}, [canReadAllSessions, sessionScope]);
function setSessionSelected(id: string, selected: boolean) {
setSelectedSessionIds((current) => {
const next = new Set(current);
@@ -193,10 +223,12 @@ export function AppShell() {
});
}
async function doResume(id: string) {
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
const token = ++resumeInvocationRef.current;
latestResumeIntentRef.current = { token, id };
const inFlight = resumeInFlightRef.current.get(id);
if (inFlight) {
if (inFlight && isAuthOperationCurrent(inFlight.guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) {
// Repeated intent for the same target shares one backend lifecycle operation and one
// commit path. Updating its token still lets s1→s2→s1 make the final s1 intent authoritative.
inFlight.latestToken = token;
@@ -206,8 +238,9 @@ export function AppShell() {
const operation = {
latestToken: token,
promise: Promise.resolve(),
guard,
};
operation.promise = runResume(id, operation).finally(() => {
operation.promise = runResume(id, operation, guard).finally(() => {
if (resumeInFlightRef.current.get(id) === operation) {
resumeInFlightRef.current.delete(id);
}
@@ -219,9 +252,11 @@ export function AppShell() {
async function runResume(
id: string,
operation: { latestToken: number; promise: Promise<void> },
guard: AuthOperationGuard,
) {
try {
const result = await resumeSession(id);
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latest = latestResumeIntentRef.current;
if (latest?.token !== operation.latestToken || latest.id !== id) return;
const reconnectSameSession = activeSessionIdRef.current === id;
@@ -254,6 +289,7 @@ export function AppShell() {
// The manifest's `phase` is the 1-based current phase (1..8).
try {
const m = (await getSession(id)) as { phase?: number };
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latestAfterManifest = latestResumeIntentRef.current;
if (
latestAfterManifest?.token !== operation.latestToken
@@ -267,7 +303,7 @@ export function AppShell() {
/* non-fatal: the first gate will set the phase */
}
} catch {
if (
if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current }) &&
latestResumeIntentRef.current?.token === operation.latestToken
&& latestResumeIntentRef.current.id === id
) {
@@ -276,41 +312,73 @@ export function AppShell() {
}
}
async function move(s: SessionSummary, group: string) {
const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
await setSessionGroup(s.id, group); refresh();
await setSessionGroup(s.id, group);
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
refresh(guard);
} catch {
toast.error("Failed to move session.");
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to move session.");
}
}
async function newGroup(s: SessionSummary) {
const name = window.prompt("New group:");
if (name && name.trim()) {
const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
await setSessionGroup(s.id, name.trim()); refresh();
await setSessionGroup(s.id, name.trim());
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
refresh(guard);
} catch {
toast.error("Failed to update group.");
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to update group.");
}
}
}
async function renameGroup(oldName: string, newName: string) {
async function renameGroup(oldName: string, newName: string, suppliedGuard?: AuthOperationGuard | null) {
const trimmed = newName.trim();
if (!trimmed || trimmed === oldName) return;
if (!trimmed || trimmed === oldName) return false;
const guard = suppliedGuard ?? captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!guard) return false;
try {
for (const s of sessions.filter((x) => x.group === oldName)) {
await setSessionGroup(s.id, trimmed);
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return false;
}
refresh();
refresh(guard);
return true;
} catch {
toast.error("Failed to rename group.");
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to rename group.");
return false;
}
}
async function renameSessionFromDialog(id: string, name: string) {
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
await renameSession(id, name);
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
setRenameTarget(null);
refresh(guard);
} catch {
if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) {
toast.error("Failed to rename session.");
}
}
}
async function toggleArchive(s: SessionSummary) {
const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id));
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
if (panelSession?.id === s.id) setPanelSession(null);
refresh();
refresh(guard);
} catch {
toast.error(s.archived ? "Failed to restore session." : "Failed to archive session.");
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) {
toast.error(s.archived ? "Failed to restore session." : "Failed to archive session.");
}
}
}
@@ -323,8 +391,11 @@ export function AppShell() {
}
async function deleteSessions(targets: SessionSummary[]) {
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
const deletedIds = new Set(
targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id),
);
@@ -334,12 +405,12 @@ export function AppShell() {
if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null);
if (deletedActiveSession) { resetSession(); selectActiveSession(null); }
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
refresh();
refresh(guard);
if (deletedIds.size !== targets.length) {
toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`);
}
} catch {
toast.error("Failed to delete selected sessions.");
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to delete selected sessions.");
}
}
@@ -401,7 +472,13 @@ export function AppShell() {
// session sits idle or a gate awaits the reviewer (pendingWidget).
const running = working && !finalized;
useSessionStream(activeSessionId, 0, streamCursorResetEpoch);
useSessionStream(
activeSessionId,
0,
streamCursorResetEpoch,
Boolean(authenticatedUser),
authGeneration,
);
// A backend "session_exit" system event (e.g. the replay server emitting it
// when the reviewer picks "Esci") asks us to leave the live session view and
@@ -463,16 +540,24 @@ export function AppShell() {
async function stopSession() {
if (!activeSessionId) return;
const id = activeSessionId;
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
invalidateResumeIntent();
try {
await closeSession(activeSessionId);
await closeSession(id);
} finally {
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
resetSession();
selectActiveSession(null);
setAwaitingQuestion(false);
}
}
async function signOut() {
await logoutUser();
}
return (
<div
ref={containerRef}
@@ -611,6 +696,14 @@ export function AppShell() {
<br />
Human In The Loop
</p>
{authenticatedUser && (
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
<span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}>
{authenticatedUser.displayName ?? authenticatedUser.subject}
</span>
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>Log out</Button>
</div>
)}
</div>
<div className="flex flex-col gap-2 px-4 pb-3">
@@ -630,17 +723,19 @@ export function AppShell() {
>
Workspace management
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setPiManagementOpen(true)}
>
Pi management
</Button>
{canManagePi && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setPiManagementOpen(true)}
>
Pi management
</Button>
)}
</div>
{principal?.isAdmin && (
{canReadAllSessions && (
<div className="px-4 pb-3">
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" aria-label="Session scope">
<Button
@@ -788,8 +883,13 @@ export function AppShell() {
</div>
</aside>
)}
<WorkspaceManager open={workspaceManagerOpen} onClose={() => setWorkspaceManagerOpen(false)} />
<PiManagement open={piManagementOpen} onClose={() => setPiManagementOpen(false)} />
<WorkspaceManager
open={workspaceManagerOpen}
onClose={() => setWorkspaceManagerOpen(false)}
canManageWorkspace={canManageWorkspace}
canManageSecrets={canManageWorkspaceSecrets}
/>
{canManagePi && <PiManagement open={piManagementOpen} onClose={() => setPiManagementOpen(false)} />}
<Toaster />
@@ -804,15 +904,7 @@ export function AppShell() {
open
initial={renameTarget.name ?? ""}
onOpenChange={(o) => { if (!o) setRenameTarget(null); }}
onSubmit={async (name) => {
try {
await renameSession(renameTarget.id, name);
setRenameTarget(null);
refresh();
} catch {
toast.error("Failed to rename session.");
}
}}
onSubmit={(name) => renameSessionFromDialog(renameTarget.id, name)}
/>
)}
{renameGroupTarget && (
@@ -820,7 +912,13 @@ export function AppShell() {
open
initial={renameGroupTarget}
onOpenChange={(o) => { if (!o) setRenameGroupTarget(null); }}
onSubmit={async (name) => { await renameGroup(renameGroupTarget, name); setRenameGroupTarget(null); }}
onSubmit={async (name) => {
const target = renameGroupTarget;
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (await renameGroup(target, name, guard) && guard && isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) {
setRenameGroupTarget(null);
}
}}
title="Rename group"
/>
)}
+24 -3
View File
@@ -1,5 +1,5 @@
// frontend/src/shell/NewSessionDialog.tsx
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
@@ -8,6 +8,7 @@ import {
} from "../components/ui/dialog";
import { Button } from "../components/ui/button";
import { createSession } from "../api/sessions";
import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
interface Props {
onCreated: (id: string) => void;
@@ -18,6 +19,8 @@ export function NewSessionDialog({ onCreated }: Props) {
const [question, setQuestion] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const operationEpochRef = useRef(0);
useEffect(() => () => { operationEpochRef.current += 1; }, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -26,16 +29,34 @@ export function NewSessionDialog({ onCreated }: Props) {
setError("The question cannot be empty.");
return;
}
const operation = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
setBusy(true);
try {
const { id } = await createSession({ question: question.trim() });
const { id } = await createSession({ question: question.trim() }, operation ? {
operation,
isCurrent: () => isAuthOperationCurrent(operation, {
sessionId: null,
disposalEpoch: operationEpochRef.current,
}),
} : undefined);
if (operation && !isAuthOperationCurrent(operation, {
sessionId: null,
disposalEpoch: operationEpochRef.current,
})) return;
setOpen(false);
setQuestion("");
onCreated(id);
} catch (err) {
if (operation && !isAuthOperationCurrent(operation, {
sessionId: null,
disposalEpoch: operationEpochRef.current,
})) return;
setError(err instanceof Error ? err.message : "Failed to create the session.");
} finally {
setBusy(false);
if (!operation || isAuthOperationCurrent(operation, {
sessionId: null,
disposalEpoch: operationEpochRef.current,
})) setBusy(false);
}
}
@@ -1,4 +1,4 @@
import { render, screen, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
@@ -60,6 +60,7 @@ test("lets text documents adapt to the full resized panel width", async () => {
test("shows Resume for resumable, hides it for finalized", async () => {
const { rerender } = wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
expect(await screen.findByRole("button", { name: /resume/i })).toBeInTheDocument();
await waitFor(() => expect(document.querySelector(".shiki")).not.toBeNull());
rerender(<SessionDocumentsPanel session={{ ...base, status: "finalized" }} onClose={vi.fn()} onResume={vi.fn()} />);
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
});
+134 -2
View File
@@ -1,16 +1,23 @@
// frontend/src/shell/SteerInput.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { canonicalWorkspaceFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
import { useSessionStore } from "../store/sessionStore";
import { workspacePreferences } from "../workspaces/preferences";
import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
import { setAuthState } from "../auth/authState";
const userA = {
issuer: "local", subject: "user-a", roles: ["user"] as const,
permissions: ["session.use"], isAdmin: false, csrfToken: "a".repeat(43), session: null,
};
beforeEach(() => {
localStorage.clear();
setAuthState(userA);
server.use(
http.post("/api/sessions/:id/steer", () =>
new HttpResponse(null, { status: 204 }),
@@ -73,6 +80,131 @@ test("submitting typed text POSTs to /steer and clears the input", async () => {
expect(input).toHaveValue("");
});
test("a delayed steer from user A cannot mutate user B's store or composer", async () => {
let release!: () => void;
let started!: () => void;
let settled!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
server.use(http.post("/api/sessions/:id/steer", async () => {
started();
try {
await held;
return new HttpResponse(null, { status: 204 });
} finally {
settled();
}
}));
setAuthState(userA);
const view = render(<SteerInput sessionId="s1" />);
const input = screen.getByRole("textbox");
await userEvent.type(input, "A-only steer");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await requestStarted;
act(() => setAuthState({ ...userA, subject: "user-b", csrfToken: "b".repeat(43) }));
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "B-entry" }));
release();
await act(async () => { await requestSettled; });
expect(useSessionStore.getState().lastUserEntry).toEqual({ kind: "input", text: "B-entry" });
expect(input).toHaveValue("A-only steer");
view.unmount();
});
test("a settings preflight from user A prevents session POST after user B logs in", async () => {
let releaseSettings!: () => void;
let settingsStarted!: () => void;
let settingsSettled!: () => void;
let sessionPosts = 0;
const settingsGate = new Promise<void>((resolve) => { releaseSettings = resolve; });
const started = new Promise<void>((resolve) => { settingsStarted = resolve; });
const settled = new Promise<void>((resolve) => { settingsSettled = resolve; });
server.use(
http.get("/api/settings", async () => {
settingsStarted();
try {
await settingsGate;
return HttpResponse.json({ workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" });
} finally {
settingsSettled();
}
}),
http.post("/api/sessions", () => {
sessionPosts += 1;
return HttpResponse.json({ id: "a-session" });
}),
);
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
const view = render(<SteerInput sessionId={null} />);
const input = screen.getByRole("textbox", { name: /new question/i });
await userEvent.type(input, "A-only question");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await started;
act(() => setAuthState(userB));
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "B-entry" }));
releaseSettings();
await act(async () => { await settled; });
expect(sessionPosts).toBe(0);
expect(useSessionStore.getState().lastUserEntry).toEqual({ kind: "input", text: "B-entry" });
view.unmount();
});
test("a workspace-policy preflight from user A prevents session POST after user B logs in", async () => {
workspacePreferences.save({
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
});
let releaseWorkspaces!: () => void;
let workspacesStarted!: () => void;
let workspacesSettled!: () => void;
let sessionPosts = 0;
const workspacesGate = new Promise<void>((resolve) => { releaseWorkspaces = resolve; });
const started = new Promise<void>((resolve) => { workspacesStarted = resolve; });
const settled = new Promise<void>((resolve) => { workspacesSettled = resolve; });
server.use(
http.get("/api/workspaces", () => HttpResponse.json([{
...workspaceSummaryFixture("psd-clinical", {
displayName: "PSD Clinical",
revision: workspaceRevisionFixture("psd-clinical"),
}),
}])),
http.get("/api/workspaces/psd-clinical", async () => {
workspacesStarted();
try {
await workspacesGate;
return HttpResponse.json({
workspace: canonicalWorkspaceFixture("psd-clinical"),
revision: workspaceRevisionFixture("psd-clinical"),
});
} finally {
workspacesSettled();
}
}),
http.post("/api/sessions", () => {
sessionPosts += 1;
return HttpResponse.json({ id: "a-session" });
}),
);
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
const view = render(<SteerInput sessionId={null} />);
const input = screen.getByRole("textbox", { name: /new question/i });
await userEvent.type(input, "A-policy-question");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await started;
act(() => setAuthState(userB));
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "B-entry" }));
releaseWorkspaces();
await act(async () => { await settled; });
expect(sessionPosts).toBe(0);
expect(useSessionStore.getState().lastUserEntry).toEqual({ kind: "input", text: "B-entry" });
view.unmount();
});
test("pressing Enter in the input submits the steer", async () => {
let captured: unknown = null;
server.use(
+26 -11
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { CornerDownLeft } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { postSteer, createSession } from "../api/sessions";
import { ApiError } from "../api/client";
import { ApiError, apiErrorMessage } from "../api/client";
import { getSettings } from "../api/settings";
import { getWorkspace, listWorkspaces } from "../api/workspaces";
import { listModels } from "../api/models";
@@ -11,6 +11,7 @@ import { useSessionStore } from "../store/sessionStore";
import {
WorkspaceSelectionError, workspacePolicyGate, workspacePreferences, type WorkspacePreference,
} from "../workspaces/drafts";
import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
const THINKING_LEVELS = ["low", "medium", "high"] as const;
@@ -48,6 +49,8 @@ export function SteerInput({
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
const setPhase = useSessionStore((s) => s.setPhase);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const operationEpochRef = useRef(0);
useEffect(() => () => { operationEpochRef.current += 1; }, []);
// Merge our own ref (for autosizing) with the forwarded one (parent focus).
function attachRef(el: HTMLTextAreaElement | null) {
@@ -68,14 +71,19 @@ export function SteerInput({
async function submit() {
const trimmed = text.trim();
if (!trimmed || busy) return;
const operation = captureAuthOperation({ sessionId, disposalEpoch: operationEpochRef.current });
if (!operation) return;
setBusy(true);
if (sessionId) {
try {
await postSteer(sessionId, trimmed);
if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return;
setLastUserEntry({ kind: "input", text: trimmed });
setText("");
} finally {
setBusy(false);
if (isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) {
setBusy(false);
}
}
return;
}
@@ -86,22 +94,29 @@ export function SteerInput({
setLastUserEntry({ kind: "input", text: trimmed });
onSessionCreating?.(trimmed);
try {
const { id } = await createSession({ question: trimmed });
const { id } = await createSession({ question: trimmed }, {
operation,
isCurrent: () => isAuthOperationCurrent(operation, {
sessionId: null,
disposalEpoch: operationEpochRef.current,
}),
});
if (!isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operationEpochRef.current })) return;
onSessionCreated?.(id);
setText("");
} catch (error) {
if (!isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operationEpochRef.current })) return;
// Keep the question in the composer so retrying does not require retyping.
// A DWH-unreachable precheck (local dev, VPN down) carries a specific code;
// surface its message as the alert instead of the generic retry hint.
const payload = error instanceof ApiError
? (error.payload as { code?: string; error?: string } | undefined)
: undefined;
const alert = payload?.code === "dwh_unreachable"
? payload.error
// ApiError messages are derived from the local status/code allowlist;
// never surface response-body text from the server.
const alert = error instanceof ApiError && error.code === "dwh_unreachable"
? apiErrorMessage(error)
: error instanceof WorkspaceSelectionError ? error.message : undefined;
onSessionCreateFailed?.(alert);
} finally {
setBusy(false);
if (isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operationEpochRef.current })) {
setBusy(false);
}
}
}
@@ -4,8 +4,17 @@ import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import { useSessionStore } from "../store/sessionStore";
import { WidgetHost } from "./WidgetHost";
import { setAuthState } from "../auth/authState";
beforeEach(() => useSessionStore.getState().resetSession());
const userA = {
issuer: "local", subject: "user-a", roles: ["user"] as const,
permissions: ["session.use"], isAdmin: false, csrfToken: "a".repeat(43), session: null,
};
beforeEach(() => {
useSessionStore.getState().resetSession();
setAuthState(userA);
});
test("a gate choice shows progress, prevents duplicate clicks, and stays silent on success", async () => {
let release!: () => void;
@@ -48,3 +57,38 @@ test("a gate choice shows progress, prevents duplicate clicks, and stays silent
expect(screen.getByRole("button", { name: "Next answer" })).toBeEnabled();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
test("a delayed gate response from user A cannot clear user B's pending gate", async () => {
let release!: () => void;
let started!: () => void;
let settled!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
server.use(http.post("/api/sessions/s1/response", async () => {
started();
try {
await held;
return new HttpResponse(null, { status: 204 });
} finally {
settled();
}
}));
setAuthState(userA);
useSessionStore.setState({
pendingWidget: { id: "gate-a", widget: "select", options: [{ id: "a", label: "A answer" }] },
});
render(<WidgetHost sessionId="s1" />);
await userEvent.click(screen.getByRole("button", { name: "A answer" }));
await requestStarted;
act(() => setAuthState({ ...userA, subject: "user-b", csrfToken: "b".repeat(43) }));
act(() => useSessionStore.setState({
pendingWidget: { id: "gate-b", widget: "select", options: [{ id: "b", label: "B answer" }] },
}));
release();
await act(async () => { await requestSettled; });
expect(useSessionStore.getState().pendingWidget?.id).toBe("gate-b");
expect(useSessionStore.getState().lastUserEntry).toBeNull();
});
+7 -6
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import { WidgetHost } from "./WidgetHost";
import { useSessionStore } from "../store/sessionStore";
import type { WidgetDescriptor } from "../api/types";
@@ -17,18 +17,19 @@ beforeEach(() => {
useSessionStore.getState().resetSession();
});
afterEach(() => {
cleanup();
errorSpy.mockRestore();
useSessionStore.getState().resetSession();
act(() => useSessionStore.getState().resetSession());
});
const gate: WidgetDescriptor = { id: "g1", widget: "select", title: "Choose" };
test("a crashing widget shows a fallback instead of unmounting the app", () => {
test("a crashing widget shows a fallback instead of unmounting the app", async () => {
useSessionStore.setState({ pendingWidget: gate });
render(<WidgetHost sessionId="s1" />);
await act(async () => { render(<WidgetHost sessionId="s1" />); });
// No throw escaped: the fallback rendered.
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(await screen.findByText(/couldn't be displayed/i)).toBeInTheDocument();
// The store (and thus the surrounding app) is still alive and interactive.
expect(useSessionStore.getState().pendingWidget).toEqual(gate);
});
+9 -1
View File
@@ -1,9 +1,10 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useSessionStore } from "../store/sessionStore";
import { resolve } from "../widgets";
import { postResponse } from "../api/sessions";
import type { UiResponse } from "../api/types";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
export function WidgetHost({ sessionId }: { sessionId: string | null }) {
const pending = useSessionStore((s) => s.pendingWidget);
@@ -12,17 +13,23 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
const [responding, setResponding] = useState(false);
const responseInFlight = useRef(false);
const operationEpochRef = useRef(0);
useEffect(() => () => { operationEpochRef.current += 1; }, []);
if (!pending) return null;
const Renderer = resolve(pending.widget);
const onRespond = async (r: UiResponse) => {
if (responseInFlight.current) return;
const operation = captureAuthOperation({ sessionId, disposalEpoch: operationEpochRef.current });
if (!operation) return;
responseInFlight.current = true;
setResponding(true);
if (sessionId) {
try {
await postResponse(sessionId, r);
if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return;
} catch (err) {
if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return;
// Surface feedback and keep the widget pending so the user can retry.
pushToast({
level: "error",
@@ -36,6 +43,7 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
return;
}
}
if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return;
responseInFlight.current = false;
setResponding(false);
setLastUserEntry({
+68 -2
View File
@@ -1,14 +1,21 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { beforeEach, expect, test, vi } from "vitest";
import { server } from "../test/msw";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
import { WorkspaceManager } from "./WorkspaceManager";
import { setAuthState } from "../auth/authState";
import { queryClient } from "../app/queryClient";
const workspace = canonicalWorkspaceFixture("psd-clinical");
const revision = workspaceRevisionFixture("psd-clinical");
const authenticatedWorkspaceUser = {
issuer: "local", subject: "workspace-user", roles: ["user"] as const,
permissions: ["workspace.manage", "workspace.secrets.manage"], isAdmin: false,
csrfToken: "w".repeat(43), session: null,
};
const requirement = {
id: "dwh.password",
connector: "dwh",
@@ -34,14 +41,24 @@ function renderManager(onClose = vi.fn()) {
onClose,
...render(
<QueryClientProvider client={client}>
<WorkspaceManager open onClose={onClose} />
<WorkspaceManager open onClose={onClose} canManageWorkspace canManageSecrets />
</QueryClientProvider>,
),
};
}
function renderDeniedManager() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<WorkspaceManager open onClose={vi.fn()} canManageWorkspace={false} canManageSecrets={false} />
</QueryClientProvider>,
);
}
beforeEach(() => {
localStorage.clear();
setAuthState(authenticatedWorkspaceUser);
server.use(
http.get("/api/workspace-registry/status", () => HttpResponse.json({
branch: "main",
@@ -70,6 +87,17 @@ beforeEach(() => {
);
});
test("defaults workspace mutations and secrets to denied", async () => {
renderDeniedManager();
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Update workspace repository" })).not.toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "PSD Clinical" }));
expect(await screen.findByRole("heading", { name: "Workspace-specific actions" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Validate workspace source" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Test workspace connections" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Runtime secrets" })).not.toBeInTheDocument();
});
test("uses a tall viewport area and keeps the workspace content scrollable", () => {
renderManager();
@@ -236,6 +264,44 @@ test("secret fields are write-only, clear after blind save, and may be forgotten
expect(await screen.findByText("Not configured")).toBeVisible();
});
test("a delayed runtime-secret save from user A cannot repopulate user B's cache or notice", async () => {
let release!: () => void;
let started!: () => void;
let settled!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
server.use(http.put("/api/workspaces/psd-clinical/secrets", async () => {
started();
try {
await held;
return HttpResponse.json(runtimeConfiguration(true));
} finally {
settled();
}
}));
queryClient.clear();
setAuthState({ issuer: "local", subject: "user-a", roles: ["user"], permissions: ["workspace.secrets.manage"], isAdmin: false, csrfToken: "a".repeat(43), session: null });
render(
<QueryClientProvider client={queryClient}>
<WorkspaceManager open onClose={vi.fn()} canManageWorkspace canManageSecrets />
</QueryClientProvider>,
);
await userEvent.click(await screen.findByRole("button", { name: "PSD Clinical" }));
const input = await screen.findByLabelText("Data warehouse password");
await userEvent.type(input, "a-secret");
await userEvent.click(screen.getByRole("button", { name: "Save entered secrets" }));
await requestStarted;
act(() => setAuthState({ issuer: "local", subject: "user-b", roles: ["user"], permissions: ["workspace.secrets.manage"], isAdmin: false, csrfToken: "b".repeat(43), session: null }));
expect(queryClient.getQueryData(["workspace-runtime-configuration", "psd-clinical"])).toBeUndefined();
release();
await act(async () => { await requestSettled; });
expect(queryClient.getQueryData(["workspace-runtime-configuration", "psd-clinical"])).toBeUndefined();
expect(screen.queryByText("Runtime secrets saved. Stored values remain hidden.")).not.toBeInTheDocument();
});
test("closing clears unsaved secret fields", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
+99 -28
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
@@ -25,6 +25,7 @@ import {
validateWorkspace,
type WorkspaceRuntimeConfiguration,
} from "../api/workspaces";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError } from "../auth/authOperation";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -60,7 +61,17 @@ function stateLabel(state: "ready" | "configuration_required"): string {
const workspaceAuthoringGuideUrl =
"https://github.com/mptyl/ThothII/blob/main/docs/install/local-workspace-registry.md#prepare-and-publish-a-workspace-source";
export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) {
export function WorkspaceManager({
open,
onClose,
canManageWorkspace = false,
canManageSecrets = false,
}: {
open: boolean;
onClose: () => void;
canManageWorkspace?: boolean;
canManageSecrets?: boolean;
}) {
const queryClient = useQueryClient();
const [selectedId, setSelectedId] = useState<string>();
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
@@ -71,15 +82,34 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
const [connectionNotice, setConnectionNotice] = useState<string>();
const [connectionDiagnostics, setConnectionDiagnostics] = useState<string[]>([]);
const [busyAction, setBusyAction] = useState<string>();
const operationEpochRef = useRef(0);
const selectedIdRef = useRef(selectedId);
selectedIdRef.current = selectedId;
useEffect(() => () => { operationEpochRef.current += 1; }, []);
async function guardedQuery<T>(request: () => Promise<T>, targetId?: string): Promise<T> {
const guard = captureAuthOperation({
sessionId: targetId ?? null,
disposalEpoch: operationEpochRef.current,
});
if (!guard) throw new StaleAuthOperationError();
const result = await request();
const currentSessionId = targetId === undefined ? null : selectedIdRef.current;
if (!isAuthOperationCurrent(guard, {
sessionId: currentSessionId,
disposalEpoch: operationEpochRef.current,
})) throw new StaleAuthOperationError();
return result;
}
const statusQuery = useQuery({
queryKey: ["workspace-repository-status"],
queryFn: getWorkspaceRegistryStatus,
queryFn: () => guardedQuery(getWorkspaceRegistryStatus),
enabled: open,
});
const workspacesQuery = useQuery({
queryKey: ["workspaces"],
queryFn: listWorkspaces,
queryFn: () => guardedQuery(listWorkspaces),
enabled: open,
});
const workspaces = workspacesQuery.data ?? [];
@@ -89,12 +119,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
);
const detailQuery = useQuery({
queryKey: ["workspace", selectedId],
queryFn: () => getWorkspace(selectedId!),
queryFn: () => guardedQuery(() => getWorkspace(selectedId!), selectedId),
enabled: Boolean(open && selectedId),
});
const runtimeQuery = useQuery({
queryKey: ["workspace-runtime-configuration", selectedId],
queryFn: () => getWorkspaceRuntimeConfiguration(selectedId!),
queryFn: () => guardedQuery(() => getWorkspaceRuntimeConfiguration(selectedId!), selectedId),
enabled: Boolean(open && selectedId),
});
@@ -131,48 +161,63 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
};
async function updateRepository() {
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!guard) return;
setBusyAction("repository");
clearMessages();
try {
await pullWorkspaceRegistry();
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
await Promise.all([
statusQuery.refetch(),
workspacesQuery.refetch(),
selectedId ? detailQuery.refetch() : Promise.resolve(),
selectedId ? runtimeQuery.refetch() : Promise.resolve(),
]);
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
setNotice("Workspace repository updated and validated.");
} catch (error) {
setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]);
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) {
setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
async function validateSource() {
if (!detailQuery.data) return;
const guard = captureAuthOperation({ sessionId: selectedId, disposalEpoch: operationEpochRef.current });
if (!guard) return;
setBusyAction("validate");
clearGlobalMessages();
setValidationNotice(undefined);
setValidationDiagnostics([]);
try {
await validateWorkspace(detailQuery.data.workspace);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
setValidationNotice("Workspace source is valid.");
} catch (error) {
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
async function testConnections() {
if (!selectedId) return;
const targetId = selectedId;
const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current });
if (!guard) return;
setBusyAction("test");
clearGlobalMessages();
setConnectionNotice(undefined);
setConnectionDiagnostics([]);
try {
const result = await testWorkspace(selectedId);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
const issues = result.diagnostics.filter(({ level }) => level !== "info");
const informational = result.diagnostics.find(({ level }) => level === "info");
setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`));
@@ -184,14 +229,19 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
: "Workspace connection test completed.");
}
} catch (error) {
setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
async function saveSecrets() {
if (!selectedId) return;
const targetId = selectedId;
const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current });
if (!guard) return;
const values = Object.fromEntries(
Object.entries(secretValues).filter(([, value]) => value.length > 0),
);
@@ -199,38 +249,49 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
setBusyAction("save-secrets");
clearMessages();
try {
const configuration = await saveWorkspaceSecrets(selectedId, values);
const configuration = await saveWorkspaceSecrets(targetId, values);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
queryClient.setQueryData(
["workspace-runtime-configuration", selectedId],
["workspace-runtime-configuration", targetId],
configuration,
);
setSecretValues({});
await workspacesQuery.refetch();
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
setNotice("Runtime secrets saved. Stored values remain hidden.");
} catch (error) {
setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
async function forgetSecret(requirementId: string) {
if (!selectedId) return;
const targetId = selectedId;
const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current });
if (!guard) return;
setBusyAction(`forget:${requirementId}`);
clearMessages();
try {
const configuration = await forgetWorkspaceSecret(selectedId, requirementId);
const configuration = await forgetWorkspaceSecret(targetId, requirementId);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
queryClient.setQueryData(
["workspace-runtime-configuration", selectedId],
["workspace-runtime-configuration", targetId],
configuration,
);
setSecretValues((current) => ({ ...current, [requirementId]: "" }));
await workspacesQuery.refetch();
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
setNotice("Stored secret forgotten.");
} catch (error) {
setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
@@ -322,7 +383,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<p className="text-sm leading-6 text-muted-foreground">
Follow the <a href={workspaceAuthoringGuideUrl} target="_blank" rel="noreferrer" className="font-medium text-foreground underline underline-offset-4 hover:text-primary">workspace authoring instructions on GitHub</a> for the required layout and validation rules.
</p>
<div className="rounded-lg border border-border bg-muted/30 p-4">
{canManageWorkspace && <div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-2xl">
<h3 className="font-heading font-semibold">Update workspace repository</h3>
@@ -332,7 +393,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<GitPullRequest />Update workspace repository
</Button>
</div>
</div>
</div>}
{!canManageWorkspace && (
<p className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
You can inspect workspaces. Workspace updates, validation, and connection tests require workspace management permission.
</p>
)}
<p className="text-sm text-muted-foreground">Select a workspace from the left only for workspace-specific validation, runtime credentials, and connection tests.</p>
</section>
) : (
@@ -387,9 +453,9 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
))}
</div>
)}
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "validate"} onClick={() => { void validateSource(); }}>
{canManageWorkspace && <Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "validate"} onClick={() => { void validateSource(); }}>
<ClipboardCheck />Validate workspace source
</Button>
</Button>}
</div>
<div data-testid="workspace-connection-card" className="rounded-lg border border-border p-4">
<h4 className="font-heading font-semibold">Test workspace connections</h4>
@@ -408,13 +474,13 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
))}
</div>
)}
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
{canManageWorkspace && <Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
<FlaskConical />Test workspace connections
</Button>
</Button>}
</div>
</div>
<div className="rounded-lg border border-border p-4">
{canManageSecrets && <div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-start gap-3">
<KeyRound className="mt-0.5 size-5 text-primary" />
<div>
@@ -478,7 +544,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
</div>
</div>
)}
</div>
</div>}
{!canManageSecrets && (
<p className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
Runtime secret controls are available only to authorized workspace operators.
</p>
)}
</>
)}
</section>
+1 -1
View File
@@ -33,7 +33,7 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
render(<App />);
// Type the first question directly in the sticky composer and send it
const composer = screen.getByRole("textbox", { name: /new question/i });
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Quante vendite?");
await userEvent.click(screen.getByRole("button", { name: /send/i }));