mirror of
https://github.com/willchen96/mike.git
synced 2026-06-20 21:18:07 +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
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
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
|
||||
);
|
||||
}
|
||||
294
frontend/src/app/components/shared/MfaVerificationPopup.tsx
Normal file
294
frontend/src/app/components/shared/MfaVerificationPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue