mirror of
https://github.com/willchen96/mike.git
synced 2026-07-10 22:32:14 +02:00
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:
parent
15c96b0dd4
commit
3a10943200
32 changed files with 3704 additions and 311 deletions
126
frontend/src/app/components/shared/MfaLoginGate.tsx
Normal file
126
frontend/src/app/components/shared/MfaLoginGate.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue