rename web/ to admin/, along with the web-named build and cli identifiers

This commit is contained in:
2026-08-16 00:17:58 +02:00
parent 5b3d1cd65c
commit 1e97c80f6b
136 changed files with 196 additions and 196 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("/");
});
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
/** Login renders outside AppShell, so it paints the page ground itself. */
page: {
display: "flex",
minHeight: "100dvh",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surface,
color: colors.text,
padding: "1rem",
},
card: {
width: "100%",
maxWidth: "24rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
probing: {
marginTop: "1rem",
color: colors.textMuted,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
submit: {
width: "100%",
},
error: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
});
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 {...stylex.props(styles.page)}>
<section {...stylex.props(styles.card)}>
<h1 {...stylex.props(styles.heading)}>nxdns</h1>
{authRequired !== true ? (
<p {...stylex.props(styles.probing)}>Checking whether a password is required</p>
) : (
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div>
<label htmlFor="password" {...stylex.props(styles.fieldLabel)}>
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
autoFocus
required
value={password}
onChange={(event) => setPassword(event.target.value)}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<button
type="submit"
disabled={busy || lockedOut}
{...stylex.props(shared.largePrimaryButton, styles.submit, shared.focusRing)}
>
{busy ? "Logging in…" : "Log in"}
</button>
</form>
)}
{error !== null && (
<p role="alert" {...stylex.props(styles.error)}>
{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;
}