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
+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>
);
}