From 121f7c533ad2fc60e75b1604202063aa59fcec70 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:39:05 +0000 Subject: [PATCH] feat: Implement MFA Challenge Support --- src/components/auth/AuthModal.tsx | 37 +++++++- src/components/auth/MFAChallenge.tsx | 124 +++++++++++++++++++++++++++ src/pages/Auth.tsx | 40 ++++++++- 3 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 src/components/auth/MFAChallenge.tsx diff --git a/src/components/auth/AuthModal.tsx b/src/components/auth/AuthModal.tsx index ad1813bd..13c0aec8 100644 --- a/src/components/auth/AuthModal.tsx +++ b/src/components/auth/AuthModal.tsx @@ -11,6 +11,7 @@ import { useToast } from '@/hooks/use-toast'; import { TurnstileCaptcha } from './TurnstileCaptcha'; import { notificationService } from '@/lib/notificationService'; import { useCaptchaBypass } from '@/hooks/useCaptchaBypass'; +import { MFAChallenge } from './MFAChallenge'; interface AuthModalProps { open: boolean; @@ -27,6 +28,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod const [captchaKey, setCaptchaKey] = useState(0); const [signInCaptchaToken, setSignInCaptchaToken] = useState(null); const [signInCaptchaKey, setSignInCaptchaKey] = useState(0); + const [mfaFactorId, setMfaFactorId] = useState(null); const [formData, setFormData] = useState({ email: '', password: '', @@ -71,8 +73,19 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod signInOptions.options = { captchaToken: tokenToUse }; } - const { error } = await supabase.auth.signInWithPassword(signInOptions); + const { data, error } = await supabase.auth.signInWithPassword(signInOptions); if (error) throw error; + + // Check if MFA is required (user exists but no session) + if (data.user && !data.session) { + const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified'); + + if (totpFactor) { + setMfaFactorId(totpFactor.id); + setLoading(false); + return; + } + } toast({ title: "Welcome back!", @@ -95,6 +108,16 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod } }; + const handleMfaSuccess = () => { + setMfaFactorId(null); + onOpenChange(false); + }; + + const handleMfaCancel = () => { + setMfaFactorId(null); + setSignInCaptchaKey(prev => prev + 1); + }; + const handleSignUp = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); @@ -261,7 +284,15 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod -
+ {mfaFactorId ? ( + + ) : ( + <> +
@@ -374,6 +405,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
+ + )} diff --git a/src/components/auth/MFAChallenge.tsx b/src/components/auth/MFAChallenge.tsx new file mode 100644 index 00000000..b36bb2e3 --- /dev/null +++ b/src/components/auth/MFAChallenge.tsx @@ -0,0 +1,124 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { useToast } from '@/hooks/use-toast'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; +import { Shield } from 'lucide-react'; + +interface MFAChallengeProps { + factorId: string; + onSuccess: () => void; + onCancel: () => void; +} + +export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) { + const { toast } = useToast(); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + const [challengeId, setChallengeId] = useState(null); + + // Create MFA challenge on mount + useEffect(() => { + const createChallenge = async () => { + try { + const { data, error } = await supabase.auth.mfa.challenge({ factorId }); + if (error) throw error; + setChallengeId(data.id); + } catch (error: any) { + toast({ + variant: "destructive", + title: "MFA Challenge Failed", + description: error.message + }); + onCancel(); + } + }; + + createChallenge(); + }, [factorId, onCancel, toast]); + + const handleVerify = async () => { + if (code.length !== 6 || !challengeId) return; + + setLoading(true); + try { + const { data, error } = await supabase.auth.mfa.verify({ + factorId, + challengeId, + code + }); + + if (error) throw error; + + if (data) { + toast({ + title: "Welcome back!", + description: "MFA verification successful." + }); + onSuccess(); + } + } catch (error: any) { + toast({ + variant: "destructive", + title: "Verification Failed", + description: error.message || "Invalid code. Please try again." + }); + setCode(''); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +

Two-Factor Authentication

+
+ +

+ Enter the 6-digit code from your authenticator app +

+ +
+ +
+ + + + + + + + + + +
+
+ +
+ + +
+
+ ); +} diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index 25e6364e..933f0406 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -15,6 +15,8 @@ import { useToast } from '@/hooks/use-toast'; import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha'; import { notificationService } from '@/lib/notificationService'; import { StorageWarning } from '@/components/auth/StorageWarning'; +import { MFAChallenge } from '@/components/auth/MFAChallenge'; + export default function Auth() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -28,6 +30,7 @@ export default function Auth() { const [captchaKey, setCaptchaKey] = useState(0); const [signInCaptchaToken, setSignInCaptchaToken] = useState(null); const [signInCaptchaKey, setSignInCaptchaKey] = useState(0); + const [mfaFactorId, setMfaFactorId] = useState(null); const [formData, setFormData] = useState({ email: '', password: '', @@ -88,6 +91,18 @@ export default function Auth() { }); if (error) throw error; + + // Check if MFA is required (user exists but no session) + if (data.user && !data.session) { + console.log('[Auth] MFA required'); + const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified'); + + if (totpFactor) { + setMfaFactorId(totpFactor.id); + setLoading(false); + return; + } + } console.log('[Auth] Sign in successful', { user: data.user?.email, @@ -139,6 +154,19 @@ export default function Auth() { setLoading(false); } }; + + const handleMfaSuccess = () => { + setMfaFactorId(null); + toast({ + title: "Welcome back!", + description: "You've been signed in successfully." + }); + }; + + const handleMfaCancel = () => { + setMfaFactorId(null); + setSignInCaptchaKey(prev => prev + 1); + }; const handleSignUp = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); @@ -324,7 +352,15 @@ export default function Auth() { - + {mfaFactorId ? ( + + ) : ( + <> +
@@ -419,6 +455,8 @@ export default function Auth() {
+ + )}