Add full shell, replaceable Omics adapter and bilingual interaction

Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
+64 -5
View File
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, 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";
@@ -6,12 +6,16 @@ import { StrictMode } from "react";
import { AuthGate } from "./AuthGate";
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "./authState";
import { server } from "../test/msw";
import { apiFetch } from "../api/client";
import { queryClient } from "../app/queryClient";
import { useSessionStore } from "../store/sessionStore";
import { checkAccess } from "./checkAccess";
vi.mock("../shell/AppShell", () => ({
AppShell: () => (
AppShell: ({ canLogout }: { canLogout: boolean }) => (
<div data-testid="authenticated-shell">
Authenticated shell
<button type="button" onClick={() => { window.dispatchEvent(new Event("test-logout")); }}>Log out</button>
{canLogout && <button type="button" onClick={() => { window.dispatchEvent(new Event("test-logout")); }}>Log out</button>}
</div>
),
}));
@@ -35,6 +39,7 @@ const user = {
const localConfig = { mode: "local", localLogin: true, oidcLogin: false };
beforeEach(() => {
window.__THOTHII_CONFIG__ = { shell: { mode: "full", defaultLocale: "en" } };
clearAuthState();
server.use(
http.get("/api/auth/config", () => HttpResponse.json(localConfig)),
@@ -45,9 +50,63 @@ beforeEach(() => {
afterEach(() => {
cleanup();
clearAuthState();
delete window.__THOTHII_CONFIG__;
});
describe("AuthGate", () => {
test("embedded uses only /me and never offers a second login", async () => {
delete window.__THOTHII_CONFIG__;
const configRequest = vi.fn();
server.use(http.get("/api/auth/config", () => { configRequest(); return HttpResponse.json(localConfig); }));
const view = render(<AuthGate />);
await screen.findByTestId("authenticated-shell");
expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument();
expect(configRequest).not.toHaveBeenCalled();
view.unmount();
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 })));
render(<AuthGate />);
await screen.findByRole("heading", { name: "Portal access required" });
expect(screen.queryByLabelText("Password")).not.toBeInTheDocument();
});
test("full OIDC exposes the existing session logout", async () => {
server.use(http.get("/api/auth/config", () => HttpResponse.json({ mode: "oidc", localLogin: false, oidcLogin: true })));
render(<AuthGate />);
expect(await screen.findByRole("button", { name: "Log out" })).toBeInTheDocument();
});
test("an operation 403 retains access, while a page-return /me 403 scrubs protected data", async () => {
delete window.__THOTHII_CONFIG__;
server.use(http.get("/api/denied-operation", () => new HttpResponse(null, { status: 403 })));
render(<AuthGate />);
await screen.findByTestId("authenticated-shell");
queryClient.setQueryData(["protected"], { secret: "previous user data" });
useSessionStore.getState().applyEvent({ type: "text_delta", text: "Private transcript" });
await expect(apiFetch("/denied-operation")).rejects.toMatchObject({ status: 403 });
expect(getAuthState()).not.toBeNull();
expect(screen.getByTestId("authenticated-shell")).toBeInTheDocument();
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 403 })));
act(() => { window.dispatchEvent(new Event("focus")); });
await waitFor(() => expect(screen.queryByTestId("authenticated-shell")).not.toBeInTheDocument());
expect(queryClient.getQueryData(["protected"])).toBeUndefined();
expect(useSessionStore.getState().transcript).toEqual([]);
expect(screen.queryByLabelText("Password")).not.toBeInTheDocument();
});
test("the reconnect probe revokes /me denial and preserves work when only expiry changes", async () => {
setAuthState(user);
const generation = getAuthGeneration();
queryClient.setQueryData(["protected"], "kept");
server.use(http.get("/api/me", () => HttpResponse.json({ ...user, session: { ...user.session, idleExpiresAt: "2026-09-13T12:00:00.000Z" } })));
await checkAccess();
expect(getAuthGeneration()).toBe(generation);
expect(queryClient.getQueryData(["protected"])).toBe("kept");
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 403 })));
await expect(checkAccess()).rejects.toMatchObject({ status: 403 });
expect(getAuthState()).toBeNull();
expect(queryClient.getQueryData(["protected"])).toBeUndefined();
});
test("shows a loading state while /me is unresolved", async () => {
server.use(http.get("/api/me", async () => {
await delay(100);
@@ -88,8 +147,8 @@ describe("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);
expect(getAuthState()).toBeNull();
expect(getAuthGeneration()).toBeGreaterThan(generation);
});
test("offers retry when the authentication provider is unavailable", async () => {
+81 -86
View File
@@ -1,122 +1,117 @@
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 type { AuthPublicConfig } from "../api/types";
import { resolveShellConfig } from "../api/runtime-config";
import { AppShell } from "../shell/AppShell";
import { FullHeader } from "../shell/host/FullHeader";
import { useShell } from "../shell/host/ShellProvider";
import {
clearAuthStateIfCurrent,
getAuthGeneration,
getAuthState,
isAuthGenerationCurrent,
setAuthState,
useAuthGeneration,
useAuthUser,
clearAuthStateIfCurrent, getAuthGeneration, getAuthState,
isAuthGenerationCurrent, setAuthState, useAuthGeneration, useAuthUser,
} from "./authState";
import { checkAccess } from "./checkAccess";
import { LoginPage } from "./LoginPage";
import { Button } from "../components/ui/button";
import { useI18n } from "../i18n";
type GateStatus = "loading" | "login" | "authenticated" | "forbidden" | "unavailable";
function AuthenticatedContent({
canLogout,
onExpired,
}: {
canLogout: boolean;
onExpired: () => void;
}) {
function AuthenticatedContent({ canLogout, onExpired }: { canLogout: boolean; onExpired: () => void }) {
const user = useAuthUser();
const authGeneration = useAuthGeneration();
useEffect(() => {
if (!user) onExpired();
}, [onExpired, user]);
return user
? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} canLogout={canLogout} />
: null;
useEffect(() => { if (!user) onExpired(); }, [onExpired, user]);
return user ? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} canLogout={canLogout} /> : null;
}
export function AuthGate() {
const { t } = useI18n();
const shell = useShell();
const embedded = (shell?.mode ?? resolveShellConfig().mode) === "embedded";
const [status, setStatus] = useState<GateStatus>("loading");
const [config, setConfig] = useState<AuthPublicConfig>();
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => setAttempt((value) => value + 1), []);
const retry = useCallback(() => setAttempt(value => value + 1), []);
const onExpired = useCallback(() => setStatus("login"), []);
useEffect(() => {
let cancelled = false;
const load = async () => {
setStatus("loading");
const loadGeneration = getAuthGeneration();
const generation = 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");
// Embedded authentication is already established by the portal.
if (!embedded) {
const publicConfig = await getAuthConfig();
if (cancelled || !isAuthGenerationCurrent(generation)) return;
setConfig(publicConfig);
}
const user = await getMe();
if (cancelled || !isAuthGenerationCurrent(generation)) return;
setAuthState(user);
setStatus("authenticated");
} catch (error) {
if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
if (error instanceof ApiError && error.status === 503) setStatus("unavailable");
else setStatus("unavailable");
if (cancelled) return;
const code = authErrorStatus(error);
if (code === 401 || code === 403) {
clearAuthStateIfCurrent(generation);
if (!getAuthState()) setStatus(code === 403 ? "forbidden" : "login");
} else if (isAuthGenerationCurrent(generation)) setStatus("unavailable");
}
};
void load();
return () => { cancelled = true; };
}, [attempt]);
}, [attempt, embedded]);
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>;
}
useEffect(() => {
let disposed = false;
let pending = false;
const recheck = async () => {
if (pending || document.visibilityState === "hidden" || status !== "authenticated") return;
pending = true;
try { await checkAccess(); }
catch (error) {
if (!disposed && !getAuthState()) {
const code = authErrorStatus(error);
if (code === 401 || code === 403) setStatus(code === 403 ? "forbidden" : "login");
}
} finally { pending = false; }
};
window.addEventListener("focus", recheck);
window.addEventListener("pageshow", recheck);
document.addEventListener("visibilitychange", recheck);
return () => {
disposed = true;
window.removeEventListener("focus", recheck);
window.removeEventListener("pageshow", recheck);
document.removeEventListener("visibilitychange", recheck);
};
}, [status]);
if (status === "authenticated") {
return (
<AuthenticatedContent
canLogout={config?.mode === "local"}
onExpired={() => setStatus("login")}
/>
);
return <AuthenticatedContent canLogout={!embedded && (config?.mode === "local" || config?.mode === "oidc")} onExpired={onExpired} />;
}
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>
if (status === "loading") {
return <main className="thot-access-state" role="status" aria-label={t("Checking access")}><p>{t("Checking access…")}</p></main>;
}
const unavailable = status === "unavailable";
const forbidden = status === "forbidden";
const title = unavailable ? "Authentication unavailable" : forbidden ? "Access not permitted" : "Portal access required";
const description = unavailable
? "The authentication provider could not be reached. Try again in a moment."
: embedded
? "Your portal access is no longer available. Return to the portal to sign in or request access, then reopen ThothII."
: "You are signed in without permission to use this workspace. Contact the installation administrator.";
return <>
{!embedded && <FullHeader />}
{status === "login" && !embedded && config
? <LoginPage config={config} onAuthenticated={() => setStatus("authenticated")} onRetry={retry} />
: <main className="thot-access-state" role="status" aria-label={t(title)}>
<section className="w-full max-w-md text-center">
<p className="thot-label text-primary">{t("ThothII access")}</p>
<h1 className="mt-3 font-heading text-3xl font-semibold">{t(title)}</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{t(description)}</p>
<Button className="mt-6" onClick={retry}>{t("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;
</main>}
</>;
}
+17 -21
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { AlertTriangle, ArrowRight, Eye, EyeOff, LockKeyhole } from "lucide-react";
@@ -23,6 +24,7 @@ function loginError(error: unknown): { message: string; retry: boolean } {
}
export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) {
const { t } = useI18n();
const localLogin = config.mode === "local" && config.localLogin;
const oidcLogin = config.mode === "oidc" && config.oidcLogin;
const formRef = useRef<HTMLFormElement>(null);
@@ -77,27 +79,25 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
<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">
<h1 className="max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">
From intent to SQL, with a human in the loop
</h1>
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">AI generates, you guide and approve.</p>
<h1 className="max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">{t("From intent to SQL, with a human in the loop")}</h1>
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">{t("AI generates, you guide and approve.")}</p>
</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>
<span className="thot-label text-primary">{t("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>
<h2 className="mt-3 font-heading text-3xl font-semibold tracking-tight">{t("Sign in to ThothII")}</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{t("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>
<p className="flex items-start gap-2 leading-5"><AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />{t(error.message)}</p>
{error.retry && onRetry && (
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetry}>Retry</Button>
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetry}>{t("Retry")}</Button>
)}
</div>
)}
@@ -105,7 +105,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
{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>
<label htmlFor="thothii-username" className="text-sm font-semibold">{t("Username")}</label>
<input
id="thothii-username"
name="username"
@@ -118,7 +118,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="thothii-password" className="text-sm font-semibold">Password</label>
<label htmlFor="thothii-password" className="text-sm font-semibold">{t("Password")}</label>
<div className="relative">
<input
ref={passwordRef}
@@ -134,9 +134,9 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "Hide password" : "Show password"}
aria-label={showPassword ? t("Hide password") : t("Show password")}
aria-controls="thothii-password"
title={showPassword ? "Hide password" : "Show password"}
title={showPassword ? t("Hide password") : t("Show password")}
disabled={submitting}
onClick={() => setShowPassword((visible) => !visible)}
>
@@ -148,25 +148,21 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
</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>
<span>{t("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 ? t("Signing in…") : t("Sign in")}
{!submitting && <ArrowRight aria-hidden="true" />}
</Button>
</form>
)}
{oidcLogin && (
<Button type="button" size="lg" className="w-full" onClick={startOidcLogin}>
Continue with single sign-on
<ArrowRight aria-hidden="true" />
<Button type="button" size="lg" className="w-full" onClick={startOidcLogin}>{t("Continue with single sign-on")}<ArrowRight aria-hidden="true" />
</Button>
)}
{!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>
<p role="status" className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">{t("No browser sign-in method is enabled for this installation.")}</p>
)}
</section>
</div>
+16
View File
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
import type { AuthenticatedUser } from "../api/types";
import { queryClient } from "../app/queryClient";
import { useSessionStore } from "../store/sessionStore";
import { rememberSession } from "../shell/host/rememberedSession";
let current: AuthenticatedUser | null = null;
let generation = 0;
@@ -22,13 +23,28 @@ export function getAuthState(): AuthenticatedUser | null {
}
export function setAuthState(user: AuthenticatedUser): void {
if (current && (current.issuer !== user.issuer || current.subject !== user.subject)) rememberSession(current, null);
scrubUserBoundState();
current = user;
generation += 1;
notify();
}
/** Refresh expiry/CSRF metadata without discarding the current user's work. */
export function refreshAuthState(user: AuthenticatedUser): void {
if (!current || current.issuer !== user.issuer || current.subject !== user.subject
|| current.isAdmin !== user.isAdmin
|| JSON.stringify([...current.permissions].sort()) !== JSON.stringify([...user.permissions].sort())
|| JSON.stringify([...current.roles].sort()) !== JSON.stringify([...user.roles].sort())) {
setAuthState(user);
return;
}
current = user;
notify();
}
export function clearAuthState(): void {
if (current) rememberSession(current, null);
scrubUserBoundState();
current = null;
generation += 1;
+16
View File
@@ -0,0 +1,16 @@
import { authErrorStatus, getMe } from "../api/auth";
import { clearAuthStateIfCurrent, getAuthGeneration, getAuthState, isAuthGenerationCurrent, refreshAuthState } from "./authState";
/** Shared by page-return and stream-reconnection probes. Only /me denial revokes app access. */
export async function checkAccess() {
const generation = getAuthGeneration();
try {
const user = await getMe();
if (getAuthState() && isAuthGenerationCurrent(generation)) refreshAuthState(user);
return user;
} catch (error) {
const status = authErrorStatus(error);
if (status === 401 || status === 403) clearAuthStateIfCurrent(generation);
throw error;
}
}