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