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:
@@ -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 () => {
|
||||
|
||||
@@ -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>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user