milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
@@ -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("/");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user