mike/frontend/src/app/components/shared/MfaLoginGate.tsx
willchen96 3a10943200 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.
2026-06-10 03:48:08 +08:00

126 lines
3.9 KiB
TypeScript

"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
);
}