168 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|