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>
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
'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>
|
|
</>
|
|
);
|
|
}
|