feat: implement multi-factor authentication (MFA) setup and verification flow

- Add SecurityPage component for managing MFA settings, including enrollment and verification.
- Create MfaLoginGate to handle MFA verification state during login.
- Develop MfaVerificationPopup for user input of verification codes.
- Implement VerifyMfaPage for the MFA verification process after login.
- Introduce reusable VerificationCodeInput component for entering verification codes.
- Integrate Supabase MFA API for managing factors and verification.
- Add loading states and error handling for a better user experience.
This commit is contained in:
willchen96 2026-06-10 03:48:08 +08:00
parent 15c96b0dd4
commit 3a10943200
32 changed files with 3704 additions and 311 deletions

View file

@ -430,7 +430,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
{isDropdownOpen && (
<div
className={cn(
"absolute bottom-full left-0 right-0 z-50 mb-1 p-1 whitespace-nowrap",
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
isOpen ? "right-0" : "w-56",
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
)}
>

View file

@ -0,0 +1,126 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { needsMfaVerification } from "./MfaVerificationPopup";
type GateState = "idle" | "checking" | "required" | "verified";
const MFA_VERIFIED_AT_KEY = "mike:mfa-verified-at";
const MFA_VERIFIED_GRACE_MS = 60_000;
export function MfaLoginGate({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { user } = useAuth();
const { profile, loading } = useUserProfile();
const [gateState, setGateState] = useState<GateState>("idle");
const isVerifyPage = pathname === "/verify-mfa";
useEffect(() => {
if (!user || loading || !profile?.mfaOnLogin) {
setGateState("idle");
return;
}
let cancelled = false;
setGateState("checking");
async function checkLoginMfa() {
try {
if (hasRecentMfaVerification()) {
if (!cancelled) setGateState("verified");
return;
}
const required = await needsMfaVerification();
if (cancelled) return;
setGateState(required ? "required" : "verified");
} catch {
if (!cancelled) setGateState("required");
}
}
void checkLoginMfa();
return () => {
cancelled = true;
};
}, [loading, profile?.mfaOnLogin, user]);
useEffect(() => {
if (!user || loading || !profile?.mfaOnLogin) return;
if (gateState === "required" && !isVerifyPage) {
if (hasRecentMfaVerification()) {
setGateState("verified");
return;
}
const search = searchParams.toString();
const next = `${pathname}${search ? `?${search}` : ""}`;
router.replace(`/verify-mfa?next=${encodeURIComponent(next)}`);
} else if (gateState === "verified" && isVerifyPage) {
const next = safeNextPath(searchParams.get("next"));
router.replace(next);
}
}, [
gateState,
isVerifyPage,
loading,
pathname,
profile?.mfaOnLogin,
router,
searchParams,
user,
]);
if (user && loading) return <FullScreenGateLoader />;
if (user && profile?.mfaOnLogin) {
if (gateState === "required" && isVerifyPage) {
return <>{children}</>;
}
if (gateState === "verified" && isVerifyPage) {
return <FullScreenGateLoader />;
}
if (gateState === "verified") {
return <>{children}</>;
}
if (gateState === "required" && !isVerifyPage) {
return <FullScreenGateLoader />;
}
return <FullScreenGateLoader />;
}
return <>{children}</>;
}
function safeNextPath(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return "/assistant";
}
if (value.startsWith("/verify-mfa")) return "/assistant";
return value;
}
function FullScreenGateLoader() {
return (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}
export function markMfaVerifiedForGate() {
window.sessionStorage.setItem(MFA_VERIFIED_AT_KEY, String(Date.now()));
}
function hasRecentMfaVerification() {
const raw = window.sessionStorage.getItem(MFA_VERIFIED_AT_KEY);
const verifiedAt = raw ? Number.parseInt(raw, 10) : 0;
return (
Number.isFinite(verifiedAt) &&
Date.now() - verifiedAt < MFA_VERIFIED_GRACE_MS
);
}

View file

@ -0,0 +1,294 @@
"use client";
import {
useEffect,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
} from "react";
import { Loader2 } from "lucide-react";
import { supabase } from "@/lib/supabase";
import { Modal } from "@/app/components/shared/Modal";
type MfaFactor = {
id: string;
friendly_name?: string | null;
factor_type: string;
};
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
export async function needsMfaVerification() {
const { data, error } =
await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (error) throw error;
return data.nextLevel === "aal2" && data.currentLevel !== "aal2";
}
interface MfaVerificationPopupProps {
open: boolean;
onCancel: () => void;
onVerified: () => void;
title?: string;
message?: string;
}
export function MfaVerificationPopup({
open,
onCancel,
onVerified,
title = "Two-factor verification required",
message = "Enter a code from your authenticator app to continue.",
}: MfaVerificationPopupProps) {
const [factors, setFactors] = useState<MfaFactor[]>([]);
const [selectedFactorId, setSelectedFactorId] = useState("");
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(null);
const canVerify =
!verifying &&
!loading &&
!!selectedFactorId &&
code.trim().length === 6;
useEffect(() => {
if (!open) return;
let cancelled = false;
devLog("[mfa-popup] opened");
async function loadFactors() {
setLoading(true);
setError(null);
setCode("");
const { data, error: listError } =
await supabase.auth.mfa.listFactors();
if (cancelled) return;
if (listError) {
devLog("[mfa-popup] list factors failed", {
error: listError.message,
});
setError(listError.message);
setFactors([]);
setSelectedFactorId("");
} else {
const verified = (data.totp ?? []) as MfaFactor[];
devLog("[mfa-popup] factors loaded", {
totpCount: verified.length,
selectedFactorId: verified[0]?.id ?? null,
});
setFactors(verified);
setSelectedFactorId(verified[0]?.id ?? "");
}
setLoading(false);
}
void loadFactors();
return () => {
cancelled = true;
};
}, [open]);
async function verify() {
if (!canVerify) return;
setVerifying(true);
setError(null);
devLog("[mfa-popup] verifying code", { factorId: selectedFactorId });
const { error: verifyError } =
await supabase.auth.mfa.challengeAndVerify({
factorId: selectedFactorId,
code: code.trim(),
});
setVerifying(false);
if (verifyError) {
devLog("[mfa-popup] verification failed", {
error: verifyError.message,
});
setError(verifyError.message);
return;
}
devLog("[mfa-popup] verification succeeded");
setCode("");
onVerified();
}
if (!open) return null;
return (
<Modal
open={open}
onClose={onCancel}
title={title}
size="sm"
className="h-auto min-h-[310px] max-h-[min(92vh,400px)]"
cancelAction={{
label: "Cancel",
onClick: onCancel,
disabled: verifying,
}}
primaryAction={{
label: verifying ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Verifying...
</span>
) : (
"Verify"
),
onClick: () => void verify(),
disabled: !canVerify,
}}
>
<div className="space-y-5 pb-2 pt-0">
<p className="text-sm text-gray-500 pb-6">{message}</p>
{loading ? (
<div className="flex h-13 items-center justify-center text-sm text-gray-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading authenticator...
</div>
) : factors.length === 0 ? (
<p className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
No verified authenticator factor is available for this
session.
</p>
) : (
<div className="space-y-4">
{factors.length > 1 && (
<select
value={selectedFactorId}
onChange={(event) =>
setSelectedFactorId(event.target.value)
}
className="h-9 w-full rounded-lg bg-gray-100 px-3 text-sm text-gray-900 outline-none focus-visible:ring-2 focus-visible:ring-gray-300/45"
>
{factors.map((factor) => (
<option key={factor.id} value={factor.id}>
{factor.friendly_name ||
"Authenticator app"}
</option>
))}
</select>
)}
<VerificationCodeInput
value={code}
onChange={setCode}
disabled={verifying}
autoFocus={open && !loading}
onSubmit={() => void verify()}
canSubmit={canVerify}
/>
</div>
)}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
</Modal>
);
}
export function VerificationCodeInput({
value,
onChange,
disabled,
autoFocus,
onSubmit,
canSubmit,
}: {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
autoFocus?: boolean;
onSubmit?: () => void;
canSubmit?: boolean;
}) {
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const digits = Array.from({ length: 6 }, (_, index) => value[index] ?? "");
useEffect(() => {
if (!autoFocus || disabled) return;
const focusTimer = window.setTimeout(() => {
const firstEmptyIndex = digits.findIndex((digit) => !digit);
inputsRef.current[
firstEmptyIndex === -1 ? 0 : firstEmptyIndex
]?.focus();
}, 0);
return () => window.clearTimeout(focusTimer);
}, [autoFocus, disabled]);
function updateDigit(index: number, nextValue: string) {
const digit = nextValue.replace(/\D/g, "").slice(-1);
const nextDigits = [...digits];
nextDigits[index] = digit;
onChange(nextDigits.join(""));
if (digit && index < inputsRef.current.length - 1) {
inputsRef.current[index + 1]?.focus();
}
}
function handlePaste(event: ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
const pasted = event.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, 6);
if (!pasted) return;
onChange(pasted);
inputsRef.current[Math.min(pasted.length, 6) - 1]?.focus();
}
function handleKeyDown(
event: KeyboardEvent<HTMLInputElement>,
index: number,
) {
if (event.key === "Enter") {
event.preventDefault();
if (canSubmit) onSubmit?.();
return;
}
if (event.key === "Backspace" && !digits[index] && index > 0) {
inputsRef.current[index - 1]?.focus();
}
if (event.key === "ArrowLeft" && index > 0) {
event.preventDefault();
inputsRef.current[index - 1]?.focus();
}
if (event.key === "ArrowRight" && index < digits.length - 1) {
event.preventDefault();
inputsRef.current[index + 1]?.focus();
}
}
return (
<div
className="flex justify-center gap-2"
role="group"
aria-label="Six digit verification code"
>
{digits.map((digit, index) => (
<input
key={index}
ref={(element) => {
inputsRef.current[index] = element;
}}
type="text"
inputMode="numeric"
autoComplete={index === 0 ? "one-time-code" : "off"}
value={digit}
disabled={disabled}
onChange={(event) => updateDigit(index, event.target.value)}
onPaste={handlePaste}
onKeyDown={(event) => handleKeyDown(event, index)}
className="h-13 w-12 rounded-lg border border-gray-300 bg-gray-50 text-center text-2xl font-medium font-serif text-gray-950 shadow-none outline-none transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-300/45 disabled:cursor-not-allowed disabled:opacity-45"
aria-label={`Verification code digit ${index + 1}`}
maxLength={1}
/>
))}
</div>
);
}