feat(auth): add remembered local login to the frontend
This commit is contained in:
@@ -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
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user