feat(web): MFA and passkey sign-in on the login page

This commit is contained in:
2026-09-16 09:22:18 +00:00
parent d8597ee3ae
commit 26825841fa
6 changed files with 825 additions and 132 deletions
+182 -3
View File
@@ -2,11 +2,13 @@
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { auth, type PublicProvider } from "@/lib/api";
import { auth, ApiError, type PublicProvider } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Logo } from "@/components/Logo";
import { NetworkBackground } from "@/components/NetworkBackground";
import { ProviderIcon } from "@/components/settings/ProviderIcon";
import { MfaEnrolWizard } from "@/components/mfa/MfaEnrolWizard";
import { isPasskeySupported, toRequestOptions, credentialToJSON } from "@/lib/webauthn";
const ERROR_MESSAGES: Record<string, string> = {
oidc_unavailable: "Single sign-on is not available on this instance's plan.",
@@ -23,8 +25,27 @@ const ERROR_MESSAGES: Record<string, string> = {
state_failed: "Could not start sign-in. Please try again.",
unknown_host: "This address does not name a known instance.",
instance_locked: "Access to this instance is suspended.",
invalid_code: "That code was not correct.",
mfa_ticket_expired: "That sign-in attempt expired. Please sign in again.",
};
/** Turns a thrown error into the page's inline message, handling the rate limit specially. */
function describeError(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 429) {
return err.retryAfter ? `Too many attempts. Try again in ${err.retryAfter}s.` : "Too many attempts. Please wait a moment and try again.";
}
if (err.code === "invalid_code" && err.attemptsLeft !== undefined) {
return `That code was not correct. ${err.attemptsLeft} attempt${err.attemptsLeft === 1 ? "" : "s"} left.`;
}
if (err.code && ERROR_MESSAGES[err.code]) return ERROR_MESSAGES[err.code];
return err.message || "Sign-in failed. Please try again.";
}
return "Sign-in failed. Please try again.";
}
type Step = "credentials" | "factor" | "enrol";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -38,6 +59,15 @@ export default function LoginPage() {
// says so instead of drawing a form that cannot sign anyone in.
const [locked, setLocked] = useState(false);
const [step, setStep] = useState<Step>("credentials");
const [factorMethods, setFactorMethods] = useState<string[]>([]);
const [useRecovery, setUseRecovery] = useState(false);
const [factorCode, setFactorCode] = useState("");
const [factorError, setFactorError] = useState("");
const [factorBusy, setFactorBusy] = useState(false);
const [credentialsNotice, setCredentialsNotice] = useState("");
const [passkeyBusy, setPasskeyBusy] = useState(false);
useEffect(() => {
const code = new URLSearchParams(window.location.search).get("error");
if (code === "instance_locked") setLocked(true);
@@ -80,16 +110,98 @@ export default function LoginPage() {
error,
} = useMutation({
mutationFn: () => auth.login(email, password),
onSuccess: () => {
onSuccess: (res) => {
if ("mfa_required" in res && res.mfa_required) {
setFactorMethods(res.methods);
setUseRecovery(false);
setFactorCode("");
setFactorError("");
setStep("factor");
return;
}
if ("enrol_required" in res && res.enrol_required) {
setStep("enrol");
return;
}
window.location.href = "/";
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setCredentialsNotice("");
signIn();
}
function returnToCredentials(message: string) {
setStep("credentials");
setCredentialsNotice(message);
}
async function handleFactorSubmit(e: React.FormEvent) {
e.preventDefault();
setFactorError("");
setFactorBusy(true);
try {
if (useRecovery) {
await auth.mfaRecovery(factorCode);
} else {
await auth.mfaTotp(factorCode);
}
window.location.href = "/";
} catch (err) {
if (err instanceof ApiError && err.code === "mfa_ticket_expired") {
returnToCredentials("That sign-in attempt expired. Please sign in again.");
return;
}
setFactorError(describeError(err));
} finally {
setFactorBusy(false);
}
}
async function handleFactorPasskey() {
setFactorError("");
setFactorBusy(true);
try {
const { publicKey, ceremony_id } = await auth.mfaWebAuthnBegin();
const cred = (await navigator.credentials.get({
publicKey: toRequestOptions(publicKey),
})) as PublicKeyCredential;
await auth.mfaWebAuthnFinish(ceremony_id, credentialToJSON(cred));
window.location.href = "/";
} catch (err) {
if (err instanceof ApiError && err.code === "mfa_ticket_expired") {
returnToCredentials("That sign-in attempt expired. Please sign in again.");
return;
}
setFactorError(describeError(err));
} finally {
setFactorBusy(false);
}
}
async function handlePasswordlessPasskey() {
setCredentialsNotice("");
setPasskeyBusy(true);
try {
const { publicKey, ceremony_id } = await auth.passkeyLoginBegin();
const cred = (await navigator.credentials.get({
publicKey: toRequestOptions(publicKey),
})) as PublicKeyCredential;
await auth.passkeyLoginFinish(ceremony_id, credentialToJSON(cred));
window.location.href = "/";
} catch (err) {
setCredentialsNotice(describeError(err));
} finally {
setPasskeyBusy(false);
}
}
function handleEnrolComplete() {
window.location.href = "/";
}
const showLocal = localEnabled || providers.length === 0;
const showDivider = showLocal && providers.length > 0;
@@ -111,9 +223,70 @@ export default function LoginPage() {
Nobody can sign in, and its servers are not being managed, until this is resolved. If you manage this account, check your email from Vantage for details.
</p>
</Card>
) : step === "enrol" ? (
<Card>
<MfaEnrolWizard mode="ticket" onComplete={handleEnrolComplete} />
</Card>
) : step === "factor" ? (
<Card>
<form onSubmit={handleFactorSubmit} className="space-y-4">
<p className="text-sm text-text-secondary">{useRecovery ? "Enter one of your recovery codes." : "Enter the 6-digit code from your authenticator app."}</p>
<div>
<label htmlFor="factor-code" className="mb-1.5 block text-sm font-medium text-text-secondary">
{useRecovery ? "Recovery code" : "Verification code"}
</label>
<input
id="factor-code"
type="text"
inputMode={useRecovery ? "text" : "numeric"}
autoComplete="one-time-code"
required
autoFocus
value={factorCode}
onChange={(e) => setFactorCode(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
{factorError && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{factorError}</div>}
<Button type="submit" variant="primary" loading={factorBusy} className="w-full justify-center">
Verify
</Button>
{factorMethods.includes("webauthn") && (
<Button type="button" variant="secondary" loading={factorBusy} className="w-full justify-center" onClick={handleFactorPasskey}>
Use passkey
</Button>
)}
{factorMethods.includes("recovery") && (
<button
type="button"
className="w-full text-center text-sm text-text-secondary underline-offset-2 hover:underline"
onClick={() => {
setUseRecovery((v) => !v);
setFactorCode("");
setFactorError("");
}}
>
{useRecovery ? "Use a verification code instead" : "Use a recovery code instead"}
</button>
)}
<button
type="button"
className="w-full text-center text-sm text-text-tertiary underline-offset-2 hover:underline"
onClick={() => returnToCredentials("")}
>
Back
</button>
</form>
</Card>
) : (
<Card>
{ssoError && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{ssoError}</div>}
{credentialsNotice && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{credentialsNotice}</div>}
{showLocal && (
<form onSubmit={handleSubmit} className="space-y-4">
@@ -147,11 +320,17 @@ export default function LoginPage() {
/>
</div>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{describeError(error)}</div>}
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
Sign In
</Button>
{isPasskeySupported() && localEnabled && (
<Button type="button" variant="secondary" loading={passkeyBusy} className="w-full justify-center" onClick={handlePasswordlessPasskey}>
Sign in with passkey
</Button>
)}
</form>
)}