feat(web): MFA and passkey sign-in on the login page
This commit is contained in:
+182
-3
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user