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:
2025-10-01 21:06:57 +00:00
parent 6044df7ae8
commit e1842f5c1a
3 changed files with 491 additions and 0 deletions

View File

@@ -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() {
</Card>
</motion.div>
{/* Security Settings - MFA */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.25 }}
>
<Box sx={{ mb: 3 }}>
<MFASettings />
</Box>
</motion.div>
{/* Account Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}

View 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>
</>
);
}

View File

@@ -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<MFAStatus> {
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<TOTPSetupResult> {
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;
},
};