Files
nxdns/admin/src/auth/LoginPage.tsx
T

168 lines
4.4 KiB
TypeScript

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