feat(webui): captcha input on login page after repeated failures

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 09:35:44 +08:00
parent 9a1775c17d
commit 2db00b5157
+53 -2
View File
@@ -18,6 +18,21 @@ export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [rememberMe, setRememberMe] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [captchaRequired, setCaptchaRequired] = useState(false);
const [captchaId, setCaptchaId] = useState("");
const [captchaQuestion, setCaptchaQuestion] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState("");
const refreshCaptcha = async () => {
setCaptchaAnswer("");
const response = await fetch("/api/auth/captcha", { cache: "no-store" });
if (!response.ok) return;
const body = (await response.json()) as { id?: string; question?: string };
if (body.id && body.question) {
setCaptchaId(body.id);
setCaptchaQuestion(body.question);
}
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -29,13 +44,26 @@ export default function LoginPage() {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ username, password, next: returnPath(), rememberMe }),
body: JSON.stringify({
username,
password,
next: returnPath(),
rememberMe,
...(captchaRequired ? { captchaId, captchaAnswer } : {}),
}),
});
const body = (await response.json().catch(() => ({}))) as {
error?: string;
redirectTo?: string;
requiresCaptcha?: boolean;
};
if (!response.ok) throw new Error(body.error || "Unable to sign in.");
if (!response.ok) {
if (body.requiresCaptcha) {
setCaptchaRequired(true);
await refreshCaptcha();
}
throw new Error(body.error || "Unable to sign in.");
}
window.location.replace(body.redirectTo || "/");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Unable to sign in.");
@@ -99,6 +127,29 @@ export default function LoginPage() {
/>
<span>Remember me for 30 days</span>
</label>
{captchaRequired && (
<label className="block space-y-1.5 text-sm font-medium">
<span>
Verification: {captchaQuestion}{" "}
<button
type="button"
onClick={() => void refreshCaptcha()}
className="text-muted-foreground underline underline-offset-2"
>
New question
</button>
</span>
<Input
name="captchaAnswer"
inputMode="numeric"
autoComplete="off"
value={captchaAnswer}
onChange={(event) => setCaptchaAnswer(event.target.value)}
disabled={submitting}
required
/>
</label>
)}
</div>
{error && (
<p className="mt-4 text-sm text-destructive" role="alert">