feat(auth): add remembered local login to the frontend
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "./test/msw";
|
||||
import { App } from "./App";
|
||||
|
||||
test("App renders the shell with create affordance", () => {
|
||||
test("App renders the shell with create affordance after authentication", async () => {
|
||||
server.use(
|
||||
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([])),
|
||||
);
|
||||
render(<App />);
|
||||
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: /new/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { AppShell } from "./shell/AppShell";
|
||||
import { AuthGate } from "./auth/AuthGate";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppShell />
|
||||
<AuthGate />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ApiError, apiFetch } from "./client";
|
||||
import type { AuthenticatedUser, AuthPublicConfig, AuthRole, AuthSessionInfo } from "./types";
|
||||
import { clearAuthStateIfCurrent, getAuthGeneration, setAuthState } from "../auth/authState";
|
||||
|
||||
const authModes = new Set<AuthPublicConfig["mode"]>(["local", "oidc", "upstream", "none", "mock"]);
|
||||
const roles = new Set<AuthRole>(["user", "admin"]);
|
||||
const sessionMethods = new Set<AuthSessionInfo["method"]>(["local", "oidc", "upstream"]);
|
||||
let pendingLogoutResponse: Promise<void> | null = null;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseAuthConfig(value: unknown): AuthPublicConfig {
|
||||
const source = record(value);
|
||||
if (!source || !authModes.has(source.mode as AuthPublicConfig["mode"])
|
||||
|| typeof source.localLogin !== "boolean" || typeof source.oidcLogin !== "boolean") {
|
||||
throw new Error("Authentication service returned an invalid public configuration");
|
||||
}
|
||||
return {
|
||||
mode: source.mode as AuthPublicConfig["mode"],
|
||||
localLogin: source.localLogin,
|
||||
oidcLogin: source.oidcLogin,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUser(value: unknown): AuthenticatedUser {
|
||||
const source = record(value);
|
||||
if (!source || typeof source.issuer !== "string" || typeof source.subject !== "string") {
|
||||
throw new Error("Authentication service returned an invalid user");
|
||||
}
|
||||
const parsedRoles = Array.isArray(source.roles)
|
||||
? source.roles.filter((role): role is AuthRole => typeof role === "string" && roles.has(role as AuthRole))
|
||||
: [];
|
||||
const parsedPermissions = Array.isArray(source.permissions)
|
||||
? source.permissions.filter((permission): permission is string => typeof permission === "string")
|
||||
: [];
|
||||
const csrfToken = source.csrfToken === undefined || source.csrfToken === null
|
||||
? null
|
||||
: typeof source.csrfToken === "string" ? source.csrfToken : null;
|
||||
const sessionSource = source.session === null || source.session === undefined
|
||||
? undefined
|
||||
: record(source.session);
|
||||
const session = sessionSource
|
||||
&& sessionMethods.has(sessionSource.method as AuthSessionInfo["method"])
|
||||
&& typeof sessionSource.remembered === "boolean"
|
||||
&& typeof sessionSource.idleExpiresAt === "string"
|
||||
&& typeof sessionSource.absoluteExpiresAt === "string"
|
||||
? {
|
||||
method: sessionSource.method as AuthSessionInfo["method"],
|
||||
remembered: sessionSource.remembered,
|
||||
idleExpiresAt: sessionSource.idleExpiresAt,
|
||||
absoluteExpiresAt: sessionSource.absoluteExpiresAt,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
issuer: source.issuer,
|
||||
subject: source.subject,
|
||||
...(typeof source.displayName === "string" ? { displayName: source.displayName } : {}),
|
||||
roles: parsedRoles,
|
||||
permissions: parsedPermissions,
|
||||
isAdmin: source.isAdmin === true,
|
||||
csrfToken,
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAuthConfig(): Promise<AuthPublicConfig> {
|
||||
return parseAuthConfig(await apiFetch<unknown>("/auth/config"));
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<AuthenticatedUser> {
|
||||
return parseUser(await apiFetch<unknown>("/me"));
|
||||
}
|
||||
|
||||
export async function loginLocal(username: string, password: string, remember: boolean): Promise<AuthenticatedUser> {
|
||||
if (pendingLogoutResponse) await pendingLogoutResponse;
|
||||
await apiFetch<void>("/auth/local/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password, remember }),
|
||||
});
|
||||
const user = await getMe();
|
||||
setAuthState(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<boolean> {
|
||||
const logoutGeneration = getAuthGeneration();
|
||||
if (pendingLogoutResponse) {
|
||||
await pendingLogoutResponse;
|
||||
return false;
|
||||
}
|
||||
let cleared = false;
|
||||
const response = (async () => {
|
||||
try {
|
||||
await apiFetch<void>("/auth/logout", { method: "POST" });
|
||||
} finally {
|
||||
cleared = clearAuthStateIfCurrent(logoutGeneration);
|
||||
}
|
||||
})();
|
||||
let trackedResponse: Promise<void>;
|
||||
trackedResponse = response.finally(() => {
|
||||
if (pendingLogoutResponse === trackedResponse) pendingLogoutResponse = null;
|
||||
});
|
||||
pendingLogoutResponse = trackedResponse;
|
||||
await trackedResponse;
|
||||
return cleared;
|
||||
}
|
||||
|
||||
export function authErrorStatus(error: unknown): number | undefined {
|
||||
return error instanceof ApiError ? error.status : undefined;
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
import { apiFetch } from "./client";
|
||||
import { apiErrorMessage, apiFetch, ApiError, assertSameOriginRequestUrl } from "./client";
|
||||
import { clearAuthState, setAuthState } from "../auth/authState";
|
||||
|
||||
const authenticated = {
|
||||
issuer: "local",
|
||||
subject: "user-1",
|
||||
roles: ["user"] as const,
|
||||
permissions: ["session.use"],
|
||||
isAdmin: false,
|
||||
csrfToken: "c".repeat(43),
|
||||
session: null,
|
||||
};
|
||||
|
||||
beforeEach(() => setAuthState(authenticated));
|
||||
afterEach(() => clearAuthState());
|
||||
|
||||
test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => {
|
||||
let contentType: string | null = "unset";
|
||||
@@ -25,3 +39,258 @@ test("POST with a body sends application/json content-type", async () => {
|
||||
await apiFetch("/sessions/s1/steer", { method: "POST", body: JSON.stringify({ text: "hi" }) });
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
|
||||
test("same-origin requests include credentials and overwrite the CSRF header from memory", async () => {
|
||||
let observed: { credentials: string | null; csrf: string | null } | undefined;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
server.use(
|
||||
http.post("/api/sessions/s1/steer", ({ request }) => {
|
||||
observed = {
|
||||
credentials: request.headers.get("credentials"),
|
||||
csrf: request.headers.get("x-thothii-csrf"),
|
||||
};
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
|
||||
await apiFetch("/sessions/s1/steer", {
|
||||
method: "POST",
|
||||
headers: { "X-ThothII-CSRF": "attacker-supplied" },
|
||||
body: JSON.stringify({ text: "hi" }),
|
||||
});
|
||||
|
||||
expect(observed?.csrf).toBe("c".repeat(43));
|
||||
expect(observed?.credentials).toBeNull();
|
||||
expect(fetchSpy.mock.calls.at(-1)?.[1]).toMatchObject({ credentials: "same-origin" });
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("a 401 clears the in-memory auth state and advances its generation", async () => {
|
||||
const before = (await import("../auth/authState")).getAuthGeneration();
|
||||
server.use(http.get("/api/private", () => new HttpResponse(null, { status: 401 })));
|
||||
|
||||
await expect(apiFetch("/private")).rejects.toMatchObject({ status: 401 });
|
||||
|
||||
const state = await import("../auth/authState");
|
||||
expect(state.getAuthState()).toBeNull();
|
||||
expect(state.getAuthGeneration()).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
test("a delayed 401 from user A cannot clear user B after a new login", async () => {
|
||||
let release!: () => void;
|
||||
const delayed = new Promise<void>((resolve) => { release = resolve; });
|
||||
server.use(http.get("/api/stale-request", async () => {
|
||||
await delayed;
|
||||
return new HttpResponse(null, { status: 401 });
|
||||
}));
|
||||
|
||||
const request = apiFetch("/stale-request");
|
||||
const userB = { ...authenticated, subject: "user-b", csrfToken: "b".repeat(43) };
|
||||
setAuthState(userB);
|
||||
release();
|
||||
|
||||
await expect(request).rejects.toMatchObject({ status: 401 });
|
||||
expect((await import("../auth/authState")).getAuthState()).toMatchObject({ subject: "user-b" });
|
||||
});
|
||||
|
||||
test("bounds streamed error bodies and never exposes raw HTML or secrets", async () => {
|
||||
const secret = "TOP-SECRET-token-123";
|
||||
const hugeBody = `<html>${secret}${"x".repeat(20_000)}</html>`;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(hugeBody.slice(0, 4_000)));
|
||||
controller.enqueue(new TextEncoder().encode(hugeBody.slice(4_000)));
|
||||
controller.close();
|
||||
},
|
||||
}), { status: 500, headers: { "content-type": "text/html", "content-length": "1" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await apiFetch<unknown>("/oversized").catch((error: unknown) => error);
|
||||
expect(result).toBeInstanceOf(ApiError);
|
||||
const failure = result as ApiError;
|
||||
expect(failure.status).toBe(500);
|
||||
expect(failure.message).not.toContain(secret);
|
||||
expect(failure.message).not.toContain("<html>");
|
||||
expect(failure.bodyText).not.toContain(secret);
|
||||
expect(failure.payload).toBeUndefined();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps only a known safe bounded JSON error payload", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
code: "dwh_unreachable",
|
||||
error: "The data warehouse is unreachable.",
|
||||
}), { status: 503, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await apiFetch<unknown>("/known-error").catch((error: unknown) => error);
|
||||
const failure = result as ApiError;
|
||||
expect(failure).toMatchObject({
|
||||
status: 503,
|
||||
code: "dwh_unreachable",
|
||||
payload: { code: "dwh_unreachable" },
|
||||
});
|
||||
expect(failure.bodyText).toBe("");
|
||||
expect(failure.message).toBe("The database is unreachable. Please retry.");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("derives local messages without retaining a malicious known-code message", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
code: "dwh_unreachable",
|
||||
message: "Bearer eyJhbGciOiJIUzI1NiJ9.password=do-not-show",
|
||||
error: "<html>password=do-not-show</html>",
|
||||
}), { status: 503, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
const failure = await apiFetch<unknown>("/known-malicious").catch((error: unknown) => error) as ApiError;
|
||||
expect(failure.code).toBe("dwh_unreachable");
|
||||
expect(failure.message).toBe("The database is unreachable. Please retry.");
|
||||
expect(failure.bodyText).toBe("");
|
||||
expect(failure.payload).toEqual({ code: "dwh_unreachable" });
|
||||
expect(failure.message).not.toMatch(/Bearer|password|html|do-not-show/i);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses a generic local message for unknown malicious codes", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
code: "unknown-secret-code",
|
||||
message: "Bearer eyJhbGciOiJIUzI1NiJ9",
|
||||
error: "password=do-not-show",
|
||||
}), { status: 500, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
const failure = await apiFetch<unknown>("/unknown-malicious").catch((error: unknown) => error) as ApiError;
|
||||
expect(failure.code).toBeUndefined();
|
||||
expect(failure.message).toBe("Request failed. Please try again.");
|
||||
expect(failure.bodyText).toBe("");
|
||||
expect(failure.payload).toBeUndefined();
|
||||
expect(apiErrorMessage(failure)).toBe("Request failed. Please try again.");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("releases the response reader after a successful bounded read", async () => {
|
||||
const reader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: new TextEncoder().encode("{not-json") })
|
||||
.mockResolvedValueOnce({ done: true, value: undefined }),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = new Response(new ReadableStream(), { status: 500 });
|
||||
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
|
||||
|
||||
try {
|
||||
await expect(apiFetch("/reader-success")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(reader.cancel).not.toHaveBeenCalled();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("cancels and releases the response reader on overflow", async () => {
|
||||
const reader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: new Uint8Array(9 * 1024) }),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = new Response(new ReadableStream(), { status: 500 });
|
||||
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
|
||||
|
||||
try {
|
||||
await expect(apiFetch("/reader-overflow")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("cancels and releases the response reader when a read throws", async () => {
|
||||
const reader = {
|
||||
read: vi.fn().mockRejectedValue(new Error("stream broke")),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = new Response(new ReadableStream(), { status: 500 });
|
||||
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
|
||||
|
||||
try {
|
||||
await expect(apiFetch("/reader-throws")).rejects.toThrow("stream broke");
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects malformed and sensitive JSON error bodies without surfacing their content", async () => {
|
||||
const bodies = [
|
||||
"{not-json",
|
||||
JSON.stringify({ code: "unknown_secret_code", error: "password=super-secret" }),
|
||||
];
|
||||
|
||||
for (const body of bodies) {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(body, { status: 500, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
try {
|
||||
const result = await apiFetch<unknown>("/unsafe-error").catch((error: unknown) => error);
|
||||
const failure = result as ApiError;
|
||||
expect(failure.payload).toBeUndefined();
|
||||
expect(failure.message).not.toContain("super-secret");
|
||||
expect(failure.message).not.toContain("not-json");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
const knownCode = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "auth_forbidden", error: "token=super-secret" }), {
|
||||
status: 403, headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const result = await apiFetch<unknown>("/known-sensitive-error").catch((error: unknown) => error);
|
||||
const failure = result as ApiError;
|
||||
expect(failure.payload).toEqual({ code: "auth_forbidden" });
|
||||
expect(failure.message).toBe("Access is not permitted.");
|
||||
expect(failure.message).not.toContain("super-secret");
|
||||
} finally {
|
||||
knownCode.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses a cross-origin request before sending credentials", () => {
|
||||
expect(() => assertSameOriginRequestUrl("https://attacker.example/api/me")).toThrow(/same-origin/i);
|
||||
});
|
||||
|
||||
test("preserves explicit 403 and 503 statuses for presentation", async () => {
|
||||
server.use(
|
||||
http.get("/api/forbidden", () => HttpResponse.json({ code: "auth_forbidden" }, { status: 403 })),
|
||||
http.get("/api/unavailable", () => HttpResponse.json({ code: "auth_unavailable" }, { status: 503 })),
|
||||
);
|
||||
await expect(apiFetch("/forbidden")).rejects.toBeInstanceOf(ApiError);
|
||||
await expect(apiFetch("/unavailable")).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
+158
-10
@@ -1,15 +1,137 @@
|
||||
import { backendBaseUrl as BASE, joinBackendPath } from "./runtime-config";
|
||||
import {
|
||||
clearAuthStateIfCurrent,
|
||||
getAuthGeneration,
|
||||
getAuthState,
|
||||
} from "../auth/authState";
|
||||
|
||||
const MAX_ERROR_BODY_BYTES = 8 * 1024;
|
||||
const safeErrorCodes = new Set([
|
||||
"auth_forbidden", "auth_invalid_credentials", "auth_not_authorized", "auth_unavailable",
|
||||
"auth_not_implemented", "authentication_required", "invalid_credentials", "login_rate_limited",
|
||||
"csrf_failed", "csrf_invalid", "dwh_unreachable", "model_unavailable",
|
||||
"workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale",
|
||||
"git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable",
|
||||
"semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable",
|
||||
"pi_management_invalid_config", "pi_management_write_failed",
|
||||
]);
|
||||
|
||||
type SafeErrorPayload = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const localCodeMessages: Record<string, string> = {
|
||||
auth_forbidden: "Access is not permitted.",
|
||||
auth_invalid_credentials: "Invalid username or password.",
|
||||
auth_not_authorized: "Access is not permitted.",
|
||||
auth_unavailable: "Authentication is temporarily unavailable. Try again.",
|
||||
auth_not_implemented: "This sign-in method is not available.",
|
||||
authentication_required: "Please sign in to continue.",
|
||||
invalid_credentials: "Invalid username or password.",
|
||||
login_rate_limited: "Too many sign-in attempts. Try again later.",
|
||||
csrf_failed: "The security check failed. Please retry.",
|
||||
csrf_invalid: "The security check failed. Please retry.",
|
||||
dwh_unreachable: "The database is unreachable. Please retry.",
|
||||
model_unavailable: "The model provider is unavailable. Please retry.",
|
||||
workspace_invalid: "The workspace configuration is invalid.",
|
||||
binding_missing: "The workspace is missing a required binding.",
|
||||
workspace_not_activatable: "The workspace cannot be activated.",
|
||||
workspace_stale: "The workspace has changed. Refresh and try again.",
|
||||
git_unavailable: "The workspace repository is unavailable.",
|
||||
git_auth_failed: "The workspace repository could not be authenticated.",
|
||||
git_non_fast_forward: "The workspace repository has moved. Refresh and try again.",
|
||||
connector_unavailable: "A workspace connector is unavailable.",
|
||||
semantic_index_incompatible: "The workspace semantic index is incompatible.",
|
||||
pi_management_forbidden: "Pi management is not permitted",
|
||||
pi_management_unavailable: "Pi management is unavailable.",
|
||||
pi_management_invalid_config: "The Pi configuration is invalid.",
|
||||
pi_management_write_failed: "The Pi configuration could not be saved.",
|
||||
};
|
||||
|
||||
const localStatusMessages: Record<number, string> = {
|
||||
401: "Please sign in to continue.",
|
||||
403: "Access is not permitted.",
|
||||
404: "The requested resource was not found.",
|
||||
409: "The request conflicts with current workspace state.",
|
||||
429: "Too many requests. Try again later.",
|
||||
500: "Request failed. Please try again.",
|
||||
502: "The service is unavailable. Please retry.",
|
||||
503: "The service is temporarily unavailable. Please retry.",
|
||||
};
|
||||
|
||||
const GENERIC_ERROR_MESSAGE = "Request failed. Please try again.";
|
||||
|
||||
function localErrorMessage(status: number, code?: string): string {
|
||||
return (code && localCodeMessages[code]) || localStatusMessages[status] || GENERIC_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown for non-2xx responses. `.message` stays "<status> <body>" for
|
||||
* backward compatibility; `.status` and `.payload` (parsed JSON body, if any)
|
||||
* let callers branch on a specific failure — e.g. a `code: "dwh_unreachable"`.
|
||||
* Error thrown for non-2xx responses. The message contains only status and a
|
||||
* whitelisted error code; `.payload` contains a bounded, sanitized JSON shape.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(readonly status: number, readonly bodyText: string, readonly payload: unknown) {
|
||||
super(`${status} ${bodyText}`);
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly bodyText: string,
|
||||
readonly payload: SafeErrorPayload | undefined,
|
||||
) {
|
||||
super(localErrorMessage(status, payload?.code));
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
get code(): string | undefined {
|
||||
return this.payload?.code;
|
||||
}
|
||||
}
|
||||
|
||||
export function apiErrorMessage(error: unknown): string {
|
||||
return error instanceof ApiError ? error.message : GENERIC_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
function parseSafeErrorPayload(text: string, truncated: boolean): SafeErrorPayload | undefined {
|
||||
if (truncated || text.length === 0) return undefined;
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch { return undefined; }
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
||||
const source = parsed as Record<string, unknown>;
|
||||
if (typeof source.code !== "string" || !safeErrorCodes.has(source.code)) return undefined;
|
||||
return { code: source.code };
|
||||
}
|
||||
|
||||
async function readBoundedText(response: Response): Promise<{ text: string; truncated: boolean }> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return { text: "", truncated: false };
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let bytes = 0;
|
||||
let truncated = false;
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
const remaining = MAX_ERROR_BODY_BYTES - bytes;
|
||||
if (remaining <= 0) {
|
||||
truncated = true;
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
const chunk = next.value.byteLength > remaining ? next.value.slice(0, remaining) : next.value;
|
||||
bytes += chunk.byteLength;
|
||||
text += decoder.decode(chunk, { stream: next.value.byteLength <= remaining });
|
||||
if (next.value.byteLength > remaining) {
|
||||
truncated = true;
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
try { await reader.cancel(); } catch { /* preserve the original read failure */ }
|
||||
throw error;
|
||||
} finally {
|
||||
text += decoder.decode();
|
||||
try { reader.releaseLock(); } catch { /* a completed browser reader may already be released */ }
|
||||
}
|
||||
return { text, truncated };
|
||||
}
|
||||
|
||||
function requestHeaders(init: RequestInit | undefined): Headers {
|
||||
@@ -27,16 +149,42 @@ function requestHeaders(init: RequestInit | undefined): Headers {
|
||||
}
|
||||
|
||||
async function request(path: string, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(joinBackendPath(BASE, path), { ...init, headers: requestHeaders(init) });
|
||||
const url = joinBackendPath(BASE, path);
|
||||
assertSameOriginRequestUrl(url);
|
||||
const dispatchGeneration = getAuthGeneration();
|
||||
const headers = requestHeaders(init);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
||||
headers.delete("X-ThothII-CSRF");
|
||||
const csrfToken = getAuthState()?.csrfToken;
|
||||
if (csrfToken) headers.set("X-ThothII-CSRF", csrfToken);
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
headers,
|
||||
});
|
||||
if (res.status === 401) clearAuthStateIfCurrent(dispatchGeneration);
|
||||
if (!res.ok) {
|
||||
const bodyText = await res.text().catch(() => "");
|
||||
let payload: unknown;
|
||||
try { payload = bodyText ? JSON.parse(bodyText) : undefined; } catch { payload = undefined; }
|
||||
throw new ApiError(res.status, bodyText, payload);
|
||||
const { text, truncated } = await readBoundedText(res);
|
||||
const payload = parseSafeErrorPayload(text, truncated);
|
||||
throw new ApiError(res.status, "", payload);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Refuse a credentialed cross-origin base before the browser can send a request. */
|
||||
export function assertSameOriginRequestUrl(url: string): void {
|
||||
if (typeof window === "undefined") {
|
||||
if (/^https?:\/\//i.test(url)) throw new Error("The browser must use the same-origin /api route");
|
||||
return;
|
||||
}
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.origin !== window.location.origin) {
|
||||
throw new Error("The browser must use the same-origin /api route");
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// Only declare a JSON content-type when we actually send a body. Body-less
|
||||
// POSTs (resume, close) would otherwise make Fastify reject the empty body
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiError, apiFetch } from "./client";
|
||||
import { ApiError, apiErrorMessage, apiFetch } from "./client";
|
||||
|
||||
export type PiReasoning = "low" | "medium" | "high";
|
||||
|
||||
@@ -54,22 +54,14 @@ const errorCodes = new Set<PiManagementApiErrorCode>([
|
||||
"pi_management_write_failed",
|
||||
]);
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Narrows the sanctioned error payload without ever surfacing its raw response body. */
|
||||
/** Narrows the sanctioned error code and derives its message locally. */
|
||||
export function asPiManagementApiError(error: unknown): PiManagementApiError | undefined {
|
||||
if (!(error instanceof ApiError)) return undefined;
|
||||
const payload = object(error.payload);
|
||||
const code = payload?.code;
|
||||
const message = payload?.error;
|
||||
if (typeof code !== "string" || !errorCodes.has(code as PiManagementApiErrorCode) || typeof message !== "string") {
|
||||
const code = error.code;
|
||||
if (typeof code !== "string" || !errorCodes.has(code as PiManagementApiErrorCode)) {
|
||||
return undefined;
|
||||
}
|
||||
return { status: error.status, code: code as PiManagementApiErrorCode, message };
|
||||
return { status: error.status, code: code as PiManagementApiErrorCode, message: apiErrorMessage(error) };
|
||||
}
|
||||
|
||||
export const getPiManagementStatus = () => apiFetch<PiManagementStatus>("/pi-management/status");
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { apiFetch } from "./client";
|
||||
import {
|
||||
captureAuthOperation,
|
||||
isAuthOperationCurrent,
|
||||
requireAuthOperationPrecondition,
|
||||
type AuthOperationPrecondition,
|
||||
} from "../auth/authOperation";
|
||||
import { getSettings } from "./settings";
|
||||
import { getWorkspace, listWorkspaces } from "./workspaces";
|
||||
import {
|
||||
@@ -6,18 +12,20 @@ import {
|
||||
workspacePreferences, type WorkspacePreference,
|
||||
} from "../workspaces/drafts";
|
||||
import type {
|
||||
Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
|
||||
AuthenticatedUser, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
|
||||
} from "./types";
|
||||
|
||||
type NewSessionInput = { question: string; name?: string } & Partial<WorkspacePreference>;
|
||||
|
||||
async function selectedPreferences(): Promise<WorkspacePreference> {
|
||||
async function selectedPreferences(precondition?: AuthOperationPrecondition): Promise<WorkspacePreference> {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
const saved = workspacePreferences.load();
|
||||
if (saved.workspaceId && saved.provider && saved.model && saved.thinking) return saved;
|
||||
|
||||
// A direct new-session entry point can run before the composer has mounted. Seed current
|
||||
// application memory from the installation defaults once, then keep choices ephemeral.
|
||||
const legacy = await getSettings();
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
return workspacePreferences.save({
|
||||
workspaceId: saved.workspaceId ?? legacy.workspace,
|
||||
provider: saved.provider ?? legacy.provider,
|
||||
@@ -44,8 +52,9 @@ function reconcileWorkspacePolicy(preferences: WorkspacePreference, allowed: rea
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
|
||||
async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecondition): Promise<WorkspacePreference> {
|
||||
while (true) {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
const preferences = workspacePreferences.load();
|
||||
const workspaceId = preferences.workspaceId;
|
||||
if (!workspaceId) return preferences;
|
||||
@@ -54,10 +63,12 @@ async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
|
||||
try {
|
||||
workspaces = await listWorkspaces();
|
||||
} catch {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
workspacePolicyGate.rejectSummary(workspaceId);
|
||||
throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
|
||||
}
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
|
||||
if (!workspace) {
|
||||
@@ -69,18 +80,22 @@ async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
|
||||
try {
|
||||
installationDefault = (await getSettings()).workspace;
|
||||
} catch {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
workspacePolicyGate.rejectSummary(workspaceId);
|
||||
throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
|
||||
}
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
const replacement = workspaces.find(
|
||||
(candidate) => candidate.id === installationDefault && candidate.revision,
|
||||
) ?? workspaces.find((candidate) => candidate.revision);
|
||||
if (!replacement) {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
workspacePolicyGate.reject(workspaceId);
|
||||
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||
}
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
workspacePreferences.save({ ...preferences, workspaceId: replacement.id });
|
||||
continue;
|
||||
}
|
||||
@@ -98,16 +113,19 @@ async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
|
||||
(selection) => ({ kind: "selection" as const, selection }),
|
||||
),
|
||||
]);
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (outcome.kind === "selection") {
|
||||
if (outcome.selection.workspaceId !== workspaceId) continue;
|
||||
return outcome.selection;
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
workspacePolicyGate.reject(workspaceId);
|
||||
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||
}
|
||||
const { record } = outcome;
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
let selection: WorkspacePreference;
|
||||
try {
|
||||
@@ -117,17 +135,31 @@ async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
|
||||
record.workspace.llm_policy.default,
|
||||
);
|
||||
} catch (error) {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
workspacePolicyGate.reject(workspaceId);
|
||||
throw error;
|
||||
}
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
workspacePolicyGate.resolve(workspaceId);
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId === workspaceId) return selection;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(i: NewSessionInput) {
|
||||
await selectedPreferences();
|
||||
const selection = await ensureWorkspaceSelectionPolicy();
|
||||
export async function createSession(i: NewSessionInput, precondition?: AuthOperationPrecondition) {
|
||||
const initiatingOperation = captureAuthOperation();
|
||||
const effectivePrecondition = precondition ?? (initiatingOperation ? {
|
||||
operation: initiatingOperation,
|
||||
isCurrent: () => isAuthOperationCurrent(initiatingOperation, {
|
||||
sessionId: null,
|
||||
disposalEpoch: initiatingOperation.disposalEpoch,
|
||||
}),
|
||||
} : undefined);
|
||||
requireAuthOperationPrecondition(effectivePrecondition);
|
||||
await selectedPreferences(effectivePrecondition);
|
||||
requireAuthOperationPrecondition(effectivePrecondition);
|
||||
const selection = await ensureWorkspaceSelectionPolicy(effectivePrecondition);
|
||||
requireAuthOperationPrecondition(effectivePrecondition);
|
||||
return apiFetch<{ id: string }>("/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...i, ...selection }),
|
||||
@@ -138,7 +170,8 @@ export async function createSession(i: NewSessionInput) {
|
||||
export const prewarmRuntime = () =>
|
||||
apiFetch<void>("/runtime/prewarm", { method: "POST" });
|
||||
|
||||
export const getMe = () => apiFetch<Principal>("/me");
|
||||
/** Compatibility export for existing shell tests/callers; AuthGate uses the validated auth API. */
|
||||
export const getMe = () => apiFetch<AuthenticatedUser>("/me");
|
||||
|
||||
export const listSessions = (scope: SessionScope = "mine") =>
|
||||
apiFetch<SessionSummary[]>(`/sessions?scope=${scope}`);
|
||||
|
||||
@@ -117,11 +117,44 @@ export interface SessionSummary {
|
||||
|
||||
export type SessionScope = "mine" | "all";
|
||||
|
||||
export type AuthRole = "user" | "admin";
|
||||
|
||||
export interface AuthSessionInfo {
|
||||
method: "local" | "oidc" | "upstream";
|
||||
remembered: boolean;
|
||||
idleExpiresAt: string;
|
||||
absoluteExpiresAt: string;
|
||||
}
|
||||
|
||||
/** Safe browser representation returned by Task 8's /me route. */
|
||||
export interface AuthenticatedUser {
|
||||
issuer: string;
|
||||
subject: string;
|
||||
displayName?: string;
|
||||
roles: readonly AuthRole[];
|
||||
permissions: readonly string[];
|
||||
isAdmin: boolean;
|
||||
/** Legacy upstream/none modes deliberately return null: there is no CSRF token. */
|
||||
csrfToken: string | null;
|
||||
/** Legacy upstream/none modes deliberately return null: there is no cookie session. */
|
||||
session: AuthSessionInfo | null;
|
||||
}
|
||||
|
||||
export interface AuthPublicConfig {
|
||||
mode: "local" | "oidc" | "upstream" | "none" | "mock";
|
||||
localLogin: boolean;
|
||||
oidcLogin: boolean;
|
||||
}
|
||||
|
||||
export interface Principal {
|
||||
issuer: string;
|
||||
subject: string;
|
||||
displayName?: string;
|
||||
roles?: readonly AuthRole[];
|
||||
permissions?: readonly string[];
|
||||
isAdmin: boolean;
|
||||
csrfToken?: string | null;
|
||||
session?: AuthSessionInfo | null;
|
||||
}
|
||||
|
||||
export interface ResumeSessionResult {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiError, apiFetch } from "./client";
|
||||
import { ApiError, apiErrorMessage, apiFetch } from "./client";
|
||||
import { sanitizeCanonicalWorkspace } from "../workspaces/drafts";
|
||||
|
||||
export type WorkspaceErrorCode =
|
||||
@@ -238,19 +238,14 @@ function requireWorkspaceRevision(value: unknown, expectedId: string): Workspace
|
||||
return revision;
|
||||
}
|
||||
|
||||
/** Sanitized registry error data; it intentionally excludes the raw response body. */
|
||||
/** Localized registry error data; it intentionally excludes the raw response body. */
|
||||
export function asWorkspaceApiError(error: unknown): WorkspaceApiError | undefined {
|
||||
if (!(error instanceof ApiError)) return undefined;
|
||||
const payload = object(error.payload);
|
||||
const code = payload?.code;
|
||||
const message = payload?.message;
|
||||
if (typeof code !== "string" || !workspaceErrorCodes.has(code as WorkspaceErrorCode) || typeof message !== "string") {
|
||||
const code = error.code;
|
||||
if (typeof code !== "string" || !workspaceErrorCodes.has(code as WorkspaceErrorCode)) {
|
||||
return undefined;
|
||||
}
|
||||
const fields = Array.isArray(payload?.fields) && payload.fields.every((field) => typeof field === "string")
|
||||
? payload.fields
|
||||
: undefined;
|
||||
return { status: error.status, code: code as WorkspaceErrorCode, message, ...(fields ? { fields } : {}) };
|
||||
return { status: error.status, code: code as WorkspaceErrorCode, message: apiErrorMessage(error) };
|
||||
}
|
||||
|
||||
function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { beforeEach, afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { AuthGate } from "./AuthGate";
|
||||
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "./authState";
|
||||
import { server } from "../test/msw";
|
||||
|
||||
vi.mock("../shell/AppShell", () => ({
|
||||
AppShell: () => (
|
||||
<div data-testid="authenticated-shell">
|
||||
Authenticated shell
|
||||
<button type="button" onClick={() => { window.dispatchEvent(new Event("test-logout")); }}>Log out</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const user = {
|
||||
issuer: "local",
|
||||
subject: "user-1",
|
||||
displayName: "Analyst",
|
||||
roles: ["user"] as const,
|
||||
permissions: ["session.use"],
|
||||
isAdmin: false,
|
||||
csrfToken: "c".repeat(43),
|
||||
session: {
|
||||
method: "local" as const,
|
||||
remembered: false,
|
||||
idleExpiresAt: "2026-08-17T10:00:00.000Z",
|
||||
absoluteExpiresAt: "2026-08-17T20:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
const localConfig = { mode: "local", localLogin: true, oidcLogin: false };
|
||||
|
||||
beforeEach(() => {
|
||||
clearAuthState();
|
||||
server.use(
|
||||
http.get("/api/auth/config", () => HttpResponse.json(localConfig)),
|
||||
http.get("/api/me", () => HttpResponse.json(user)),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearAuthState();
|
||||
});
|
||||
|
||||
describe("AuthGate", () => {
|
||||
test("shows a loading state while /me is unresolved", async () => {
|
||||
server.use(http.get("/api/me", async () => {
|
||||
await delay(100);
|
||||
return HttpResponse.json(user);
|
||||
}));
|
||||
|
||||
render(<AuthGate />);
|
||||
|
||||
expect(screen.getByRole("status", { name: /checking access/i })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("authenticated-shell")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the authenticated shell from the safe /me DTO", async () => {
|
||||
render(<AuthGate />);
|
||||
|
||||
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: /sign in/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("returns to local login after an expired session 401", async () => {
|
||||
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 })));
|
||||
|
||||
render(<AuthGate />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: /sign in to thothii/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("presents a forbidden /me response explicitly", async () => {
|
||||
setAuthState(user);
|
||||
const generation = getAuthGeneration();
|
||||
server.use(http.get("/api/me", () => HttpResponse.json(
|
||||
{ code: "auth_not_authorized", error: "This operation is not permitted" },
|
||||
{ status: 403 },
|
||||
)));
|
||||
|
||||
render(<AuthGate />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: /access not permitted/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/signed in without permission/i)).toBeInTheDocument();
|
||||
expect(getAuthState()).toMatchObject({ subject: "user-1", csrfToken: "c".repeat(43) });
|
||||
expect(getAuthGeneration()).toBe(generation);
|
||||
});
|
||||
|
||||
test("offers retry when the authentication provider is unavailable", async () => {
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.get("/api/me", () => {
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? HttpResponse.json({ code: "auth_unavailable" }, { status: 503 })
|
||||
: HttpResponse.json(user);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<AuthGate />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: /authentication unavailable/i })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("accepts the nullable legacy /me session shape without inventing a token", async () => {
|
||||
server.use(http.get("/api/me", () => HttpResponse.json({
|
||||
issuer: "portal",
|
||||
subject: "legacy-user",
|
||||
displayName: "Legacy user",
|
||||
roles: ["user"],
|
||||
permissions: ["session.use"],
|
||||
isAdmin: false,
|
||||
csrfToken: null,
|
||||
session: null,
|
||||
})));
|
||||
|
||||
render(<AuthGate />);
|
||||
|
||||
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not write credentials or tokens to browser storage", async () => {
|
||||
const storageWrites = (["setItem", "removeItem", "clear"] as const).map((method) =>
|
||||
vi.spyOn(Storage.prototype, method));
|
||||
render(<AuthGate />);
|
||||
|
||||
await screen.findByTestId("authenticated-shell");
|
||||
for (const write of storageWrites) expect(write).not.toHaveBeenCalled();
|
||||
for (const write of storageWrites) write.mockRestore();
|
||||
});
|
||||
|
||||
test("does not emit act warnings while StrictMode authenticates", async () => {
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
try {
|
||||
render(<StrictMode><AuthGate /></StrictMode>);
|
||||
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
|
||||
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ApiError } from "../api/client";
|
||||
import { authErrorStatus, getAuthConfig, getMe } from "../api/auth";
|
||||
import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
|
||||
import { AppShell } from "../shell/AppShell";
|
||||
import {
|
||||
clearAuthStateIfCurrent,
|
||||
getAuthGeneration,
|
||||
getAuthState,
|
||||
isAuthGenerationCurrent,
|
||||
setAuthState,
|
||||
useAuthGeneration,
|
||||
useAuthUser,
|
||||
} from "./authState";
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
type GateStatus = "loading" | "login" | "authenticated" | "forbidden" | "unavailable";
|
||||
|
||||
function AuthenticatedContent({ onExpired }: { onExpired: () => void }) {
|
||||
const user = useAuthUser();
|
||||
const authGeneration = useAuthGeneration();
|
||||
useEffect(() => {
|
||||
if (!user) onExpired();
|
||||
}, [onExpired, user]);
|
||||
return user
|
||||
? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} />
|
||||
: null;
|
||||
}
|
||||
|
||||
export function AuthGate() {
|
||||
const [status, setStatus] = useState<GateStatus>("loading");
|
||||
const [config, setConfig] = useState<AuthPublicConfig>();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setStatus("loading");
|
||||
const loadGeneration = getAuthGeneration();
|
||||
try {
|
||||
const publicConfig = await getAuthConfig();
|
||||
if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
|
||||
setConfig(publicConfig);
|
||||
try {
|
||||
const authenticated = await getMe();
|
||||
if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
|
||||
setAuthState(authenticated);
|
||||
setStatus("authenticated");
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
const statusCode = authErrorStatus(error);
|
||||
if (statusCode === 401) {
|
||||
if (isAuthGenerationCurrent(loadGeneration)) clearAuthStateIfCurrent(loadGeneration);
|
||||
if (getAuthState() === null) setStatus("login");
|
||||
return;
|
||||
}
|
||||
if (!isAuthGenerationCurrent(loadGeneration)) return;
|
||||
if (statusCode === 403) setStatus("forbidden");
|
||||
else if (statusCode === 503) setStatus("unavailable");
|
||||
else setStatus("login");
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
|
||||
if (error instanceof ApiError && error.status === 503) setStatus("unavailable");
|
||||
else setStatus("unavailable");
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [attempt]);
|
||||
|
||||
if (status === "loading") {
|
||||
return <main className="grid min-h-screen place-items-center bg-background" role="status" aria-label="Checking access"><p className="text-sm text-muted-foreground">Checking access…</p></main>;
|
||||
}
|
||||
|
||||
if (status === "authenticated") {
|
||||
return <AuthenticatedContent onExpired={() => setStatus("login")} />;
|
||||
}
|
||||
|
||||
if (status === "unavailable") {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-5" role="status" aria-label="Authentication unavailable">
|
||||
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-7 text-center shadow-md">
|
||||
<p className="thot-label text-primary">ThothII access</p>
|
||||
<h1 className="mt-3 font-heading text-3xl font-semibold">Authentication unavailable</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">The authentication provider could not be reached. Try again in a moment.</p>
|
||||
<Button className="mt-6" onClick={retry}>Retry</Button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "forbidden") {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-5">
|
||||
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-7 text-center shadow-md">
|
||||
<p className="thot-label text-primary">ThothII access</p>
|
||||
<h1 className="mt-3 font-heading text-3xl font-semibold">Access not permitted</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">You are signed in without permission to use this workspace. Contact the installation administrator.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return config ? <LoginPage config={config} onAuthenticated={(_authenticated: AuthenticatedUser) => {
|
||||
setStatus("authenticated");
|
||||
}} onRetry={retry} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import { clearAuthState, setAuthState } from "./authState";
|
||||
import * as authApi from "../api/auth";
|
||||
import { server } from "../test/msw";
|
||||
|
||||
const localConfig = { mode: "local", localLogin: true, oidcLogin: false } as const;
|
||||
const oidcConfig = { mode: "oidc", localLogin: false, oidcLogin: true } as const;
|
||||
const authenticated = {
|
||||
issuer: "local",
|
||||
subject: "admin-1",
|
||||
roles: ["admin"] as const,
|
||||
permissions: ["session.use", "pi.manage"],
|
||||
isAdmin: true,
|
||||
csrfToken: "c".repeat(43),
|
||||
session: {
|
||||
method: "local" as const,
|
||||
remembered: true,
|
||||
idleExpiresAt: "2026-08-17T10:00:00.000Z",
|
||||
absoluteExpiresAt: "2026-09-16T10:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => clearAuthState());
|
||||
afterEach(() => clearAuthState());
|
||||
|
||||
describe("LoginPage", () => {
|
||||
test("shows an unchecked local Remember me control and clears the password after failure", async () => {
|
||||
let submittedPassword = "";
|
||||
server.use(http.post("/api/auth/local/login", async ({ request }) => {
|
||||
const body = await request.json() as { password: string; remember: boolean };
|
||||
submittedPassword = body.password;
|
||||
expect(body.remember).toBe(false);
|
||||
return HttpResponse.json({ code: "invalid_credentials" }, { status: 401 });
|
||||
}));
|
||||
|
||||
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("checkbox", { name: /remember me/i })).not.toBeChecked();
|
||||
await userEvent.type(screen.getByLabelText(/username/i), "alice");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "not-a-real-password");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i);
|
||||
expect(submittedPassword).toBe("not-a-real-password");
|
||||
expect(screen.getByLabelText(/password/i)).toHaveValue("");
|
||||
});
|
||||
|
||||
test("submits a remembered local login once and returns the safe user", async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.post("/api/auth/local/login", async ({ request }) => {
|
||||
requests += 1;
|
||||
const body = await request.json() as { remember: boolean };
|
||||
expect(body.remember).toBe(true);
|
||||
await delay(20);
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.get("/api/me", () => HttpResponse.json(authenticated)),
|
||||
);
|
||||
const onAuthenticated = vi.fn();
|
||||
render(<LoginPage config={localConfig} onAuthenticated={onAuthenticated} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /remember me/i }));
|
||||
await userEvent.type(screen.getByLabelText(/username/i), "admin");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
|
||||
const submit = screen.getByRole("button", { name: /sign in/i });
|
||||
await Promise.all([userEvent.click(submit), userEvent.click(submit)]);
|
||||
|
||||
expect(requests).toBe(1);
|
||||
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ subject: "admin-1" })));
|
||||
expect(screen.getByLabelText(/password/i)).toHaveValue("");
|
||||
});
|
||||
|
||||
test("shows OIDC only when public configuration enables it and uses same-origin navigation", () => {
|
||||
const { rerender } = render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
|
||||
expect(screen.queryByRole("link", { name: /single sign-on/i })).not.toBeInTheDocument();
|
||||
|
||||
rerender(<LoginPage config={oidcConfig} onAuthenticated={vi.fn()} />);
|
||||
expect(screen.getByRole("link", { name: /single sign-on/i })).toHaveAttribute(
|
||||
"href", "/api/auth/oidc/login",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not dispatch local login until an in-flight logout response settles", async () => {
|
||||
let releaseLogout!: () => void;
|
||||
let logoutStarted!: () => void;
|
||||
let loginDispatched = false;
|
||||
const logoutGate = new Promise<void>((resolve) => { releaseLogout = resolve; });
|
||||
const logoutRequest = new Promise<void>((resolve) => { logoutStarted = resolve; });
|
||||
server.use(
|
||||
http.post("/api/auth/logout", async () => {
|
||||
logoutStarted();
|
||||
await logoutGate;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
http.post("/api/auth/local/login", () => {
|
||||
loginDispatched = true;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.get("/api/me", () => HttpResponse.json(authenticated)),
|
||||
);
|
||||
setAuthState({ ...authenticated, subject: "user-a" });
|
||||
const logoutPromise = authApi.logout();
|
||||
await logoutRequest;
|
||||
const loginPromise = authApi.loginLocal("admin", "password", false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(loginDispatched).toBe(false);
|
||||
releaseLogout();
|
||||
await expect(logoutPromise).resolves.toBe(true);
|
||||
await loginPromise;
|
||||
expect(loginDispatched).toBe(true);
|
||||
});
|
||||
|
||||
test("a failed logout response does not deadlock the next local login", async () => {
|
||||
let logoutCalls = 0;
|
||||
let loginDispatched = false;
|
||||
server.use(
|
||||
http.post("/api/auth/logout", () => {
|
||||
logoutCalls += 1;
|
||||
return HttpResponse.json({ code: "auth_unavailable" }, { status: 503 });
|
||||
}),
|
||||
http.post("/api/auth/local/login", () => {
|
||||
loginDispatched = true;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.get("/api/me", () => HttpResponse.json(authenticated)),
|
||||
);
|
||||
setAuthState({ ...authenticated, subject: "user-a" });
|
||||
await expect(authApi.logout()).rejects.toMatchObject({ status: 503 });
|
||||
await authApi.loginLocal("admin", "password", false);
|
||||
expect(logoutCalls).toBe(1);
|
||||
expect(loginDispatched).toBe(true);
|
||||
});
|
||||
|
||||
test("uses explicit provider-unavailable copy with retry affordance", async () => {
|
||||
server.use(http.post("/api/auth/local/login", () => HttpResponse.json(
|
||||
{ code: "auth_unavailable" }, { status: 503 },
|
||||
)));
|
||||
const retry = vi.fn();
|
||||
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} onRetry={retry} />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/username/i), "alice");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/temporarily unavailable/i);
|
||||
await userEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("never writes the password, remember choice, or returned token to browser storage", async () => {
|
||||
server.use(
|
||||
http.post("/api/auth/local/login", () => HttpResponse.json({})),
|
||||
http.get("/api/me", () => HttpResponse.json(authenticated)),
|
||||
);
|
||||
const storageWrites = (["setItem", "removeItem", "clear"] as const).map((method) =>
|
||||
vi.spyOn(Storage.prototype, method));
|
||||
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
|
||||
await userEvent.type(screen.getByLabelText(/username/i), "alice");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await vi.waitFor(() => expect(screen.getByLabelText(/password/i)).toHaveValue(""));
|
||||
for (const write of storageWrites) expect(write).not.toHaveBeenCalled();
|
||||
for (const write of storageWrites) write.mockRestore();
|
||||
});
|
||||
|
||||
test("settles a successful login on the current StrictMode mount", async () => {
|
||||
server.use(
|
||||
http.post("/api/auth/local/login", () => HttpResponse.json({})),
|
||||
http.get("/api/me", () => HttpResponse.json(authenticated)),
|
||||
);
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const onAuthenticated = vi.fn();
|
||||
try {
|
||||
render(<StrictMode><LoginPage config={localConfig} onAuthenticated={onAuthenticated} /></StrictMode>);
|
||||
await userEvent.type(screen.getByLabelText(/username/i), "admin");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledOnce());
|
||||
expect(screen.getByLabelText(/password/i)).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("settles a failed login and clears the password on the current StrictMode mount", async () => {
|
||||
server.use(http.post("/api/auth/local/login", () => HttpResponse.json({ code: "invalid_credentials" }, { status: 401 })));
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
try {
|
||||
render(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
|
||||
await userEvent.type(screen.getByLabelText(/username/i), "admin");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "wrong-password");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i);
|
||||
expect(screen.getByLabelText(/password/i)).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not update an unmounted StrictMode login attempt", async () => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
let settled!: () => void;
|
||||
const settledAfterUnmount = new Promise<void>((resolve) => { settled = resolve; });
|
||||
const loginLocal = vi.spyOn(authApi, "loginLocal").mockImplementation(async () => {
|
||||
try {
|
||||
await pending;
|
||||
throw new Error("deferred invalid credentials");
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
});
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
try {
|
||||
const view = render(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
|
||||
await userEvent.type(screen.getByLabelText(/username/i), "admin");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "wrong-password");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
view.unmount();
|
||||
release();
|
||||
await act(async () => { await settledAfterUnmount; });
|
||||
expect(loginLocal).toHaveBeenCalledOnce();
|
||||
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/can't perform a react state update|not wrapped in act/i);
|
||||
} finally {
|
||||
loginLocal.mockRestore();
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { AlertTriangle, ArrowRight, LockKeyhole } from "lucide-react";
|
||||
import { ApiError } from "../api/client";
|
||||
import { loginLocal } from "../api/auth";
|
||||
import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
interface LoginPageProps {
|
||||
config: AuthPublicConfig;
|
||||
onAuthenticated: (user: AuthenticatedUser) => void;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
function loginError(error: unknown): { message: string; retry: boolean } {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
return { message: "Authentication is temporarily unavailable. Try again.", retry: true };
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
return { message: "This sign-in request was rejected. Open ThothII from its configured address and try again.", retry: false };
|
||||
}
|
||||
return { message: "Invalid username or password.", retry: false };
|
||||
}
|
||||
|
||||
export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) {
|
||||
const localLogin = config.mode === "local" && config.localLogin;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const submittingRef = useRef(false);
|
||||
const attemptRef = useRef(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; retry: boolean }>();
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => { mountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submittingRef.current) return;
|
||||
const form = event.currentTarget;
|
||||
const values = new FormData(form);
|
||||
const username = String(values.get("username") ?? "");
|
||||
const password = String(values.get("password") ?? "");
|
||||
const remember = values.get("remember") === "on";
|
||||
const attempt = ++attemptRef.current;
|
||||
submittingRef.current = true;
|
||||
setError(undefined);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const user = await loginLocal(username, password, remember);
|
||||
if (mountedRef.current && attemptRef.current === attempt) onAuthenticated(user);
|
||||
} catch (failure) {
|
||||
if (mountedRef.current && attemptRef.current === attempt) setError(loginError(failure));
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
if (!mountedRef.current || attemptRef.current !== attempt) return;
|
||||
// Passwords never enter React state and are cleared after every attempt,
|
||||
// including operational failures and successful authentication.
|
||||
form.reset();
|
||||
setSubmitting(false);
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 sm:py-12">
|
||||
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-5xl items-center gap-12 lg:grid-cols-[minmax(0,1fr)_26rem]">
|
||||
<section className="hidden max-w-xl lg:block">
|
||||
<p className="thot-label text-primary">Secure re-entry · governed workspace</p>
|
||||
<h1 className="mt-4 max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">
|
||||
Return to the analytical ledger.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">
|
||||
ThothII keeps each question, review decision, and SQL artifact inside a traceable human-in-the-loop workflow.
|
||||
</p>
|
||||
<div className="mt-10 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="h-px w-16 bg-primary" aria-hidden="true" />
|
||||
<span>Access is checked before the workspace opens.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-md sm:p-8">
|
||||
<div className="mb-7">
|
||||
<div className="flex items-center gap-2 text-primary" aria-hidden="true">
|
||||
<LockKeyhole className="size-4" />
|
||||
<span className="thot-label text-primary">ThothII access</span>
|
||||
</div>
|
||||
<h2 className="mt-3 font-heading text-3xl font-semibold tracking-tight">Sign in to ThothII</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">Use your installation account to continue.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="mb-5 grid gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<p className="flex items-start gap-2 leading-5"><AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />{error.message}</p>
|
||||
{error.retry && onRetry && (
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetry}>Retry</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localLogin && (
|
||||
<form ref={formRef} onSubmit={submit} className="grid gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor="thothii-username" className="text-sm font-semibold">Username</label>
|
||||
<input
|
||||
id="thothii-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor="thothii-password" className="text-sm font-semibold">Password</label>
|
||||
<input
|
||||
ref={passwordRef}
|
||||
id="thothii-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input name="remember" type="checkbox" className="size-4 accent-[oklch(var(--primary))]" />
|
||||
<span>Remember me for 30 days</span>
|
||||
</label>
|
||||
<Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting}>
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
{!submitting && <ArrowRight aria-hidden="true" />}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{config.oidcLogin && (
|
||||
<a
|
||||
href="/api/auth/oidc/login"
|
||||
className="mt-4 inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border border-border bg-card px-4 text-sm font-semibold shadow-xs outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/25"
|
||||
>
|
||||
Continue with single sign-on
|
||||
<ArrowRight aria-hidden="true" className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{!localLogin && !config.oidcLogin && (
|
||||
<p role="status" className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||
No browser sign-in method is enabled for this installation.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, expect, test } from "vitest";
|
||||
import { clearAuthState, setAuthState } from "./authState";
|
||||
import { captureAuthOperation, isAuthOperationCurrent } from "./authOperation";
|
||||
|
||||
const userA = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const,
|
||||
permissions: ["session.use"], isAdmin: false, csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearAuthState();
|
||||
setAuthState(userA);
|
||||
});
|
||||
|
||||
test("captures identity, generation, session target, and disposal epoch", () => {
|
||||
const operation = captureAuthOperation({ sessionId: "s1", disposalEpoch: 4 });
|
||||
|
||||
expect(operation).toMatchObject({
|
||||
authGeneration: expect.any(Number),
|
||||
issuer: "local",
|
||||
subject: "user-a",
|
||||
sessionId: "s1",
|
||||
disposalEpoch: 4,
|
||||
});
|
||||
expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 4 })).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a changed identity, target session, or disposal epoch", () => {
|
||||
const operation = captureAuthOperation({ sessionId: "s1", disposalEpoch: 4 });
|
||||
setAuthState({ ...userA, subject: "user-b", csrfToken: "b".repeat(43) });
|
||||
|
||||
expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 4 })).toBe(false);
|
||||
expect(isAuthOperationCurrent(operation!, { sessionId: "s2", disposalEpoch: 4 })).toBe(false);
|
||||
expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("does not start an authenticated operation without a principal", () => {
|
||||
clearAuthState();
|
||||
expect(captureAuthOperation()).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getAuthGeneration, getAuthState } from "./authState";
|
||||
|
||||
export type AuthOperationGuard = Readonly<{
|
||||
authGeneration: number;
|
||||
issuer: string;
|
||||
subject: string;
|
||||
sessionId: string | null;
|
||||
disposalEpoch: number;
|
||||
}>;
|
||||
|
||||
export type AuthOperationPrecondition = Readonly<{
|
||||
operation: AuthOperationGuard;
|
||||
isCurrent: () => boolean;
|
||||
}>;
|
||||
|
||||
export class StaleAuthOperationError extends Error {
|
||||
constructor() {
|
||||
super("The authenticated operation is no longer current");
|
||||
this.name = "StaleAuthOperationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function captureAuthOperation(options: {
|
||||
sessionId?: string | null;
|
||||
disposalEpoch?: number;
|
||||
} = {}): AuthOperationGuard | null {
|
||||
const user = getAuthState();
|
||||
if (!user) return null;
|
||||
return {
|
||||
authGeneration: getAuthGeneration(),
|
||||
issuer: user.issuer,
|
||||
subject: user.subject,
|
||||
sessionId: options.sessionId ?? null,
|
||||
disposalEpoch: options.disposalEpoch ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAuthOperationCurrent(
|
||||
operation: AuthOperationGuard | null,
|
||||
options: { sessionId?: string | null; disposalEpoch: number },
|
||||
): boolean {
|
||||
if (!operation) return false;
|
||||
const user = getAuthState();
|
||||
return Boolean(
|
||||
user
|
||||
&& getAuthGeneration() === operation.authGeneration
|
||||
&& user.issuer === operation.issuer
|
||||
&& user.subject === operation.subject
|
||||
&& operation.sessionId === (options.sessionId ?? null)
|
||||
&& operation.disposalEpoch === options.disposalEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
export function requireCurrentAuthOperation(
|
||||
operation: AuthOperationGuard | null,
|
||||
options: { sessionId?: string | null; disposalEpoch: number },
|
||||
): asserts operation is AuthOperationGuard {
|
||||
if (!isAuthOperationCurrent(operation, options)) throw new StaleAuthOperationError();
|
||||
}
|
||||
|
||||
export function requireAuthOperationPrecondition(
|
||||
precondition: AuthOperationPrecondition | undefined,
|
||||
): void {
|
||||
if (precondition && !precondition.isCurrent()) throw new StaleAuthOperationError();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { apiFetch } from "../api/client";
|
||||
import { queryClient } from "../app/queryClient";
|
||||
import { server } from "../test/msw";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { clearAuthState, getAuthState, setAuthState } from "./authState";
|
||||
|
||||
const userA = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
|
||||
csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
useSessionStore.getState().resetSession();
|
||||
clearAuthState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
useSessionStore.getState().resetSession();
|
||||
clearAuthState();
|
||||
});
|
||||
|
||||
function seedUserAData() {
|
||||
setAuthState(userA);
|
||||
queryClient.setQueryData(["sessions", "mine"], [{ id: "a-session" }]);
|
||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: "A transcript" });
|
||||
}
|
||||
|
||||
test("an ordinary API 401 scrubs A data before B can log in", async () => {
|
||||
server.use(http.get("/api/ordinary-expiry", () => new HttpResponse(null, { status: 401 })));
|
||||
seedUserAData();
|
||||
|
||||
await expect(apiFetch("/ordinary-expiry")).rejects.toMatchObject({ status: 401 });
|
||||
expect(getAuthState()).toBeNull();
|
||||
expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined();
|
||||
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||
|
||||
setAuthState(userB);
|
||||
expect(getAuthState()).toMatchObject({ subject: "user-b" });
|
||||
expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined();
|
||||
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||
});
|
||||
|
||||
test("logout followed by B login cannot retain A cache or live transcript", () => {
|
||||
seedUserAData();
|
||||
|
||||
clearAuthState();
|
||||
setAuthState(userB);
|
||||
|
||||
expect(getAuthState()).toMatchObject({ subject: "user-b" });
|
||||
expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined();
|
||||
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { AuthenticatedUser } from "../api/types";
|
||||
import { queryClient } from "../app/queryClient";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
|
||||
let current: AuthenticatedUser | null = null;
|
||||
let generation = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function scrubUserBoundState(): void {
|
||||
queryClient.clear();
|
||||
useSessionStore.getState().resetSession();
|
||||
}
|
||||
|
||||
/** Authentication is intentionally process-local; no browser storage is involved. */
|
||||
export function getAuthState(): AuthenticatedUser | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setAuthState(user: AuthenticatedUser): void {
|
||||
scrubUserBoundState();
|
||||
current = user;
|
||||
generation += 1;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function clearAuthState(): void {
|
||||
scrubUserBoundState();
|
||||
current = null;
|
||||
generation += 1;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function isAuthGenerationCurrent(expectedGeneration: number): boolean {
|
||||
return generation === expectedGeneration;
|
||||
}
|
||||
|
||||
export function clearAuthStateIfCurrent(expectedGeneration: number): boolean {
|
||||
if (!isAuthGenerationCurrent(expectedGeneration)) return false;
|
||||
clearAuthState();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getAuthGeneration(): number {
|
||||
return generation;
|
||||
}
|
||||
|
||||
export function subscribeAuthState(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useAuthState(): AuthenticatedUser | null {
|
||||
return useSyncExternalStore(subscribeAuthState, getAuthState, getAuthState);
|
||||
}
|
||||
|
||||
export function useAuthUser(): AuthenticatedUser | null {
|
||||
return useAuthState();
|
||||
}
|
||||
|
||||
export function useAuthGeneration(): number {
|
||||
return useSyncExternalStore(subscribeAuthState, getAuthGeneration, getAuthGeneration);
|
||||
}
|
||||
|
||||
export function hasPermission(user: Pick<AuthenticatedUser, "permissions"> | null | undefined, permission: string): boolean {
|
||||
return user?.permissions.includes(permission) ?? false;
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, useLayoutEffect } from "react";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { createStreamEventCoalescer, useSessionStream } from "./useSessionStream";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "../auth/authState";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
|
||||
beforeEach(() => {
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
clearAuthState();
|
||||
});
|
||||
|
||||
test("coalesces a burst of text deltas into bounded store updates", () => {
|
||||
@@ -32,6 +36,73 @@ test("coalesces a burst of text deltas into bounded store updates", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("disposal discards buffered deltas instead of flushing them", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const applied: Array<{ type: string; text?: string }> = [];
|
||||
const coalescer = createStreamEventCoalescer((event) => applied.push(event), 100);
|
||||
|
||||
coalescer.push({ type: "text_delta", text: "a" });
|
||||
coalescer.push({ type: "text_delta", text: "b" });
|
||||
coalescer.dispose();
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(applied).toEqual([{ type: "text_delta", text: "a" }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a stale coalescer timer checks source validity before applying text", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let current = true;
|
||||
const applied: Array<{ type: string; text?: string }> = [];
|
||||
const coalescer = createStreamEventCoalescer((event) => applied.push(event), 100, () => current);
|
||||
|
||||
coalescer.push({ type: "text_delta", text: "a" });
|
||||
coalescer.push({ type: "text_delta", text: "b" });
|
||||
current = false;
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(applied).toEqual([{ type: "text_delta", text: "a" }]);
|
||||
coalescer.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("buffered user-A text cannot reappear after logout and user-B login", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const userA = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
|
||||
csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
|
||||
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 })));
|
||||
setAuthState(userA);
|
||||
const { rerender } = renderHook(
|
||||
({ authGeneration }) => useSessionStream("s1", 0, 0, true, authGeneration),
|
||||
{ initialProps: { authGeneration: getAuthGeneration() } },
|
||||
);
|
||||
const sourceA = FakeEventSource.instances[0];
|
||||
act(() => {
|
||||
sourceA.emit({ type: "text_delta", text: "A-one" });
|
||||
sourceA.emit({ type: "text_delta", text: "A-two" });
|
||||
sourceA.onerror?.(new Event("error"));
|
||||
setAuthState(userB);
|
||||
});
|
||||
rerender({ authGeneration: getAuthGeneration() });
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||
expect(FakeEventSource.instances[0].closed).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("flushes pending stream text before a structural event", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -280,3 +351,130 @@ test("the old source is invalid before later layout effects can deliver a queued
|
||||
expect(first.closed).toBe(true);
|
||||
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||
});
|
||||
|
||||
test("auth-generation changes close the old source, reset live state, and do not reconnect after logout", () => {
|
||||
setAuthState({
|
||||
issuer: "local", subject: "user-1", roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
||||
csrfToken: "c".repeat(43), session: null,
|
||||
});
|
||||
const { rerender } = renderHook(
|
||||
({ authenticated, authGeneration }) => useSessionStream("s1", 0, 0, authenticated, authGeneration),
|
||||
{ initialProps: { authenticated: true, authGeneration: getAuthGeneration() } },
|
||||
);
|
||||
const first = FakeEventSource.instances[0];
|
||||
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "stale" }));
|
||||
expect(useSessionStore.getState().transcript).toHaveLength(1);
|
||||
|
||||
clearAuthState();
|
||||
rerender({ authenticated: false, authGeneration: getAuthGeneration() });
|
||||
|
||||
expect(first.closed).toBe(true);
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||
});
|
||||
|
||||
test("a stale source error cannot clear or mutate the next user's live session", async () => {
|
||||
const userA = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
|
||||
csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
|
||||
setAuthState(userA);
|
||||
const { rerender } = renderHook(
|
||||
({ authenticated, authGeneration }) => useSessionStream("s1", 0, 0, authenticated, authGeneration),
|
||||
{ initialProps: { authenticated: true, authGeneration: getAuthGeneration() } },
|
||||
);
|
||||
const sourceA = FakeEventSource.instances[0];
|
||||
|
||||
act(() => setAuthState(userB));
|
||||
rerender({ authenticated: true, authGeneration: getAuthGeneration() });
|
||||
const sourceB = FakeEventSource.instances[1];
|
||||
act(() => sourceB.emitNamed("text_delta", { type: "text_delta", text: "B-data" }));
|
||||
|
||||
act(() => sourceA.onerror?.(new Event("error")));
|
||||
|
||||
expect((await import("../auth/authState")).getAuthState()).toMatchObject({ subject: "user-b" });
|
||||
expect(sourceB.closed).toBe(false);
|
||||
expect(FakeEventSource.instances).toHaveLength(2);
|
||||
expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B-data" }]);
|
||||
});
|
||||
|
||||
test("a native current-source error probes /me once and clears auth on a guarded 401", async () => {
|
||||
let probes = 0;
|
||||
server.use(http.get("/api/me", () => {
|
||||
probes += 1;
|
||||
return new HttpResponse(null, { status: 401 });
|
||||
}));
|
||||
setAuthState({
|
||||
issuer: "local", subject: "user-a", roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
||||
csrfToken: "a".repeat(43), session: null,
|
||||
});
|
||||
renderHook(() => useSessionStream("s1", 0, 0, true, getAuthGeneration()));
|
||||
const source = FakeEventSource.instances[0];
|
||||
|
||||
act(() => {
|
||||
source.onerror?.(new Event("error"));
|
||||
source.onerror?.(new Event("error"));
|
||||
});
|
||||
await waitFor(() => expect(getAuthState()).toBeNull());
|
||||
|
||||
expect(probes).toBe(1);
|
||||
expect((await import("../auth/authState")).getAuthState()).toBeNull();
|
||||
});
|
||||
|
||||
test("a native error keeps current auth connected after a 200 /me probe and does not storm", async () => {
|
||||
let probes = 0;
|
||||
const user = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
|
||||
csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
server.use(http.get("/api/me", () => {
|
||||
probes += 1;
|
||||
return HttpResponse.json(user);
|
||||
}));
|
||||
setAuthState(user);
|
||||
const result = renderHook(() => useSessionStream("s1", 0, 0, true, getAuthGeneration()));
|
||||
const source = FakeEventSource.instances[0];
|
||||
|
||||
act(() => source.onerror?.(new Event("error")));
|
||||
await waitFor(() => expect(result.result.current.connected).toBe(true));
|
||||
act(() => source.onerror?.(new Event("error")));
|
||||
|
||||
expect(probes).toBe(1);
|
||||
expect(getAuthState()).toMatchObject({ subject: "user-a" });
|
||||
});
|
||||
|
||||
test("a stale source does not probe and a user-B login during an A probe cannot mutate B", async () => {
|
||||
let release!: () => void;
|
||||
let probes = 0;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const userA = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
|
||||
csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
|
||||
server.use(http.get("/api/me", async () => {
|
||||
probes += 1;
|
||||
await held;
|
||||
return HttpResponse.json(userA);
|
||||
}));
|
||||
setAuthState(userA);
|
||||
const { rerender } = renderHook(
|
||||
({ sessionId, authGeneration }) => useSessionStream(sessionId, 0, 0, true, authGeneration),
|
||||
{ initialProps: { sessionId: "s1" as string | null, authGeneration: getAuthGeneration() } },
|
||||
);
|
||||
const sourceA = FakeEventSource.instances[0];
|
||||
act(() => sourceA.onerror?.(new Event("error")));
|
||||
await waitFor(() => expect(probes).toBe(1));
|
||||
|
||||
act(() => setAuthState(userB));
|
||||
rerender({ sessionId: "s2", authGeneration: getAuthGeneration() });
|
||||
const sourceB = FakeEventSource.instances[1];
|
||||
act(() => sourceA.onerror?.(new Event("error")));
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(probes).toBe(1);
|
||||
expect(getAuthState()).toMatchObject({ subject: "user-b" });
|
||||
expect(sourceB.closed).toBe(false);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { BASE } from "../api/client";
|
||||
import { joinBackendPath } from "../api/runtime-config";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import type { StreamEvent } from "../api/types";
|
||||
import {
|
||||
getAuthGeneration,
|
||||
isAuthGenerationCurrent,
|
||||
} from "../auth/authState";
|
||||
import { getMe } from "../api/auth";
|
||||
import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
|
||||
|
||||
const STREAM_UPDATE_INTERVAL_MS = 100;
|
||||
|
||||
@@ -15,6 +21,7 @@ function isStreamingDelta(
|
||||
export function createStreamEventCoalescer(
|
||||
applyEvent: (event: StreamEvent) => void,
|
||||
intervalMs = STREAM_UPDATE_INTERVAL_MS,
|
||||
canApply: () => boolean = () => true,
|
||||
) {
|
||||
let pending: Extract<StreamEvent, { type: "text_delta" | "activity_delta" }> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -24,6 +31,7 @@ export function createStreamEventCoalescer(
|
||||
if (!pending) return;
|
||||
const event = pending;
|
||||
pending = null;
|
||||
if (disposed || !canApply()) return;
|
||||
applyEvent(event);
|
||||
};
|
||||
|
||||
@@ -31,14 +39,18 @@ export function createStreamEventCoalescer(
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (!pending || disposed) return;
|
||||
if (!canApply()) {
|
||||
pending = null;
|
||||
return;
|
||||
}
|
||||
flushPending();
|
||||
schedule();
|
||||
if (pending && !disposed && canApply()) schedule();
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
return {
|
||||
push(event: StreamEvent) {
|
||||
if (disposed) return;
|
||||
if (disposed || !canApply()) return;
|
||||
if (!isStreamingDelta(event)) {
|
||||
flushPending();
|
||||
if (timer) clearTimeout(timer);
|
||||
@@ -60,7 +72,7 @@ export function createStreamEventCoalescer(
|
||||
if (disposed) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
flushPending();
|
||||
pending = null;
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
@@ -70,24 +82,33 @@ export function useSessionStream(
|
||||
sessionId: string | null,
|
||||
generation = 0,
|
||||
cursorResetEpoch = 0,
|
||||
authenticated = true,
|
||||
authGeneration?: number,
|
||||
) {
|
||||
const effectiveAuthGeneration = authGeneration ?? getAuthGeneration();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const applyEvent = useSessionStore((s) => s.applyEvent);
|
||||
const resetSession = useSessionStore((s) => s.resetSession);
|
||||
const cursor = useRef({
|
||||
sessionId: null as string | null,
|
||||
cursorResetEpoch,
|
||||
authGeneration: null as number | null,
|
||||
lastEventId: "",
|
||||
});
|
||||
const activeSource = useRef<object | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const authChanged = cursor.current.authGeneration !== null
|
||||
&& cursor.current.authGeneration !== effectiveAuthGeneration;
|
||||
if (
|
||||
cursor.current.sessionId !== sessionId
|
||||
|| cursor.current.cursorResetEpoch !== cursorResetEpoch
|
||||
|| authChanged
|
||||
) {
|
||||
cursor.current = { sessionId, cursorResetEpoch, lastEventId: "" };
|
||||
cursor.current = { sessionId, cursorResetEpoch, authGeneration: effectiveAuthGeneration, lastEventId: "" };
|
||||
if (authChanged) resetSession();
|
||||
}
|
||||
if (!sessionId) {
|
||||
if (!sessionId || !authenticated) {
|
||||
activeSource.current = null;
|
||||
setConnected(false);
|
||||
return;
|
||||
@@ -96,19 +117,40 @@ export function useSessionStream(
|
||||
const query = cursor.current.lastEventId
|
||||
? `?lastEventId=${encodeURIComponent(cursor.current.lastEventId)}`
|
||||
: "";
|
||||
const sourceAuthGeneration = effectiveAuthGeneration;
|
||||
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events${query}`));
|
||||
const identity = { source: es, sessionId, cursorResetEpoch };
|
||||
const coalescer = createStreamEventCoalescer(applyEvent);
|
||||
let authProbeStarted = false;
|
||||
const isCurrentSource = () => activeSource.current === identity
|
||||
&& isAuthGenerationCurrent(sourceAuthGeneration);
|
||||
const probeAuth = async () => {
|
||||
if (authProbeStarted || !isCurrentSource()) return;
|
||||
authProbeStarted = true;
|
||||
const operation = captureAuthOperation({ sessionId, disposalEpoch: 0 });
|
||||
if (!operation) return;
|
||||
try {
|
||||
await getMe();
|
||||
if (!isCurrentSource() || !isAuthOperationCurrent(operation, { sessionId, disposalEpoch: 0 })) return;
|
||||
setConnected(true);
|
||||
} catch {
|
||||
if (!isCurrentSource() || !isAuthOperationCurrent(operation, { sessionId, disposalEpoch: 0 })) return;
|
||||
setConnected(false);
|
||||
}
|
||||
};
|
||||
const coalescer = createStreamEventCoalescer(applyEvent, STREAM_UPDATE_INTERVAL_MS, isCurrentSource);
|
||||
activeSource.current = identity;
|
||||
es.onopen = () => {
|
||||
if (activeSource.current === identity) setConnected(true);
|
||||
if (!isCurrentSource()) { es.close(); return; }
|
||||
setConnected(true);
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (activeSource.current === identity) setConnected(false);
|
||||
if (!isCurrentSource()) { es.close(); return; }
|
||||
setConnected(false);
|
||||
void probeAuth();
|
||||
};
|
||||
|
||||
const handle = (ev: MessageEvent) => {
|
||||
if (activeSource.current !== identity) return;
|
||||
if (!isCurrentSource()) return;
|
||||
if (ev.lastEventId) cursor.current.lastEventId = ev.lastEventId;
|
||||
try {
|
||||
coalescer.push(JSON.parse(ev.data) as StreamEvent);
|
||||
@@ -138,10 +180,12 @@ export function useSessionStream(
|
||||
coalescer.dispose();
|
||||
if (activeSource.current === identity) {
|
||||
activeSource.current = null;
|
||||
setConnected(false);
|
||||
if (getAuthGeneration() === sourceAuthGeneration) {
|
||||
setConnected(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [sessionId, generation, cursorResetEpoch, applyEvent]);
|
||||
}, [sessionId, generation, cursorResetEpoch, authenticated, effectiveAuthGeneration, applyEvent, resetSession]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export class FakeEventSource {
|
||||
static instances: FakeEventSource[] = [];
|
||||
onmessage: ((e: { data: string; lastEventId: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onerror: ((event?: unknown) => void) | null = null;
|
||||
closed = false;
|
||||
private listeners = new Map<string, Set<(e: { data: string; lastEventId: string }) => void>>();
|
||||
private historicalListeners = new Map<
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { setupServer } from "msw/node";
|
||||
import { http, HttpResponse } from "msw";
|
||||
export const server = setupServer(
|
||||
http.get("/api/auth/config", () => HttpResponse.json({ mode: "local", localLogin: true, oidcLogin: false })),
|
||||
http.get("/api/me", () => HttpResponse.json({
|
||||
issuer: "mock", subject: "test-user", displayName: "Test user", roles: ["admin"],
|
||||
permissions: [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||
], isAdmin: true, csrfToken: "c".repeat(43), session: null,
|
||||
})),
|
||||
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user