diff --git a/maternal-web/app/settings/page.tsx b/maternal-web/app/settings/page.tsx index 171149d..fa035f2 100644 --- a/maternal-web/app/settings/page.tsx +++ b/maternal-web/app/settings/page.tsx @@ -7,6 +7,7 @@ import { useState, useEffect } from 'react'; import { AppShell } from '@/components/layouts/AppShell/AppShell'; import { ProtectedRoute } from '@/components/common/ProtectedRoute'; import { usersApi } from '@/lib/api/users'; +import { MFASettings } from '@/components/settings/MFASettings'; import { motion } from 'framer-motion'; export default function SettingsPage() { @@ -217,6 +218,17 @@ export default function SettingsPage() { + {/* Security Settings - MFA */} + + + + + + {/* Account Actions */} (null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // TOTP Setup Dialog + const [totpDialogOpen, setTotpDialogOpen] = useState(false); + const [totpSetupData, setTotpSetupData] = useState(null); + const [verificationCode, setVerificationCode] = useState(''); + const [isVerifying, setIsVerifying] = useState(false); + const [copiedCodes, setCopiedCodes] = useState>(new Set()); + + // Email MFA Dialog + const [emailDialogOpen, setEmailDialogOpen] = useState(false); + const [isSettingUpEmail, setIsSettingUpEmail] = useState(false); + + // Disable MFA Dialog + const [disableDialogOpen, setDisableDialogOpen] = useState(false); + const [isDisabling, setIsDisabling] = useState(false); + + // Load MFA status on mount + useEffect(() => { + loadMFAStatus(); + }, []); + + const loadMFAStatus = async () => { + try { + setIsLoading(true); + const status = await mfaApi.getStatus(); + setMFAStatus(status); + setError(null); + } catch (err: any) { + console.error('Failed to load MFA status:', err); + setError('Failed to load MFA status'); + } finally { + setIsLoading(false); + } + }; + + const handleSetupTOTP = async () => { + try { + setIsLoading(true); + const setupData = await mfaApi.setupTOTP(); + setTotpSetupData(setupData); + setTotpDialogOpen(true); + setError(null); + } catch (err: any) { + console.error('Failed to setup TOTP:', err); + setError(err.response?.data?.message || 'Failed to setup authenticator app'); + } finally { + setIsLoading(false); + } + }; + + const handleVerifyTOTP = async () => { + if (!verificationCode || verificationCode.length !== 6) { + setError('Please enter a valid 6-digit code'); + return; + } + + try { + setIsVerifying(true); + setError(null); + await mfaApi.enableTOTP(verificationCode); + setSuccessMessage('Two-factor authentication enabled successfully!'); + setTotpDialogOpen(false); + setVerificationCode(''); + setTotpSetupData(null); + await loadMFAStatus(); + } catch (err: any) { + console.error('Failed to verify TOTP:', err); + setError(err.response?.data?.message || 'Invalid verification code'); + } finally { + setIsVerifying(false); + } + }; + + const handleSetupEmailMFA = async () => { + try { + setIsSettingUpEmail(true); + setError(null); + await mfaApi.setupEmailMFA(); + setSuccessMessage('Email-based 2FA enabled successfully! Check your email for backup codes.'); + setEmailDialogOpen(false); + await loadMFAStatus(); + } catch (err: any) { + console.error('Failed to setup email MFA:', err); + setError(err.response?.data?.message || 'Failed to setup email 2FA'); + } finally { + setIsSettingUpEmail(false); + } + }; + + const handleDisableMFA = async () => { + try { + setIsDisabling(true); + setError(null); + await mfaApi.disableMFA(); + setSuccessMessage('Two-factor authentication disabled'); + setDisableDialogOpen(false); + await loadMFAStatus(); + } catch (err: any) { + console.error('Failed to disable MFA:', err); + setError(err.response?.data?.message || 'Failed to disable 2FA'); + } finally { + setIsDisabling(false); + } + }; + + const copyBackupCode = (code: string, index: number) => { + navigator.clipboard.writeText(code); + setCopiedCodes(new Set(copiedCodes).add(index)); + setTimeout(() => { + setCopiedCodes((prev) => { + const newSet = new Set(prev); + newSet.delete(index); + return newSet; + }); + }, 2000); + }; + + if (isLoading && !mfaStatus) { + return ( + + + + + + ); + } + + return ( + <> + + + + + + Two-Factor Authentication + + {mfaStatus?.enabled && ( + + )} + + + + Add an extra layer of security to your account by enabling two-factor authentication. + + + {error && ( + setError(null)}> + {error} + + )} + + {successMessage && ( + setSuccessMessage(null)}> + {successMessage} + + )} + + {!mfaStatus?.enabled ? ( + + + + + ) : ( + + + Two-factor authentication is currently enabled using{' '} + {mfaStatus.method === 'totp' ? 'Authenticator App' : 'Email'}. + + + + )} + + + + {/* TOTP Setup Dialog */} + setTotpDialogOpen(false)} maxWidth="sm" fullWidth> + Setup Authenticator App + + + Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.) + + + {totpSetupData && ( + <> + {/* QR Code */} + + QR Code + + + {/* Manual Entry Code */} + + + Can't scan? Enter this code manually: + + + {totpSetupData.secret} + + + + {/* Backup Codes */} + + + Save your backup codes + + + Store these codes in a safe place. Each can only be used once. + + + {totpSetupData.backupCodes.map((code, index) => ( + copyBackupCode(code, index)} + > + {copiedCodes.has(index) ? ( + + ) : ( + + )} + + } + > + + + ))} + + + + {/* Verification Code Input */} + setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + helperText="Enter the 6-digit code from your authenticator app" + error={!!error} + disabled={isVerifying} + /> + + )} + + + + + + + + {/* Email MFA Dialog */} + setEmailDialogOpen(false)} maxWidth="sm" fullWidth> + Setup Email Authentication + + + You will receive a verification code via email each time you log in. + + + Backup codes will be sent to your email. Make sure to save them in a secure place. + + + + + + + + + {/* Disable MFA Dialog */} + setDisableDialogOpen(false)} maxWidth="sm" fullWidth> + Disable Two-Factor Authentication? + + + Disabling two-factor authentication will make your account less secure. + + + + + + + + + ); +} diff --git a/maternal-web/lib/api/mfa.ts b/maternal-web/lib/api/mfa.ts new file mode 100644 index 0000000..c825afd --- /dev/null +++ b/maternal-web/lib/api/mfa.ts @@ -0,0 +1,93 @@ +import axios from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3020'; + +export interface MFAStatus { + enabled: boolean; + method?: 'totp' | 'email'; + hasBackupCodes: boolean; +} + +export interface TOTPSetupResult { + secret: string; + qrCodeUrl: string; + backupCodes: string[]; +} + +export const mfaApi = { + // Get MFA status + async getStatus(): Promise { + const response = await axios.get(`${API_BASE_URL}/api/v1/auth/mfa/status`, { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + }); + return response.data; + }, + + // Setup TOTP (Google Authenticator) + async setupTOTP(): Promise { + const response = await axios.post( + `${API_BASE_URL}/api/v1/auth/mfa/totp/setup`, + {}, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + } + ); + return response.data; + }, + + // Enable TOTP + async enableTOTP(code: string): Promise<{ success: boolean; message: string }> { + const response = await axios.post( + `${API_BASE_URL}/api/v1/auth/mfa/totp/enable`, + { code }, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + } + ); + return response.data; + }, + + // Setup Email MFA + async setupEmailMFA(): Promise<{ success: boolean; message: string }> { + const response = await axios.post( + `${API_BASE_URL}/api/v1/auth/mfa/email/setup`, + {}, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + } + ); + return response.data; + }, + + // Disable MFA + async disableMFA(): Promise<{ success: boolean; message: string }> { + const response = await axios.delete(`${API_BASE_URL}/api/v1/auth/mfa`, { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + }); + return response.data; + }, + + // Regenerate backup codes + async regenerateBackupCodes(): Promise<{ success: boolean; backupCodes: string[] }> { + const response = await axios.post( + `${API_BASE_URL}/api/v1/auth/mfa/backup-codes/regenerate`, + {}, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + } + ); + return response.data; + }, +};