rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user