Add MFA Setup UI in Settings page
Implements user interface for setting up and managing two-factor authentication: MFA Setup UI Features: - MFASettings component with full MFA management UI - TOTP setup dialog with QR code display - Manual entry code for authenticator apps - Backup codes display with copy functionality - Verification code input for TOTP enabling - Email MFA setup dialog with confirmation - Disable MFA dialog with warning - Real-time MFA status indicator (enabled/disabled) - Method type chip (Authenticator App / Email) User Experience: - Step-by-step TOTP setup wizard - QR code scanning for easy authenticator app setup - Backup codes shown only once during setup - Copy-to-clipboard for backup codes - Visual feedback (success/error alerts) - Loading states for all async operations - Animated transitions with Framer Motion API Integration: - MFA API client in lib/api/mfa.ts - Get MFA status - Setup TOTP with QR code - Verify and enable TOTP - Setup Email MFA - Disable MFA - Regenerate backup codes Settings Page Updates: - Added Security section with MFA settings - Integrated MFASettings component - Maintains existing settings page structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
386
maternal-web/components/settings/MFASettings.tsx
Normal file
386
maternal-web/components/settings/MFASettings.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Stack,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
QrCode2,
|
||||
Email,
|
||||
ContentCopy,
|
||||
CheckCircle,
|
||||
} from '@mui/icons-material';
|
||||
import { mfaApi, type MFAStatus, type TOTPSetupResult } from '@/lib/api/mfa';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export function MFASettings() {
|
||||
const [mfaStatus, setMFAStatus] = useState<MFAStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// TOTP Setup Dialog
|
||||
const [totpDialogOpen, setTotpDialogOpen] = useState(false);
|
||||
const [totpSetupData, setTotpSetupData] = useState<TOTPSetupResult | null>(null);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [copiedCodes, setCopiedCodes] = useState<Set<number>>(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 (
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Security color="primary" />
|
||||
<Typography variant="h6" fontWeight="600">
|
||||
Two-Factor Authentication
|
||||
</Typography>
|
||||
{mfaStatus?.enabled && (
|
||||
<Chip
|
||||
label={mfaStatus.method === 'totp' ? 'Authenticator App' : 'Email'}
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ ml: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Add an extra layer of security to your account by enabling two-factor authentication.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessMessage(null)}>
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!mfaStatus?.enabled ? (
|
||||
<Stack spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<QrCode2 />}
|
||||
onClick={handleSetupTOTP}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
>
|
||||
Setup Authenticator App (Recommended)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Email />}
|
||||
onClick={() => setEmailDialogOpen(true)}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
>
|
||||
Setup Email Authentication
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">
|
||||
Two-factor authentication is currently enabled using{' '}
|
||||
<strong>{mfaStatus.method === 'totp' ? 'Authenticator App' : 'Email'}</strong>.
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDisableDialogOpen(true)}
|
||||
fullWidth
|
||||
>
|
||||
Disable Two-Factor Authentication
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TOTP Setup Dialog */}
|
||||
<Dialog open={totpDialogOpen} onClose={() => setTotpDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Setup Authenticator App</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</Typography>
|
||||
|
||||
{totpSetupData && (
|
||||
<>
|
||||
{/* QR Code */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
|
||||
<img
|
||||
src={totpSetupData.qrCodeUrl}
|
||||
alt="QR Code"
|
||||
style={{ maxWidth: '200px', width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Manual Entry Code */}
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 3, bgcolor: 'grey.50' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Can't scan? Enter this code manually:
|
||||
</Typography>
|
||||
<Typography variant="body2" fontFamily="monospace" sx={{ mt: 1 }}>
|
||||
{totpSetupData.secret}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Backup Codes */}
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" fontWeight="600" gutterBottom>
|
||||
Save your backup codes
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Store these codes in a safe place. Each can only be used once.
|
||||
</Typography>
|
||||
<List dense>
|
||||
{totpSetupData.backupCodes.map((code, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={() => copyBackupCode(code, index)}
|
||||
>
|
||||
{copiedCodes.has(index) ? (
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
) : (
|
||||
<ContentCopy fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={code}
|
||||
primaryTypographyProps={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
|
||||
{/* Verification Code Input */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
placeholder="Enter 6-digit code"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
helperText="Enter the 6-digit code from your authenticator app"
|
||||
error={!!error}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTotpDialogOpen(false)} disabled={isVerifying}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyTOTP}
|
||||
variant="contained"
|
||||
disabled={isVerifying || verificationCode.length !== 6}
|
||||
>
|
||||
{isVerifying ? <CircularProgress size={20} /> : 'Verify & Enable'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Email MFA Dialog */}
|
||||
<Dialog open={emailDialogOpen} onClose={() => setEmailDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Setup Email Authentication</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
You will receive a verification code via email each time you log in.
|
||||
</Typography>
|
||||
<Alert severity="info">
|
||||
Backup codes will be sent to your email. Make sure to save them in a secure place.
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEmailDialogOpen(false)} disabled={isSettingUpEmail}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSetupEmailMFA}
|
||||
variant="contained"
|
||||
disabled={isSettingUpEmail}
|
||||
>
|
||||
{isSettingUpEmail ? <CircularProgress size={20} /> : 'Enable Email 2FA'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Disable MFA Dialog */}
|
||||
<Dialog open={disableDialogOpen} onClose={() => setDisableDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Disable Two-Factor Authentication?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning">
|
||||
Disabling two-factor authentication will make your account less secure.
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDisableDialogOpen(false)} disabled={isDisabling}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDisableMFA}
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={isDisabling}
|
||||
>
|
||||
{isDisabling ? <CircularProgress size={20} /> : 'Disable 2FA'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user