2025-05-31 19:25:04 -07:00

742 lines
21 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
TextField,
Button,
Alert,
CircularProgress,
Link,
Divider,
InputAdornment,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Checkbox,
FormControlLabel,
Grid
} from '@mui/material';
import {
Email as EmailIcon,
Security as SecurityIcon,
CheckCircle as CheckCircleIcon,
ErrorOutline as ErrorIcon,
Refresh as RefreshIcon,
DevicesOther as DevicesIcon
} from '@mui/icons-material';
import { ApiClient } from 'services/api-client';
// Email Verification Component
export function EmailVerificationPage() {
const [verificationToken, setVerificationToken] = useState('');
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
const [message, setMessage] = useState('');
const [userType, setUserType] = useState<string>('');
const apiClient = new ApiClient();
useEffect(() => {
// Get token from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
setVerificationToken(token);
handleVerifyEmail(token);
}
}, []);
const handleVerifyEmail = async (token: string) => {
if (!token) {
setStatus('error');
setMessage('Invalid verification link');
return;
}
setLoading(true);
try {
const response = await fetch('/api/1.0/auth/verify-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
});
const data = await response.json();
if (data.success) {
setStatus('success');
setMessage(data.data.message);
setUserType(data.data.userType);
// Redirect to login after 3 seconds
setTimeout(() => {
window.location.href = '/login';
}, 3000);
} else {
setStatus('error');
setMessage(data.error?.message || 'Verification failed');
}
} catch (error) {
setStatus('error');
setMessage('Network error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleResendVerification = async () => {
// This would need the email address - you might want to add an input for it
// or store it in localStorage from the registration process
try {
setLoading(true);
const response = await fetch('/api/1.0/auth/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: localStorage.getItem('pendingVerificationEmail') || ''
}),
});
const data = await response.json();
if (data.success) {
setMessage('Verification email sent! Please check your inbox.');
}
} catch (error) {
setMessage('Failed to resend verification email.');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.50',
p: 2
}}
>
<Card sx={{ maxWidth: 500, width: '100%' }}>
<CardContent sx={{ p: 4 }}>
<Box textAlign="center" mb={3}>
{status === 'pending' && (
<>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" gutterBottom>
Verifying Email
</Typography>
<Typography color="text.secondary">
Please wait while we verify your email address...
</Typography>
</>
)}
{status === 'success' && (
<>
<CheckCircleIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
<Typography variant="h4" gutterBottom color="success.main">
Email Verified!
</Typography>
<Typography color="text.secondary">
Your {userType} account has been successfully activated.
</Typography>
</>
)}
{status === 'error' && (
<>
<ErrorIcon sx={{ fontSize: 64, color: 'error.main', mb: 2 }} />
<Typography variant="h4" gutterBottom color="error.main">
Verification Failed
</Typography>
<Typography color="text.secondary">
We couldn't verify your email address.
</Typography>
</>
)}
</Box>
{loading && (
<Box display="flex" justifyContent="center" my={3}>
<CircularProgress />
</Box>
)}
{message && (
<Alert
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
sx={{ mt: 2 }}
>
{message}
</Alert>
)}
{status === 'success' && (
<Box mt={3} textAlign="center">
<Typography variant="body2" color="text.secondary" mb={2}>
You will be redirected to the login page in a few seconds...
</Typography>
<Button
variant="contained"
onClick={() => window.location.href = '/login'}
fullWidth
>
Go to Login
</Button>
</Box>
)}
{status === 'error' && (
<Box mt={3}>
<Button
variant="outlined"
onClick={handleResendVerification}
disabled={loading}
startIcon={<RefreshIcon />}
fullWidth
sx={{ mb: 2 }}
>
Resend Verification Email
</Button>
<Button
variant="contained"
onClick={() => window.location.href = '/login'}
fullWidth
>
Back to Login
</Button>
</Box>
)}
</CardContent>
</Card>
</Box>
);
}
// MFA Verification Component
export function MFAVerificationDialog({
open,
onClose,
email,
deviceId,
deviceName,
onVerificationSuccess
}: {
open: boolean;
onClose: () => void;
email: string;
deviceId: string;
deviceName: string;
onVerificationSuccess: (authData: any) => void;
}) {
const [code, setCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
const apiClient = new ApiClient();
useEffect(() => {
if (!open) return;
const timer = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(timer);
setError('MFA code has expired. Please try logging in again.');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [open]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleVerifyMFA = async () => {
if (!code || code.length !== 6) {
setError('Please enter a valid 6-digit code');
return;
}
setLoading(true);
setError('');
try {
const response = await fetch('/api/1.0/auth/mfa/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
code,
deviceId,
rememberDevice,
}),
});
const data = await response.json();
if (data.success) {
onVerificationSuccess(data.data);
onClose();
} else {
setError(data.error?.message || 'Invalid verification code');
}
} catch (error) {
setError('Network error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleResendCode = async () => {
setLoading(true);
try {
const response = await fetch('/api/1.0/auth/mfa/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password: '', // This would need to be stored securely or re-entered
deviceId,
deviceName,
}),
});
const data = await response.json();
if (data.success) {
setTimeLeft(600); // Reset timer
setError('');
alert('New verification code sent to your email');
}
} catch (error) {
setError('Failed to resend code');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<SecurityIcon color="primary" />
<Typography variant="h6">
Verify Your Identity
</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Alert severity="info" sx={{ mb: 3 }}>
We've detected a login from a new device: <strong>{deviceName}</strong>
</Alert>
<Typography variant="body1" gutterBottom>
We've sent a 6-digit verification code to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{email}
</Typography>
<TextField
fullWidth
label="Enter 6-digit code"
value={code}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
setCode(value);
setError('');
}}
placeholder="000000"
inputProps={{
maxLength: 6,
style: {
fontSize: 24,
textAlign: 'center',
letterSpacing: 8
}
}}
sx={{ mt: 2, mb: 2 }}
error={!!error}
helperText={error}
/>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="body2" color="text.secondary">
Code expires in: {formatTime(timeLeft)}
</Typography>
<Button
size="small"
onClick={handleResendCode}
disabled={loading || timeLeft > 540} // Allow resend after 1 minute
>
Resend Code
</Button>
</Box>
<FormControlLabel
control={
<Checkbox
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
/>
}
label="Remember this device for 90 days"
/>
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
If you didn't attempt to log in, please change your password immediately.
</Typography>
</Alert>
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleVerifyMFA}
disabled={loading || !code || code.length !== 6 || timeLeft === 0}
>
{loading ? <CircularProgress size={20} /> : 'Verify'}
</Button>
</DialogActions>
</Dialog>
);
}
// Enhanced Registration Success Component
export function RegistrationSuccessDialog({
open,
onClose,
email,
userType
}: {
open: boolean;
onClose: () => void;
email: string;
userType: string;
}) {
const [resendLoading, setResendLoading] = useState(false);
const [resendMessage, setResendMessage] = useState('');
const handleResendVerification = async () => {
setResendLoading(true);
try {
const response = await fetch('/api/1.0/auth/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
setResendMessage(data.success ?
'Verification email sent!' :
'Failed to resend email. Please try again later.'
);
} catch (error) {
setResendMessage('Network error. Please try again.');
} finally {
setResendLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogContent sx={{ textAlign: 'center', p: 4 }}>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h5" gutterBottom>
Check Your Email
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
We've sent a verification link to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{email}
</Typography>
<Alert severity="info" sx={{ mt: 2, mb: 3, textAlign: 'left' }}>
<Typography variant="body2">
<strong>Next steps:</strong>
<br />
1. Check your email inbox (and spam folder)
<br />
2. Click the verification link
<br />
3. Your {userType} account will be activated
</Typography>
</Alert>
{resendMessage && (
<Alert
severity={resendMessage.includes('sent') ? 'success' : 'error'}
sx={{ mb: 2 }}
>
{resendMessage}
</Alert>
)}
</DialogContent>
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
<Button
onClick={handleResendVerification}
disabled={resendLoading}
startIcon={resendLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
>
Resend Email
</Button>
<Button variant="contained" onClick={onClose}>
Got It
</Button>
</DialogActions>
</Dialog>
);
}
// Enhanced Login Component with MFA Support
export function EnhancedLoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [mfaRequired, setMfaRequired] = useState(false);
const [mfaData, setMfaData] = useState<any>(null);
const apiClient = new ApiClient();
// Generate device fingerprint (simplified)
const getDeviceFingerprint = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx!.textBaseline = 'top';
ctx!.font = '14px Arial';
ctx!.fillText('Device fingerprint', 2, 2);
const fingerprint = canvas.toDataURL() +
navigator.userAgent +
navigator.language +
screen.width + 'x' + screen.height;
return btoa(fingerprint).slice(0, 16);
};
const getDeviceName = () => {
const ua = navigator.userAgent;
const browserName = ua.includes('Chrome') ? 'Chrome' :
ua.includes('Firefox') ? 'Firefox' :
ua.includes('Safari') ? 'Safari' : 'Browser';
const osName = ua.includes('Windows') ? 'Windows' :
ua.includes('Mac') ? 'macOS' :
ua.includes('Linux') ? 'Linux' :
ua.includes('Android') ? 'Android' :
ua.includes('iOS') ? 'iOS' : 'Unknown OS';
return `${browserName} on ${osName}`;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/1.0/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
login: email,
password,
}),
});
const data = await response.json();
if (data.success) {
if (data.data.mfaRequired) {
// MFA required for new device
setMfaRequired(true);
setMfaData({
email,
deviceId: data.data.deviceId,
deviceName: data.data.deviceName,
});
} else {
// Normal login success
handleLoginSuccess(data.data);
}
} else {
setError(data.error?.message || 'Login failed');
}
} catch (error) {
setError('Network error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleMFASuccess = (authData: any) => {
handleLoginSuccess(authData);
};
const handleLoginSuccess = (authData: any) => {
// Store tokens
localStorage.setItem('accessToken', authData.accessToken);
localStorage.setItem('refreshToken', authData.refreshToken);
localStorage.setItem('user', JSON.stringify(authData.user));
// Redirect based on user type
const userType = authData.user.userType;
window.location.href = userType === 'employer' ? '/employer-dashboard' : '/candidate-dashboard';
};
return (
<Box component="form" onSubmit={handleLogin} sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
label="Email or Username"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
fullWidth
variant="contained"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? <CircularProgress size={20} /> : 'Sign In'}
</Button>
{/* MFA Dialog */}
{mfaRequired && mfaData && (
<MFAVerificationDialog
open={mfaRequired}
onClose={() => setMfaRequired(false)}
email={mfaData.email}
deviceId={mfaData.deviceId}
deviceName={mfaData.deviceName}
onVerificationSuccess={handleMFASuccess}
/>
)}
</Box>
);
}
// Device Management Component
export function TrustedDevicesManager() {
const [devices, setDevices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// This would need API endpoints to manage trusted devices
useEffect(() => {
// Load trusted devices
setLoading(false);
}, []);
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<DevicesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Trusted Devices
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Manage devices that you've marked as trusted. You won't need to verify
your identity when signing in from these devices.
</Typography>
{devices.length === 0 ? (
<Alert severity="info">
No trusted devices yet. When you log in from a new device and choose
to remember it, it will appear here.
</Alert>
) : (
<Grid container spacing={2}>
{devices.map((device, index) => (
<Grid key={index} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1">
{device.deviceName}
</Typography>
<Typography variant="body2" color="text.secondary">
Added: {new Date(device.addedAt).toLocaleDateString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Last used: {new Date(device.lastUsed).toLocaleDateString()}
</Typography>
<Button
size="small"
color="error"
sx={{ mt: 1 }}
onClick={() => {
// Remove device
}}
>
Remove
</Button>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</CardContent>
</Card>
);
}