milestone 9: react spa admin ui, frontend ci and embedded dist

This commit is contained in:
2026-08-02 13:04:09 +02:00
parent 5253c47303
commit 617cc966a2
82 changed files with 11833 additions and 17 deletions
+97
View File
@@ -0,0 +1,97 @@
import { act } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { safeRedirect } from "@/auth/LoginPage";
import { AuthProvider, resetAuthProbeForTests } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function flushAll() {
for (let i = 0; i < 20; i++) {
await act(async () => {
vi.advanceTimersByTime(0);
await Promise.resolve();
});
}
}
function renderLoginRoute() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/login"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
test("429 login shows a ticking countdown and keeps submit disabled until it ends", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) !== "/api/auth/login") return jsonResponse({ error: "not stubbed" }, 404);
const body = JSON.parse(String(init?.body)) as { password: string };
if (body.password === "") return jsonResponse({ error: "password required" }, 401);
return jsonResponse({ error: "rate limited" }, 429, { "retry-after": "3" });
});
vi.stubGlobal("fetch", fetchMock);
renderLoginRoute();
await flushAll();
const input = screen.getByLabelText("Password");
fireEvent.change(input, { target: { value: "wrong" } });
fireEvent.submit(input.closest("form") as HTMLFormElement);
await flushAll();
const button = screen.getByRole("button", { name: "Log in" }) as HTMLButtonElement;
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again in 3s.");
expect(button.disabled).toBe(true);
const callsAtLockout = fetchMock.mock.calls.length;
fireEvent.submit(input.closest("form") as HTMLFormElement);
await flushAll();
expect(fetchMock.mock.calls.length).toBe(callsAtLockout);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again in 2s.");
expect(button.disabled).toBe(true);
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again shortly.");
expect(button.disabled).toBe(false);
});
test("safeRedirect only allows same-origin absolute paths", () => {
expect(safeRedirect(undefined)).toBe("/");
expect(safeRedirect("/queries")).toBe("/queries");
expect(safeRedirect("/queries?x=1")).toBe("/queries?x=1");
expect(safeRedirect("//evil.example")).toBe("/");
expect(safeRedirect("https://evil.example")).toBe("/");
expect(safeRedirect("/\\evil.example")).toBe("/");
expect(safeRedirect("/\\\\evil.example")).toBe("/");
expect(safeRedirect("\\evil")).toBe("/");
});
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store";
export function safeRedirect(raw: string | undefined): string {
if (raw === undefined) return "/";
if (!/^\/(?![/\\])/.test(raw) || raw.includes("\\")) return "/";
return raw;
}
function errorMessage(error: unknown, remaining: number | null): string {
if (error instanceof ApiError) {
if (error.status === 401) return "Incorrect password.";
if (error.status === 429) {
return remaining !== null && remaining > 0
? `Too many attempts. Try again in ${remaining}s.`
: "Too many attempts. Try again shortly.";
}
if (error.status === 503) return "The server is starting or degraded. Try again shortly.";
return error.message;
}
return "Could not reach the server.";
}
export default function LoginPage() {
const { authRequired, probe, login } = useAuth();
const router = useRouter();
const search = useSearch({ from: "/login" });
const redirect = safeRedirect(search.redirect);
const [password, setPassword] = useState("");
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
const [remaining, setRemaining] = useState<number | null>(null);
useEffect(() => {
setRemaining(retryAfter);
if (retryAfter === null) return;
const timer = setInterval(() => setRemaining((s) => (s === null || s <= 1 ? 0 : s - 1)), 1000);
return () => clearInterval(timer);
}, [error, retryAfter]);
const lockedOut = remaining !== null && remaining > 0;
useEffect(() => {
if (authRequired === false) {
router.history.replace(redirect);
return;
}
if (authRequired === null) {
probe()
.then((required) => {
if (!required) router.history.replace(redirect);
})
.catch((probeError: unknown) => setError(probeError));
}
}, [authRequired, probe, redirect, router]);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy || lockedOut) return;
setBusy(true);
setError(null);
try {
await login(password);
router.history.push(redirect);
} catch (loginError) {
setError(loginError);
} finally {
setBusy(false);
}
}
return (
<main className="flex min-h-dvh items-center justify-center bg-zinc-50 p-4 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<section className="w-full max-w-sm">
<h1 className="text-2xl font-semibold">nxdns</h1>
{authRequired !== true ? (
<p className="mt-4 text-zinc-500">Checking whether a password is required</p>
) : (
<form onSubmit={onSubmit} className="mt-6 space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
autoFocus
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<button
type="submit"
disabled={busy || lockedOut}
className="w-full rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{busy ? "Logging in…" : "Log in"}
</button>
</form>
)}
{error !== null && (
<p role="alert" className="mt-4 text-sm text-red-600 dark:text-red-400">
{errorMessage(error, remaining)}
</p>
)}
</section>
</main>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { act } from "react";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { AuthProvider, resetAuthProbeForTests, useAuth } from "@/auth/store";
const STORAGE_KEY = "nxdns_auth_required";
function wrapper({ children }: { children: ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("mounting with nothing stored probes and settles authRequired true on 401", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/auth/login") return jsonResponse({ error: "password required" }, 401);
return jsonResponse({ error: "not stubbed" }, 404);
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.authRequired).toBeNull();
await waitFor(() => expect(result.current.authRequired).toBe(true));
expect(sessionStorage.getItem(STORAGE_KEY)).toBe("true");
});
test("mounting with nothing stored probes and settles authRequired false when auth is off", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ authenticated: true, auth_required: false })),
);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.authRequired).toBe(false));
expect(sessionStorage.getItem(STORAGE_KEY)).toBe("false");
});
test("a stored value is the fast path: no probe fires on mount", async () => {
sessionStorage.setItem(STORAGE_KEY, "true");
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.authRequired).toBe(true);
await act(async () => {});
expect(fetchMock).not.toHaveBeenCalled();
});
test("logout swallows a 401 from an already-dead session", async () => {
sessionStorage.setItem(STORAGE_KEY, "true");
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ error: "unauthorized" }, 401)),
);
const { result } = renderHook(() => useAuth(), { wrapper });
await expect(result.current.logout()).resolves.toBeUndefined();
});
test("logout rethrows non-401 errors such as 429", async () => {
sessionStorage.setItem(STORAGE_KEY, "true");
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ error: "rate limited" }, 429, { "retry-after": "7" })),
);
const { result } = renderHook(() => useAuth(), { wrapper });
await expect(result.current.logout()).rejects.toMatchObject({
name: "ApiError",
status: 429,
retryAfter: 7,
});
});
+105
View File
@@ -0,0 +1,105 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { ApiError, login as apiLogin, logout as apiLogout } from "@/lib/api";
import type { LoginResponse } from "@/lib/types";
const STORAGE_KEY = "nxdns_auth_required";
export function readStoredAuthRequired(): boolean | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw === null ? null : raw === "true";
} catch {
return null;
}
}
export function rememberAuthRequired(value: boolean): void {
try {
sessionStorage.setItem(STORAGE_KEY, String(value));
} catch {
// Storage unavailable; the probe will run again next load.
}
}
// Deduped across StrictMode double-effects: one empty-password login answers
// whether auth is on (401 → on; 200 with auth_required=false → off).
let probePromise: Promise<boolean> | null = null;
function probeAuthRequired(): Promise<boolean> {
probePromise ??= apiLogin({ password: "" }).then(
(response) => {
rememberAuthRequired(response.auth_required);
return response.auth_required;
},
(error: unknown) => {
probePromise = null;
if (error instanceof ApiError && error.status === 401) {
rememberAuthRequired(true);
return true;
}
throw error;
},
);
return probePromise;
}
export function resetAuthProbeForTests(): void {
probePromise = null;
}
export interface AuthStore {
/** null until a login response, a probe, or a stored value settles it. */
authRequired: boolean | null;
/** Resolves true when a password is required (form must be shown). */
probe: () => Promise<boolean>;
login: (password: string) => Promise<LoginResponse>;
/** Ends the session server-side; swallows an already-dead session's 401. */
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthStore | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [authRequired, setAuthRequired] = useState<boolean | null>(readStoredAuthRequired);
const probe = useCallback(async () => {
const required = await probeAuthRequired();
setAuthRequired(required);
return required;
}, []);
useEffect(() => {
if (authRequired !== null) return;
probe().catch(() => {
// Server unreachable; LoginPage's own probe surfaces the error.
});
}, [authRequired, probe]);
const login = useCallback(async (password: string) => {
const response = await apiLogin({ password });
rememberAuthRequired(response.auth_required);
setAuthRequired(response.auth_required);
return response;
}, []);
const logout = useCallback(async () => {
try {
await apiLogout();
} catch (error) {
if (error instanceof ApiError && error.status === 401) return;
throw error;
}
}, []);
const value = useMemo<AuthStore>(
() => ({ authRequired, probe, login, logout }),
[authRequired, probe, login, logout],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
export function useAuth(): AuthStore {
const store = useContext(AuthContext);
if (store === null) throw new Error("useAuth requires an AuthProvider");
return store;
}