diff --git a/maternal-web/app/(auth)/login/page.tsx b/maternal-web/app/(auth)/login/page.tsx index 74dc74d..41c573e 100644 --- a/maternal-web/app/(auth)/login/page.tsx +++ b/maternal-web/app/(auth)/login/page.tsx @@ -21,6 +21,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { motion } from 'framer-motion'; import * as z from 'zod'; import { useAuth } from '@/lib/auth/AuthContext'; +import { MFAVerificationDialog } from '@/components/auth/MFAVerificationDialog'; +import { tokenStorage } from '@/lib/utils/tokenStorage'; import Link from 'next/link'; const loginSchema = z.object({ @@ -34,6 +36,8 @@ export default function LoginPage() { const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [mfaRequired, setMfaRequired] = useState(false); + const [mfaData, setMfaData] = useState<{ userId: string; mfaMethod: 'totp' | 'email' } | null>(null); const { login } = useAuth(); const router = useRouter(); @@ -53,12 +57,33 @@ export default function LoginPage() { await login(data); // Navigation is handled in the login function } catch (err: any) { - setError(err.message || 'Failed to login. Please check your credentials.'); + // Check if MFA is required + if (err.response?.data?.mfaRequired) { + setMfaRequired(true); + setMfaData({ + userId: err.response.data.userId, + mfaMethod: err.response.data.mfaMethod, + }); + } else { + setError(err.message || 'Failed to login. Please check your credentials.'); + } } finally { setIsLoading(false); } }; + const handleMFAVerified = (tokens: { accessToken: string; refreshToken: string }, user: any) => { + // Store tokens and navigate + tokenStorage.setTokens(tokens.accessToken, tokens.refreshToken); + setMfaRequired(false); + router.push('/'); + }; + + const handleMFACancel = () => { + setMfaRequired(false); + setMfaData(null); + }; + return ( + + {/* MFA Verification Dialog */} + {mfaRequired && mfaData && ( + + )} ); } diff --git a/maternal-web/components/auth/MFAVerificationDialog.tsx b/maternal-web/components/auth/MFAVerificationDialog.tsx new file mode 100644 index 0000000..515eb61 --- /dev/null +++ b/maternal-web/components/auth/MFAVerificationDialog.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Typography, + Alert, + CircularProgress, + Box, + Link as MuiLink, +} from '@mui/material'; +import { Security } from '@mui/icons-material'; +import axios from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3020'; + +interface MFAVerificationDialogProps { + open: boolean; + userId: string; + mfaMethod: 'totp' | 'email'; + onVerified: (tokens: { accessToken: string; refreshToken: string }, user: any) => void; + onCancel: () => void; +} + +export function MFAVerificationDialog({ + open, + userId, + mfaMethod, + onVerified, + onCancel, +}: MFAVerificationDialogProps) { + const [verificationCode, setVerificationCode] = useState(''); + const [error, setError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const [isSendingCode, setIsSendingCode] = useState(false); + const [codeSent, setCodeSent] = useState(false); + + // Auto-send email code when dialog opens + useEffect(() => { + if (open && mfaMethod === 'email' && !codeSent) { + sendEmailCode(); + } + }, [open, mfaMethod, codeSent]); + + const sendEmailCode = async () => { + try { + setIsSendingCode(true); + setError(null); + await axios.post(`${API_BASE_URL}/api/v1/auth/mfa/email/send-code`, { + userId, + }); + setCodeSent(true); + } catch (err: any) { + console.error('Failed to send email code:', err); + setError(err.response?.data?.message || 'Failed to send verification code'); + } finally { + setIsSendingCode(false); + } + }; + + const handleVerify = async () => { + if (!verificationCode || verificationCode.length < 6) { + setError('Please enter a valid verification code'); + return; + } + + try { + setIsVerifying(true); + setError(null); + + const response = await axios.post(`${API_BASE_URL}/api/v1/auth/mfa/verify`, { + userId, + code: verificationCode, + }); + + if (response.data.success) { + // Get tokens after successful MFA verification + // Note: Backend should return tokens after MFA verification + // For now, we'll assume success and let the parent handle it + onVerified(response.data.tokens, response.data.user); + } + } catch (err: any) { + console.error('Failed to verify MFA code:', err); + setError(err.response?.data?.message || 'Invalid verification code'); + } finally { + setIsVerifying(false); + } + }; + + const handleResendCode = async () => { + setCodeSent(false); + setVerificationCode(''); + setError(null); + await sendEmailCode(); + }; + + const handleCancel = () => { + setVerificationCode(''); + setError(null); + setCodeSent(false); + onCancel(); + }; + + return ( + + + + + Two-Factor Authentication + + + + {mfaMethod === 'totp' ? ( + <> + + Enter the 6-digit code from your authenticator app to continue. + + + ) : ( + <> + + {codeSent + ? 'A 6-digit verification code has been sent to your email.' + : 'Sending verification code to your email...'} + + {isSendingCode && ( + + + + )} + + )} + + {error && ( + + {error} + + )} + + + setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, mfaMethod === 'totp' ? 6 : 6)) + } + disabled={isVerifying || isSendingCode} + autoFocus + inputProps={{ + style: { textAlign: 'center', fontSize: '1.5rem', letterSpacing: '0.5rem' }, + maxLength: 6, + }} + /> + + {mfaMethod === 'email' && codeSent && ( + + + Didn't receive the code?{' '} + + Resend + + + + )} + + + + Tip: You can also use a backup code if you don't have access to your{' '} + {mfaMethod === 'totp' ? 'authenticator app' : 'email'}. + + + + + + + + + ); +}