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