Implementing MFA
This commit is contained in:
parent
9b320366ce
commit
35701d9719
@ -190,6 +190,9 @@ RUN pip install "redis[hiredis]>=4.5.0"
|
||||
# New backend implementation
|
||||
RUN pip install fastapi uvicorn "python-jose[cryptography]" bcrypt python-multipart
|
||||
|
||||
# Needed for email verification
|
||||
RUN pip install pyyaml user-agents cryptography
|
||||
|
||||
# Automatic type conversion pydantic -> typescript
|
||||
RUN pip install pydantic typing-inspect jinja2
|
||||
RUN apt-get update \
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Dialog,
|
||||
|
711
frontend/src/components/EmailVerificationComponents.tsx
Normal file
711
frontend/src/components/EmailVerificationComponents.tsx
Normal file
@ -0,0 +1,711 @@
|
||||
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 { useAuth } from 'hooks/AuthContext';
|
||||
import { BackstoryPageProps } from './BackstoryTab';
|
||||
|
||||
// Email Verification Component
|
||||
const EmailVerificationPage = (props: BackstoryPageProps) => {
|
||||
const { apiClient } = useAuth();
|
||||
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>('');
|
||||
|
||||
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
|
||||
const MFAVerificationDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
email,
|
||||
deviceId,
|
||||
deviceName,
|
||||
onVerificationSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
email: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
onVerificationSuccess: (authData: any) => void;
|
||||
}) => {
|
||||
const { apiClient } = useAuth();
|
||||
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
|
||||
|
||||
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
|
||||
const 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
|
||||
const LoginForm = () => {
|
||||
const { apiClient } = useAuth();
|
||||
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 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
|
||||
const 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>
|
||||
);
|
||||
}
|
||||
|
||||
export { EmailVerificationPage, MFAVerificationDialog, TrustedDevicesManager, RegistrationSuccessDialog, LoginForm };
|
742
frontend/src/components/MFA.tsx
Normal file
742
frontend/src/components/MFA.tsx
Normal file
@ -0,0 +1,742 @@
|
||||
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>
|
||||
);
|
||||
}
|
1308
frontend/src/components/RegistrationForms.tsx
Normal file
1308
frontend/src/components/RegistrationForms.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -55,6 +55,9 @@ import { BackstoryLogo } from 'components/ui/BackstoryLogo';
|
||||
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LoginForm } from "components/EmailVerificationComponents";
|
||||
import { CandidateRegistrationForm, EmployerRegistrationForm } from "components/RegistrationForms";
|
||||
|
||||
type UserRegistrationType = 'candidate' | 'employer';
|
||||
|
||||
interface LoginRequest {
|
||||
@ -412,327 +415,329 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||
)}
|
||||
|
||||
{tabValue === 0 && (
|
||||
<Box component="form" onSubmit={handleLogin}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Sign In
|
||||
</Typography>
|
||||
<LoginForm />
|
||||
// <Box component="form" onSubmit={handleLogin}>
|
||||
// <Typography variant="h5" gutterBottom>
|
||||
// Sign In
|
||||
// </Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username or Email"
|
||||
type="text"
|
||||
value={loginForm.login}
|
||||
onChange={handleLoginChange}
|
||||
margin="normal"
|
||||
required
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
placeholder="Enter username or email"
|
||||
/>
|
||||
// <TextField
|
||||
// fullWidth
|
||||
// label="Username or Email"
|
||||
// type="text"
|
||||
// value={loginForm.login}
|
||||
// onChange={handleLoginChange}
|
||||
// margin="normal"
|
||||
// required
|
||||
// disabled={loading}
|
||||
// variant="outlined"
|
||||
// placeholder="Enter username or email"
|
||||
// />
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type={showLoginPassword ? 'text' : 'password'}
|
||||
value={loginForm.password}
|
||||
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
autoComplete='current-password'
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={toggleLoginPasswordVisibility}
|
||||
edge="end"
|
||||
disabled={loading}
|
||||
>
|
||||
{showLoginPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
// <TextField
|
||||
// fullWidth
|
||||
// label="Password"
|
||||
// type={showLoginPassword ? 'text' : 'password'}
|
||||
// value={loginForm.password}
|
||||
// onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
|
||||
// margin="normal"
|
||||
// required
|
||||
// disabled={loading}
|
||||
// variant="outlined"
|
||||
// autoComplete='current-password'
|
||||
// slotProps={{
|
||||
// input: {
|
||||
// endAdornment: (
|
||||
// <InputAdornment position="end">
|
||||
// <IconButton
|
||||
// aria-label="toggle password visibility"
|
||||
// onClick={toggleLoginPasswordVisibility}
|
||||
// edge="end"
|
||||
// disabled={loading}
|
||||
// >
|
||||
// {showLoginPassword ? <VisibilityOff /> : <Visibility />}
|
||||
// </IconButton>
|
||||
// </InputAdornment>
|
||||
// )
|
||||
// }
|
||||
// }}
|
||||
// />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <Person />}
|
||||
>
|
||||
{loading ? 'Signing In...' : 'Sign In'}
|
||||
</Button>
|
||||
</Box>
|
||||
// <Button
|
||||
// type="submit"
|
||||
// fullWidth
|
||||
// variant="contained"
|
||||
// sx={{ mt: 3, mb: 2 }}
|
||||
// disabled={loading}
|
||||
// startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <Person />}
|
||||
// >
|
||||
// {loading ? 'Signing In...' : 'Sign In'}
|
||||
// </Button>
|
||||
// </Box>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<Box component="form" onSubmit={handleRegister}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Create Account
|
||||
</Typography>
|
||||
<CandidateRegistrationForm />
|
||||
// <Box component="form" onSubmit={handleRegister}>
|
||||
// <Typography variant="h5" gutterBottom>
|
||||
// Create Account
|
||||
// </Typography>
|
||||
|
||||
{/* User Type Selection */}
|
||||
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
|
||||
<FormLabel component="legend" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Select Account Type</Typography>
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={registerForm.userType}
|
||||
onChange={handleUserTypeChange}
|
||||
sx={{ gap: 1 }}
|
||||
>
|
||||
{(['candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
|
||||
const info = getUserTypeInfo(userType);
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={userType}
|
||||
value={userType}
|
||||
disabled={loading || userType === 'employer'}
|
||||
control={<Radio />}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
{info.icon}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||
{info.title}
|
||||
{userType === 'employer' && (
|
||||
<Chip
|
||||
label="Coming Soon"
|
||||
size="small"
|
||||
color="warning"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{info.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1,
|
||||
m: 0,
|
||||
bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
|
||||
'&:hover': {
|
||||
bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
|
||||
},
|
||||
opacity: userType === 'employer' ? 0.6 : 1
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
// {/* User Type Selection */}
|
||||
// <FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
|
||||
// <FormLabel component="legend" sx={{ mb: 2 }}>
|
||||
// <Typography variant="h6">Select Account Type</Typography>
|
||||
// </FormLabel>
|
||||
// <RadioGroup
|
||||
// value={registerForm.userType}
|
||||
// onChange={handleUserTypeChange}
|
||||
// sx={{ gap: 1 }}
|
||||
// >
|
||||
// {(['candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
|
||||
// const info = getUserTypeInfo(userType);
|
||||
// return (
|
||||
// <FormControlLabel
|
||||
// key={userType}
|
||||
// value={userType}
|
||||
// disabled={loading || userType === 'employer'}
|
||||
// control={<Radio />}
|
||||
// label={
|
||||
// <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
// {info.icon}
|
||||
// <Box>
|
||||
// <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||
// {info.title}
|
||||
// {userType === 'employer' && (
|
||||
// <Chip
|
||||
// label="Coming Soon"
|
||||
// size="small"
|
||||
// color="warning"
|
||||
// sx={{ ml: 1 }}
|
||||
// />
|
||||
// )}
|
||||
// </Typography>
|
||||
// <Typography variant="body2" color="text.secondary">
|
||||
// {info.description}
|
||||
// </Typography>
|
||||
// </Box>
|
||||
// </Box>
|
||||
// }
|
||||
// sx={{
|
||||
// border: '1px solid',
|
||||
// borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
|
||||
// borderRadius: 1,
|
||||
// p: 1,
|
||||
// m: 0,
|
||||
// bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
|
||||
// '&:hover': {
|
||||
// bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
|
||||
// },
|
||||
// opacity: userType === 'employer' ? 0.6 : 1
|
||||
// }}
|
||||
// />
|
||||
// );
|
||||
// })}
|
||||
// </RadioGroup>
|
||||
// </FormControl>
|
||||
|
||||
{/* Employer Placeholder */}
|
||||
{registerForm.userType === 'employer' && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Employer Registration Coming Soon
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
We're currently building our employer features. If you're interested in posting jobs
|
||||
and finding talent, please contact our support team at support@backstory.com for
|
||||
early access.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
// {/* Employer Placeholder */}
|
||||
// {registerForm.userType === 'employer' && (
|
||||
// <Alert severity="info" sx={{ mb: 3 }}>
|
||||
// <Typography variant="h6" gutterBottom>
|
||||
// Employer Registration Coming Soon
|
||||
// </Typography>
|
||||
// <Typography variant="body2">
|
||||
// We're currently building our employer features. If you're interested in posting jobs
|
||||
// and finding talent, please contact our support team at support@backstory.com for
|
||||
// early access.
|
||||
// </Typography>
|
||||
// </Alert>
|
||||
// )}
|
||||
|
||||
{/* Basic Information Fields */}
|
||||
{registerForm.userType !== 'employer' && (
|
||||
<>
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
value={registerForm.firstName}
|
||||
onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
|
||||
required
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Grid>
|
||||
// {/* Basic Information Fields */}
|
||||
// {registerForm.userType !== 'employer' && (
|
||||
// <>
|
||||
// <Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
// <Grid size={{ xs: 12, sm: 6 }}>
|
||||
// <TextField
|
||||
// fullWidth
|
||||
// label="First Name"
|
||||
// value={registerForm.firstName}
|
||||
// onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
|
||||
// required
|
||||
// disabled={loading}
|
||||
// variant="outlined"
|
||||
// />
|
||||
// </Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
value={registerForm.lastName}
|
||||
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
|
||||
required
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
// <Grid size={{ xs: 12, sm: 6 }}>
|
||||
// <TextField
|
||||
// fullWidth
|
||||
// label="Last Name"
|
||||
// value={registerForm.lastName}
|
||||
// onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
|
||||
// required
|
||||
// disabled={loading}
|
||||
// variant="outlined"
|
||||
// />
|
||||
// </Grid>
|
||||
// </Grid>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
value={registerForm.username}
|
||||
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
/>
|
||||
// <TextField
|
||||
// fullWidth
|
||||
// label="Username"
|
||||
// value={registerForm.username}
|
||||
// onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
|
||||
// margin="normal"
|
||||
// required
|
||||
// disabled={loading}
|
||||
// variant="outlined"
|
||||
// />
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
value={registerForm.email}
|
||||
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
/>
|
||||
// <TextField
|
||||
// fullWidth
|
||||
// label="Email"
|
||||
// type="email"
|
||||
// value={registerForm.email}
|
||||
// onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
|
||||
// margin="normal"
|
||||
// required
|
||||
// disabled={loading}
|
||||
// variant="outlined"
|
||||
// />
|
||||
|
||||
{/* Conditional fields based on user type */}
|
||||
{registerForm.userType === 'candidate' && (
|
||||
<>
|
||||
<PhoneInput
|
||||
label="Phone (Optional)"
|
||||
placeholder="Enter phone number"
|
||||
defaultCountry='US'
|
||||
value={registerForm.phone}
|
||||
disabled={loading}
|
||||
onChange={(v) => setPhone(v as E164Number)}
|
||||
/>
|
||||
// {/* Conditional fields based on user type */}
|
||||
// {registerForm.userType === 'candidate' && (
|
||||
// <>
|
||||
// <PhoneInput
|
||||
// label="Phone (Optional)"
|
||||
// placeholder="Enter phone number"
|
||||
// defaultCountry='US'
|
||||
// value={registerForm.phone}
|
||||
// disabled={loading}
|
||||
// onChange={(v) => setPhone(v as E164Number)}
|
||||
// />
|
||||
|
||||
<LocationInput
|
||||
value={location}
|
||||
onChange={handleLocationChange}
|
||||
showCity
|
||||
helperText="Include your city for more specific job matches"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
// <LocationInput
|
||||
// value={location}
|
||||
// onChange={handleLocationChange}
|
||||
// showCity
|
||||
// helperText="Include your city for more specific job matches"
|
||||
// />
|
||||
// </>
|
||||
// )}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type={showRegisterPassword ? 'text' : 'password'}
|
||||
value={registerForm.password}
|
||||
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||
margin="normal"
|
||||
required
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={toggleRegisterPasswordVisibility}
|
||||
edge="end"
|
||||
disabled={loading}
|
||||
>
|
||||
{showRegisterPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
// <TextField
|
||||
// fullWidth
|
||||
// label="Password"
|
||||
// type={showRegisterPassword ? 'text' : 'password'}
|
||||
// value={registerForm.password}
|
||||
// onChange={(e) => handlePasswordChange(e.target.value)}
|
||||
// margin="normal"
|
||||
// required
|
||||
// disabled={loading}
|
||||
// variant="outlined"
|
||||
// slotProps={{
|
||||
// input: {
|
||||
// endAdornment: (
|
||||
// <InputAdornment position="end">
|
||||
// <IconButton
|
||||
// aria-label="toggle password visibility"
|
||||
// onClick={toggleRegisterPasswordVisibility}
|
||||
// edge="end"
|
||||
// disabled={loading}
|
||||
// >
|
||||
// {showRegisterPassword ? <VisibilityOff /> : <Visibility />}
|
||||
// </IconButton>
|
||||
// </InputAdornment>
|
||||
// )
|
||||
// }
|
||||
// }}
|
||||
// />
|
||||
|
||||
{/* Password Requirements */}
|
||||
{registerForm.password.length > 0 && (
|
||||
<Box sx={{ mt: 1, mb: 1 }}>
|
||||
<Button
|
||||
onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
|
||||
startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
|
||||
size="small"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Password Requirements
|
||||
</Button>
|
||||
<Collapse in={showPasswordRequirements}>
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<List dense>
|
||||
{passwordRequirements.map((requirement, index) => (
|
||||
<ListItem key={index} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
{requirement.met ? (
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
) : (
|
||||
<Cancel color="error" fontSize="small" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={requirement.label}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
fontSize: '0.875rem',
|
||||
color: requirement.met ? 'success.main' : 'error.main'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
// {/* Password Requirements */}
|
||||
// {registerForm.password.length > 0 && (
|
||||
// <Box sx={{ mt: 1, mb: 1 }}>
|
||||
// <Button
|
||||
// onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
|
||||
// startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
|
||||
// size="small"
|
||||
// sx={{ mb: 1 }}
|
||||
// >
|
||||
// Password Requirements
|
||||
// </Button>
|
||||
// <Collapse in={showPasswordRequirements}>
|
||||
// <Paper variant="outlined" sx={{ p: 2 }}>
|
||||
// <List dense>
|
||||
// {passwordRequirements.map((requirement, index) => (
|
||||
// <ListItem key={index} sx={{ py: 0.5 }}>
|
||||
// <ListItemIcon sx={{ minWidth: 36 }}>
|
||||
// {requirement.met ? (
|
||||
// <CheckCircle color="success" fontSize="small" />
|
||||
// ) : (
|
||||
// <Cancel color="error" fontSize="small" />
|
||||
// )}
|
||||
// </ListItemIcon>
|
||||
// <ListItemText
|
||||
// primary={requirement.label}
|
||||
// sx={{
|
||||
// '& .MuiListItemText-primary': {
|
||||
// fontSize: '0.875rem',
|
||||
// color: requirement.met ? 'success.main' : 'error.main'
|
||||
// }
|
||||
// }}
|
||||
// />
|
||||
// </ListItem>
|
||||
// ))}
|
||||
// </List>
|
||||
// </Paper>
|
||||
// </Collapse>
|
||||
// </Box>
|
||||
// )}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Confirm Password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={registerForm.confirmPassword}
|
||||
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
error={hasPasswordMatchError}
|
||||
helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle confirm password visibility"
|
||||
onClick={toggleConfirmPasswordVisibility}
|
||||
edge="end"
|
||||
disabled={loading}
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
// <TextField
|
||||
// fullWidth
|
||||
// label="Confirm Password"
|
||||
// type={showConfirmPassword ? 'text' : 'password'}
|
||||
// value={registerForm.confirmPassword}
|
||||
// onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
|
||||
// margin="normal"
|
||||
// required
|
||||
// disabled={loading}
|
||||
// variant="outlined"
|
||||
// error={hasPasswordMatchError}
|
||||
// helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
|
||||
// slotProps={{
|
||||
// input: {
|
||||
// endAdornment: (
|
||||
// <InputAdornment position="end">
|
||||
// <IconButton
|
||||
// aria-label="toggle confirm password visibility"
|
||||
// onClick={toggleConfirmPasswordVisibility}
|
||||
// edge="end"
|
||||
// disabled={loading}
|
||||
// >
|
||||
// {showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
// </IconButton>
|
||||
// </InputAdornment>
|
||||
// )
|
||||
// }
|
||||
// }}
|
||||
// />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
|
||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
|
||||
>
|
||||
{loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
// <Button
|
||||
// type="submit"
|
||||
// fullWidth
|
||||
// variant="contained"
|
||||
// sx={{ mt: 3, mb: 2 }}
|
||||
// disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
|
||||
// startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
|
||||
// >
|
||||
// {loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
|
||||
// </Button>
|
||||
// </>
|
||||
// )}
|
||||
// </Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
|
@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const RegisterPage = () => {
|
||||
return (
|
||||
<pre>
|
||||
+------------------------------------------------------+
|
||||
| BACKSTORY [Logo] Home |
|
||||
+------------------------------------------------------+
|
||||
| |
|
||||
| Create Your Candidate Account |
|
||||
| |
|
||||
| [ ] Email |
|
||||
| [ ] Password |
|
||||
| [ ] Confirm Password |
|
||||
| |
|
||||
| [ ] I agree to the Terms & Privacy Policy |
|
||||
| |
|
||||
| [Create Account] |
|
||||
| |
|
||||
| Already have an account? [Login] |
|
||||
| |
|
||||
| --- or --- |
|
||||
| |
|
||||
| [Continue with Google] |
|
||||
| [Continue with LinkedIn] |
|
||||
| |
|
||||
+------------------------------------------------------+
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
RegisterPage
|
||||
};
|
@ -148,7 +148,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Enhanced Response Handlers with Date Conversion
|
||||
// Response Handlers with Date Conversion
|
||||
// ============================
|
||||
|
||||
/**
|
||||
@ -202,6 +202,296 @@ class ApiClient {
|
||||
return extractedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create candidate with email verification
|
||||
*/
|
||||
async createCandidateWithVerification(
|
||||
candidate: CreateCandidateWithVerificationRequest
|
||||
): Promise<RegistrationResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/candidates`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest(candidate))
|
||||
});
|
||||
|
||||
return handleApiResponse<RegistrationResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create employer with email verification
|
||||
*/
|
||||
async createEmployerWithVerification(
|
||||
employer: CreateEmployerWithVerificationRequest
|
||||
): Promise<RegistrationResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/employers`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest(employer))
|
||||
});
|
||||
|
||||
return handleApiResponse<RegistrationResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email address
|
||||
*/
|
||||
async verifyEmail(request: EmailVerificationRequest): Promise<EmailVerificationResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/verify-email`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest(request))
|
||||
});
|
||||
|
||||
return handleApiResponse<EmailVerificationResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend verification email
|
||||
*/
|
||||
async resendVerificationEmail(request: ResendVerificationRequest): Promise<{ message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/resend-verification`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest(request))
|
||||
});
|
||||
|
||||
return handleApiResponse<{ message: string }>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request MFA for new device
|
||||
*/
|
||||
async requestMFA(request: MFARequest): Promise<MFARequestResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/mfa/request`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest(request))
|
||||
});
|
||||
|
||||
return handleApiResponse<MFARequestResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA code
|
||||
*/
|
||||
async verifyMFA(request: MFAVerifyRequest): Promise<Types.AuthResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest(request))
|
||||
});
|
||||
|
||||
return handleApiResponse<Types.AuthResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced login with device detection
|
||||
*/
|
||||
async loginEnhanced(email: string, password: string): Promise<Types.AuthResponse | MFARequestResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest({ login: email, password }))
|
||||
});
|
||||
|
||||
// This could return either a full auth response or MFA request
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || 'Login failed');
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout with token revocation
|
||||
*/
|
||||
async logoutEnhanced(accessToken: string, refreshToken: string): Promise<{ message: string; tokensRevoked: any }> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest({
|
||||
accessToken,
|
||||
refreshToken
|
||||
}))
|
||||
});
|
||||
|
||||
return handleApiResponse<{ message: string; tokensRevoked: any }>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout from all devices
|
||||
*/
|
||||
async logoutAllDevices(): Promise<{ message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/logout-all`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders
|
||||
});
|
||||
|
||||
return handleApiResponse<{ message: string }>(response);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Device Management Methods
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Get trusted devices for current user
|
||||
*/
|
||||
async getTrustedDevices(): Promise<TrustedDevice[]> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/trusted-devices`, {
|
||||
headers: this.defaultHeaders
|
||||
});
|
||||
|
||||
return handleApiResponse<TrustedDevice[]>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trusted device
|
||||
*/
|
||||
async removeTrustedDevice(deviceId: string): Promise<{ message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/trusted-devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.defaultHeaders
|
||||
});
|
||||
|
||||
return handleApiResponse<{ message: string }>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security log for current user
|
||||
*/
|
||||
async getSecurityLog(days: number = 7): Promise<SecurityEvent[]> {
|
||||
const response = await fetch(`${this.baseUrl}/auth/security-log?days=${days}`, {
|
||||
headers: this.defaultHeaders
|
||||
});
|
||||
|
||||
return handleApiResponse<SecurityEvent[]>(response);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Admin Methods (if user has admin role)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Get pending user verifications (admin only)
|
||||
*/
|
||||
async getPendingVerifications(): Promise<PendingVerification[]> {
|
||||
const response = await fetch(`${this.baseUrl}/admin/pending-verifications`, {
|
||||
headers: this.defaultHeaders
|
||||
});
|
||||
|
||||
return handleApiResponse<PendingVerification[]>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually verify user (admin only)
|
||||
*/
|
||||
async manuallyVerifyUser(userId: string, reason: string): Promise<{ message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/admin/verify-user/${userId}`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest({ reason }))
|
||||
});
|
||||
|
||||
return handleApiResponse<{ message: string }>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user security events (admin only)
|
||||
*/
|
||||
async getUserSecurityEvents(userId: string, days: number = 30): Promise<SecurityEvent[]> {
|
||||
const response = await fetch(`${this.baseUrl}/admin/users/${userId}/security-events?days=${days}`, {
|
||||
headers: this.defaultHeaders
|
||||
});
|
||||
|
||||
return handleApiResponse<SecurityEvent[]>(response);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Utility Methods
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Generate device fingerprint for MFA
|
||||
*/
|
||||
generateDeviceFingerprint(): string {
|
||||
// Create a basic device fingerprint
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
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 +
|
||||
// (navigator.platform || '') +
|
||||
(navigator.cookieEnabled ? '1' : '0');
|
||||
|
||||
// Create a hash-like string from the fingerprint
|
||||
let hash = 0;
|
||||
for (let i = 0; i < fingerprint.length; i++) {
|
||||
const char = fingerprint.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(16).slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device name from user agent
|
||||
*/
|
||||
getDeviceName(): string {
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
const browser = ua.includes('Chrome') ? 'Chrome' :
|
||||
ua.includes('Firefox') ? 'Firefox' :
|
||||
ua.includes('Safari') ? 'Safari' :
|
||||
ua.includes('Edge') ? 'Edge' : 'Browser';
|
||||
|
||||
const os = ua.includes('Windows') ? 'Windows' :
|
||||
ua.includes('Mac') ? 'macOS' :
|
||||
ua.includes('Linux') ? 'Linux' :
|
||||
ua.includes('Android') ? 'Android' :
|
||||
ua.includes('iOS') ? 'iOS' : 'Unknown OS';
|
||||
|
||||
return `${browser} on ${os}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email verification is pending
|
||||
*/
|
||||
isEmailVerificationPending(): boolean {
|
||||
return localStorage.getItem('pendingEmailVerification') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set email verification pending status
|
||||
*/
|
||||
setPendingEmailVerification(email: string, pending: boolean = true): void {
|
||||
if (pending) {
|
||||
localStorage.setItem('pendingEmailVerification', 'true');
|
||||
localStorage.setItem('pendingVerificationEmail', email);
|
||||
} else {
|
||||
localStorage.removeItem('pendingEmailVerification');
|
||||
localStorage.removeItem('pendingVerificationEmail');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending verification email
|
||||
*/
|
||||
getPendingVerificationEmail(): string | null {
|
||||
return localStorage.getItem('pendingVerificationEmail');
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Authentication Methods
|
||||
// ============================
|
||||
@ -802,6 +1092,222 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================
|
||||
// Enhanced Request/Response Types
|
||||
// ============================
|
||||
|
||||
export interface CreateCandidateWithVerificationRequest {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CreateEmployerWithVerificationRequest {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
companyName: string;
|
||||
industry: string;
|
||||
companySize: string;
|
||||
companyDescription: string;
|
||||
websiteUrl?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface EmailVerificationRequest {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ResendVerificationRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface MFARequest {
|
||||
email: string;
|
||||
password: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
export interface MFAVerifyRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
deviceId: string;
|
||||
rememberDevice: boolean;
|
||||
}
|
||||
|
||||
export interface RegistrationResponse {
|
||||
message: string;
|
||||
email: string;
|
||||
verificationRequired: boolean;
|
||||
}
|
||||
|
||||
export interface EmailVerificationResponse {
|
||||
message: string;
|
||||
accountActivated: boolean;
|
||||
userType: string;
|
||||
}
|
||||
|
||||
export interface MFARequestResponse {
|
||||
mfaRequired: boolean;
|
||||
message: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export interface TrustedDevice {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
browser: string;
|
||||
browserVersion: string;
|
||||
os: string;
|
||||
osVersion: string;
|
||||
addedAt: string;
|
||||
lastUsed: string;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Additional Types
|
||||
// ============================
|
||||
|
||||
export interface SecurityEvent {
|
||||
timestamp: string;
|
||||
userId: string;
|
||||
eventType: 'login' | 'logout' | 'mfa_request' | 'mfa_verify' | 'password_change' | 'email_verify' | 'device_add' | 'device_remove';
|
||||
details: {
|
||||
ipAddress?: string;
|
||||
deviceName?: string;
|
||||
success?: boolean;
|
||||
failureReason?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PendingVerification {
|
||||
id: string;
|
||||
email: string;
|
||||
userType: 'candidate' | 'employer';
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Usage Examples
|
||||
// ============================
|
||||
|
||||
/*
|
||||
// Registration with email verification
|
||||
const apiClient = new EnhancedApiClient();
|
||||
|
||||
try {
|
||||
const result = await apiClient.createCandidateWithVerification({
|
||||
email: 'user@example.com',
|
||||
username: 'johndoe',
|
||||
password: 'SecurePassword123!',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
phone: '+1234567890'
|
||||
});
|
||||
|
||||
console.log(result.message); // "Registration successful! Please check your email..."
|
||||
|
||||
// Set pending verification status
|
||||
apiClient.setPendingEmailVerification(result.email);
|
||||
|
||||
// Show success dialog to user
|
||||
showRegistrationSuccessDialog(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
}
|
||||
|
||||
// Enhanced login with MFA support
|
||||
try {
|
||||
const loginResult = await apiClient.loginEnhanced('user@example.com', 'password');
|
||||
|
||||
if ('mfaRequired' in loginResult && loginResult.mfaRequired) {
|
||||
// Show MFA dialog
|
||||
showMFADialog({
|
||||
email: 'user@example.com',
|
||||
deviceId: loginResult.deviceId!,
|
||||
deviceName: loginResult.message || 'Unknown device'
|
||||
});
|
||||
} else {
|
||||
// Normal login success
|
||||
const authData = loginResult as Types.AuthResponse;
|
||||
handleLoginSuccess(authData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
|
||||
// Email verification
|
||||
try {
|
||||
const verificationResult = await apiClient.verifyEmail({
|
||||
token: 'verification-token-from-email'
|
||||
});
|
||||
|
||||
console.log(verificationResult.message); // "Email verified successfully!"
|
||||
|
||||
// Clear pending verification
|
||||
apiClient.setPendingEmailVerification('', false);
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = '/login';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Email verification failed:', error);
|
||||
}
|
||||
|
||||
// MFA verification
|
||||
try {
|
||||
const mfaResult = await apiClient.verifyMFA({
|
||||
email: 'user@example.com',
|
||||
code: '123456',
|
||||
deviceId: 'device-fingerprint',
|
||||
rememberDevice: true
|
||||
});
|
||||
|
||||
// Handle successful login
|
||||
handleLoginSuccess(mfaResult);
|
||||
|
||||
} catch (error) {
|
||||
console.error('MFA verification failed:', error);
|
||||
}
|
||||
|
||||
// Device management
|
||||
try {
|
||||
const devices = await apiClient.getTrustedDevices();
|
||||
|
||||
devices.forEach(device => {
|
||||
console.log(`Device: ${device.deviceName}, Last used: ${device.lastUsed}`);
|
||||
});
|
||||
|
||||
// Remove a device
|
||||
await apiClient.removeTrustedDevice('device-id-to-remove');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Device management failed:', error);
|
||||
}
|
||||
|
||||
// Security log
|
||||
try {
|
||||
const securityEvents = await apiClient.getSecurityLog(30); // Last 30 days
|
||||
|
||||
securityEvents.forEach(event => {
|
||||
console.log(`${event.timestamp}: ${event.eventType} from ${event.details.deviceName}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load security log:', error);
|
||||
}
|
||||
*/
|
||||
|
||||
// ============================
|
||||
// React Hooks for Streaming with Date Conversion
|
||||
// ============================
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Generated TypeScript types from Pydantic models
|
||||
// Source: src/backend/models.py
|
||||
// Generated on: 2025-05-31T18:20:52.253576
|
||||
// Generated on: 2025-06-01T01:48:43.853130
|
||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||
|
||||
// ============================
|
||||
@ -378,6 +378,10 @@ export interface Education {
|
||||
location?: Location;
|
||||
}
|
||||
|
||||
export interface EmailVerificationRequest {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface Employer {
|
||||
id?: string;
|
||||
email: string;
|
||||
@ -535,6 +539,20 @@ export interface Location {
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface MFARequest {
|
||||
email: string;
|
||||
password: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
export interface MFAVerifyRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
deviceId: string;
|
||||
rememberDevice?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageReaction {
|
||||
userId: string;
|
||||
reaction: string;
|
||||
@ -609,6 +627,10 @@ export interface RefreshToken {
|
||||
revokedReason?: string;
|
||||
}
|
||||
|
||||
export interface ResendVerificationRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface RetrievalParameters {
|
||||
searchType: "similarity" | "mmr" | "hybrid" | "keyword";
|
||||
topK: number;
|
||||
|
@ -348,7 +348,141 @@ class RedisDatabase:
|
||||
"""Delete job"""
|
||||
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
||||
await self.redis.delete(key)
|
||||
|
||||
|
||||
# MFA and Email Verification operations
|
||||
async def store_email_verification_token(self, email: str, token: str, user_type: str, user_data: dict) -> bool:
|
||||
"""Store email verification token with user data"""
|
||||
try:
|
||||
key = f"email_verification:{token}"
|
||||
verification_data = {
|
||||
"email": email.lower(),
|
||||
"user_type": user_type,
|
||||
"user_data": user_data,
|
||||
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"verified": False
|
||||
}
|
||||
|
||||
# Store with 24 hour expiration
|
||||
await self.redis.setex(
|
||||
key,
|
||||
24 * 60 * 60, # 24 hours in seconds
|
||||
json.dumps(verification_data, default=str)
|
||||
)
|
||||
|
||||
logger.debug(f"📧 Stored email verification token for {email}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error storing email verification token: {e}")
|
||||
return False
|
||||
|
||||
async def get_email_verification_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Retrieve email verification token data"""
|
||||
try:
|
||||
key = f"email_verification:{token}"
|
||||
data = await self.redis.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving email verification token: {e}")
|
||||
return None
|
||||
|
||||
async def mark_email_verified(self, token: str) -> bool:
|
||||
"""Mark email verification token as used"""
|
||||
try:
|
||||
key = f"email_verification:{token}"
|
||||
token_data = await self.get_email_verification_token(token)
|
||||
if token_data:
|
||||
token_data["verified"] = True
|
||||
token_data["verified_at"] = datetime.now(timezone.utc).isoformat()
|
||||
await self.redis.setex(
|
||||
key,
|
||||
24 * 60 * 60, # Keep for remaining TTL
|
||||
json.dumps(token_data, default=str)
|
||||
)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error marking email verified: {e}")
|
||||
return False
|
||||
|
||||
async def store_mfa_code(self, email: str, code: str, device_id: str) -> bool:
|
||||
"""Store MFA code for verification"""
|
||||
try:
|
||||
key = f"mfa_code:{email.lower()}:{device_id}"
|
||||
mfa_data = {
|
||||
"code": code,
|
||||
"email": email.lower(),
|
||||
"device_id": device_id,
|
||||
"expires_at": (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"attempts": 0,
|
||||
"verified": False
|
||||
}
|
||||
|
||||
# Store with 10 minute expiration
|
||||
await self.redis.setex(
|
||||
key,
|
||||
10 * 60, # 10 minutes in seconds
|
||||
json.dumps(mfa_data, default=str)
|
||||
)
|
||||
|
||||
logger.debug(f"🔐 Stored MFA code for {email}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error storing MFA code: {e}")
|
||||
return False
|
||||
|
||||
async def get_mfa_code(self, email: str, device_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Retrieve MFA code data"""
|
||||
try:
|
||||
key = f"mfa_code:{email.lower()}:{device_id}"
|
||||
data = await self.redis.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving MFA code: {e}")
|
||||
return None
|
||||
|
||||
async def increment_mfa_attempts(self, email: str, device_id: str) -> int:
|
||||
"""Increment MFA verification attempts"""
|
||||
try:
|
||||
key = f"mfa_code:{email.lower()}:{device_id}"
|
||||
mfa_data = await self.get_mfa_code(email, device_id)
|
||||
if mfa_data:
|
||||
mfa_data["attempts"] += 1
|
||||
await self.redis.setex(
|
||||
key,
|
||||
10 * 60, # Keep original TTL
|
||||
json.dumps(mfa_data, default=str)
|
||||
)
|
||||
return mfa_data["attempts"]
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error incrementing MFA attempts: {e}")
|
||||
return 0
|
||||
|
||||
async def mark_mfa_verified(self, email: str, device_id: str) -> bool:
|
||||
"""Mark MFA code as verified"""
|
||||
try:
|
||||
key = f"mfa_code:{email.lower()}:{device_id}"
|
||||
mfa_data = await self.get_mfa_code(email, device_id)
|
||||
if mfa_data:
|
||||
mfa_data["verified"] = True
|
||||
mfa_data["verified_at"] = datetime.now(timezone.utc).isoformat()
|
||||
await self.redis.setex(
|
||||
key,
|
||||
10 * 60, # Keep for remaining TTL
|
||||
json.dumps(mfa_data, default=str)
|
||||
)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error marking MFA verified: {e}")
|
||||
return False
|
||||
|
||||
# Job Applications operations
|
||||
async def get_job_application(self, application_id: str) -> Optional[Dict]:
|
||||
"""Get job application by ID"""
|
||||
|
86
src/backend/device_manager.py
Normal file
86
src/backend/device_manager.py
Normal file
@ -0,0 +1,86 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request, BackgroundTasks # type: ignore
|
||||
from database import RedisDatabase
|
||||
import hashlib
|
||||
from logger import logger
|
||||
from datetime import datetime, timezone
|
||||
from user_agents import parse # type: ignore
|
||||
import json
|
||||
|
||||
class DeviceManager:
|
||||
def __init__(self, database: RedisDatabase):
|
||||
self.database = database
|
||||
|
||||
def generate_device_fingerprint(self, request: Request) -> str:
|
||||
"""Generate device fingerprint from request"""
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
ip_address = request.client.host if request.client else "unknown"
|
||||
accept_language = request.headers.get("accept-language", "")
|
||||
|
||||
# Create fingerprint
|
||||
fingerprint_data = f"{user_agent}|{accept_language}"
|
||||
fingerprint = hashlib.sha256(fingerprint_data.encode()).hexdigest()[:16]
|
||||
|
||||
return fingerprint
|
||||
|
||||
def parse_device_info(self, request: Request) -> dict:
|
||||
"""Parse device information from request"""
|
||||
user_agent_string = request.headers.get("user-agent", "")
|
||||
user_agent = parse(user_agent_string)
|
||||
|
||||
return {
|
||||
"device_id": self.generate_device_fingerprint(request),
|
||||
"device_name": f"{user_agent.browser.family} on {user_agent.os.family}",
|
||||
"browser": user_agent.browser.family,
|
||||
"browser_version": user_agent.browser.version_string,
|
||||
"os": user_agent.os.family,
|
||||
"os_version": user_agent.os.version_string,
|
||||
"ip_address": request.client.host if request.client else "unknown",
|
||||
"user_agent": user_agent_string
|
||||
}
|
||||
|
||||
async def is_trusted_device(self, user_id: str, device_id: str) -> bool:
|
||||
"""Check if device is trusted for user"""
|
||||
try:
|
||||
key = f"trusted_device:{user_id}:{device_id}"
|
||||
exists = await self.database.redis.exists(key)
|
||||
return exists > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking trusted device: {e}")
|
||||
return False
|
||||
|
||||
async def add_trusted_device(self, user_id: str, device_id: str, device_info: dict):
|
||||
"""Add device to trusted devices"""
|
||||
try:
|
||||
key = f"trusted_device:{user_id}:{device_id}"
|
||||
device_data = {
|
||||
**device_info,
|
||||
"added_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_used": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
# Store for 90 days
|
||||
await self.database.redis.setex(
|
||||
key,
|
||||
90 * 24 * 60 * 60, # 90 days in seconds
|
||||
json.dumps(device_data, default=str)
|
||||
)
|
||||
|
||||
logger.info(f"🔒 Added trusted device {device_id} for user {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding trusted device: {e}")
|
||||
|
||||
async def update_device_last_used(self, user_id: str, device_id: str):
|
||||
"""Update last used timestamp for device"""
|
||||
try:
|
||||
key = f"trusted_device:{user_id}:{device_id}"
|
||||
device_data = await self.database.redis.get(key)
|
||||
if device_data:
|
||||
device_info = json.loads(device_data)
|
||||
device_info["last_used"] = datetime.now(timezone.utc).isoformat()
|
||||
await self.database.redis.setex(
|
||||
key,
|
||||
90 * 24 * 60 * 60, # Reset 90 day expiry
|
||||
json.dumps(device_info, default=str)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating device last used: {e}")
|
367
src/backend/email_service.py
Normal file
367
src/backend/email_service.py
Normal file
@ -0,0 +1,367 @@
|
||||
import os
|
||||
from logger import logger
|
||||
from email.mime.text import MIMEText # type: ignore
|
||||
from email.mime.multipart import MIMEMultipart # type: ignore
|
||||
import smtplib
|
||||
import asyncio
|
||||
from email_templates import EMAIL_TEMPLATES
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
from database import RedisDatabase
|
||||
|
||||
class EmailService:
|
||||
def __init__(self):
|
||||
self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com")
|
||||
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||
self.email_user = os.getenv("EMAIL_USER", "your-app@example.com")
|
||||
self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password")
|
||||
self.from_name = os.getenv("FROM_NAME", "Backstory")
|
||||
|
||||
async def send_verification_email(self, to_email: str, verification_token: str, user_name: str):
|
||||
"""Send email verification email"""
|
||||
try:
|
||||
verification_link = f"{os.getenv('FRONTEND_URL', 'https://backstory-beta.ketrenos.com')}/verify-email?token={verification_token}"
|
||||
|
||||
subject = f"Welcome to {self.from_name} - Please verify your email"
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Verification</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
|
||||
.content {{ background: white; padding: 30px; border: 1px solid #e1e5e9; }}
|
||||
.button {{ display: inline-block; background: #667eea; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; margin: 20px 0; }}
|
||||
.footer {{ background: #f8f9fa; padding: 20px; text-align: center; border-radius: 0 0 8px 8px; font-size: 14px; color: #6c757d; }}
|
||||
.security-note {{ background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 6px; margin: 20px 0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Welcome to {self.from_name}!</h1>
|
||||
<p>Thanks for joining us, {user_name}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Please verify your email address</h2>
|
||||
<p>To complete your registration and start using {self.from_name}, please verify your email address by clicking the button below:</p>
|
||||
|
||||
<a href="{verification_link}" class="button">Verify Email Address</a>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #667eea;">{verification_link}</p>
|
||||
|
||||
<div class="security-note">
|
||||
<strong>Security Note:</strong> This verification link will expire in 24 hours. If you didn't create this account, please ignore this email.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This email was sent to {to_email}<br>
|
||||
If you have any questions, contact our support team.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
await self._send_email(to_email, subject, html_content)
|
||||
logger.info(f"📧 Verification email sent to {to_email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to send verification email to {to_email}: {e}")
|
||||
raise
|
||||
|
||||
async def send_mfa_email(self, to_email: str, mfa_code: str, device_name: str, user_name: str):
|
||||
"""Send MFA code email"""
|
||||
try:
|
||||
subject = f"Security Code for {self.from_name}"
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Security Code</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
|
||||
.content {{ background: white; padding: 30px; border: 1px solid #e1e5e9; }}
|
||||
.code {{ background: #f8f9fa; border: 2px solid #667eea; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #667eea; border-radius: 8px; margin: 20px 0; }}
|
||||
.footer {{ background: #f8f9fa; padding: 20px; text-align: center; border-radius: 0 0 8px 8px; font-size: 14px; color: #6c757d; }}
|
||||
.warning {{ background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 15px; border-radius: 6px; margin: 20px 0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔐 Security Code</h1>
|
||||
<p>Hi {user_name}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New device login detected</h2>
|
||||
<p>We detected a login attempt from a new device: <strong>{device_name}</strong></p>
|
||||
<p>Please enter this security code to complete your login:</p>
|
||||
|
||||
<div class="code">{mfa_code}</div>
|
||||
|
||||
<p>This code will expire in 10 minutes.</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Important:</strong> If you didn't attempt to log in, please change your password immediately and contact our support team.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This email was sent to {to_email}<br>
|
||||
For security questions, contact our support team.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
await self._send_email(to_email, subject, html_content)
|
||||
logger.info(f"📧 MFA code sent to {to_email} for device {device_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to send MFA email to {to_email}: {e}")
|
||||
raise
|
||||
|
||||
async def _send_email(self, to_email: str, subject: str, html_content: str):
|
||||
"""Send email using SMTP"""
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = f"{self.from_name} <{self.email_user}>"
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = subject
|
||||
|
||||
# Add HTML content
|
||||
html_part = MIMEText(html_content, 'html')
|
||||
msg.attach(html_part)
|
||||
|
||||
# Send email
|
||||
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(self.email_user, self.email_password)
|
||||
text = msg.as_string()
|
||||
server.sendmail(self.email_user, to_email, text)
|
||||
|
||||
logger.debug(f"📧 Email sent successfully to {to_email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ SMTP error sending to {to_email}: {e}")
|
||||
raise
|
||||
|
||||
email_service = EmailService()
|
||||
|
||||
class EnhancedEmailService:
|
||||
def __init__(self):
|
||||
self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com")
|
||||
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||
self.email_user = os.getenv("EMAIL_USER", "your-app@example.com")
|
||||
self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password")
|
||||
self.from_name = os.getenv("FROM_NAME", "Backstory")
|
||||
self.app_name = os.getenv("APP_NAME", "Backstory")
|
||||
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com")
|
||||
|
||||
def _get_template(self, template_name: str) -> dict:
|
||||
"""Get email template by name"""
|
||||
return EMAIL_TEMPLATES.get(template_name, {})
|
||||
|
||||
def _format_template(self, template: str, **kwargs) -> str:
|
||||
"""Format template with provided variables"""
|
||||
return template.format(
|
||||
app_name=self.app_name,
|
||||
from_name=self.from_name,
|
||||
frontend_url=self.frontend_url,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
async def send_verification_email(
|
||||
self,
|
||||
to_email: str,
|
||||
verification_token: str,
|
||||
user_name: str,
|
||||
user_type: str = "user"
|
||||
):
|
||||
"""Send email verification email using template"""
|
||||
try:
|
||||
template = self._get_template("verification")
|
||||
verification_link = f"{self.frontend_url}/verify-email?token={verification_token}"
|
||||
|
||||
subject = self._format_template(
|
||||
template["subject"],
|
||||
user_name=user_name,
|
||||
to_email=to_email
|
||||
)
|
||||
|
||||
html_content = self._format_template(
|
||||
template["html"],
|
||||
user_name=user_name,
|
||||
user_type=user_type,
|
||||
to_email=to_email,
|
||||
verification_link=verification_link
|
||||
)
|
||||
|
||||
await self._send_email(to_email, subject, html_content)
|
||||
logger.info(f"📧 Verification email sent to {to_email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to send verification email to {to_email}: {e}")
|
||||
raise
|
||||
|
||||
async def send_mfa_email(
|
||||
self,
|
||||
to_email: str,
|
||||
mfa_code: str,
|
||||
device_name: str,
|
||||
user_name: str,
|
||||
ip_address: str = "Unknown"
|
||||
):
|
||||
"""Send MFA code email using template"""
|
||||
try:
|
||||
template = self._get_template("mfa")
|
||||
login_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
subject = self._format_template(template["subject"])
|
||||
|
||||
html_content = self._format_template(
|
||||
template["html"],
|
||||
user_name=user_name,
|
||||
device_name=device_name,
|
||||
ip_address=ip_address,
|
||||
login_time=login_time,
|
||||
mfa_code=mfa_code,
|
||||
to_email=to_email
|
||||
)
|
||||
|
||||
await self._send_email(to_email, subject, html_content)
|
||||
logger.info(f"📧 MFA code sent to {to_email} for device {device_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to send MFA email to {to_email}: {e}")
|
||||
raise
|
||||
|
||||
async def send_password_reset_email(
|
||||
self,
|
||||
to_email: str,
|
||||
reset_token: str,
|
||||
user_name: str
|
||||
):
|
||||
"""Send password reset email using template"""
|
||||
try:
|
||||
template = self._get_template("password_reset")
|
||||
reset_link = f"{self.frontend_url}/reset-password?token={reset_token}"
|
||||
|
||||
subject = self._format_template(template["subject"])
|
||||
|
||||
html_content = self._format_template(
|
||||
template["html"],
|
||||
user_name=user_name,
|
||||
reset_link=reset_link,
|
||||
to_email=to_email
|
||||
)
|
||||
|
||||
await self._send_email(to_email, subject, html_content)
|
||||
logger.info(f"📧 Password reset email sent to {to_email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to send password reset email to {to_email}: {e}")
|
||||
raise
|
||||
|
||||
async def _send_email(self, to_email: str, subject: str, html_content: str):
|
||||
"""Send email using SMTP with improved error handling"""
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = f"{self.from_name} <{self.email_user}>"
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = subject
|
||||
msg['Reply-To'] = self.email_user
|
||||
|
||||
# Add HTML content
|
||||
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||
msg.attach(html_part)
|
||||
|
||||
# Send email with connection pooling and retry logic
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(self.email_user, self.email_password)
|
||||
text = msg.as_string()
|
||||
server.sendmail(self.email_user, to_email, text)
|
||||
break # Success, exit retry loop
|
||||
|
||||
except smtplib.SMTPException as e:
|
||||
if attempt == max_retries - 1: # Last attempt
|
||||
raise
|
||||
logger.warning(f"⚠️ SMTP attempt {attempt + 1} failed, retrying: {e}")
|
||||
await asyncio.sleep(1) # Wait before retry
|
||||
|
||||
logger.debug(f"📧 Email sent successfully to {to_email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ SMTP error sending to {to_email}: {e}")
|
||||
raise
|
||||
|
||||
class EmailRateLimiter:
|
||||
def __init__(self, database: RedisDatabase):
|
||||
self.database = database
|
||||
|
||||
async def can_send_email(self, email: str, email_type: str, limit: int = 5, window_minutes: int = 60) -> bool:
|
||||
"""Check if email can be sent based on rate limiting"""
|
||||
try:
|
||||
key = f"email_rate_limit:{email_type}:{email.lower()}"
|
||||
current_time = datetime.now(timezone.utc)
|
||||
window_start = current_time - timedelta(minutes=window_minutes)
|
||||
|
||||
# Get current count
|
||||
count_data = await self.database.redis.get(key)
|
||||
if not count_data:
|
||||
# First email, allow it
|
||||
await self._record_email_sent(key, current_time, window_minutes)
|
||||
return True
|
||||
|
||||
email_records = json.loads(count_data)
|
||||
|
||||
# Filter out old records
|
||||
recent_records = [
|
||||
record for record in email_records
|
||||
if datetime.fromisoformat(record) > window_start
|
||||
]
|
||||
|
||||
if len(recent_records) >= limit:
|
||||
logger.warning(f"⚠️ Email rate limit exceeded for {email} ({email_type})")
|
||||
return False
|
||||
|
||||
# Add current email to records
|
||||
recent_records.append(current_time.isoformat())
|
||||
await self.database.redis.setex(
|
||||
key,
|
||||
window_minutes * 60,
|
||||
json.dumps(recent_records)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error checking email rate limit: {e}")
|
||||
# On error, allow the email to be safe
|
||||
return True
|
||||
|
||||
async def _record_email_sent(self, key: str, timestamp: datetime, ttl_minutes: int):
|
||||
"""Record that an email was sent"""
|
||||
await self.database.redis.setex(
|
||||
key,
|
||||
ttl_minutes * 60,
|
||||
json.dumps([timestamp.isoformat()])
|
||||
)
|
347
src/backend/email_templates.py
Normal file
347
src/backend/email_templates.py
Normal file
@ -0,0 +1,347 @@
|
||||
EMAIL_TEMPLATES = {
|
||||
"verification": {
|
||||
"subject": "Welcome to {app_name} - Please verify your email",
|
||||
"html": """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Verification</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}}
|
||||
.header h1 {{
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.header p {{
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}}
|
||||
.content {{
|
||||
padding: 40px 30px;
|
||||
}}
|
||||
.content h2 {{
|
||||
margin: 0 0 20px 0;
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.3s ease;
|
||||
}}
|
||||
.button:hover {{
|
||||
background: #5a6fd8;
|
||||
}}
|
||||
.link-text {{
|
||||
word-break: break-all;
|
||||
color: #667eea;
|
||||
background-color: #f8f9ff;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
margin: 16px 0;
|
||||
}}
|
||||
.footer {{
|
||||
background: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}}
|
||||
.security-note {{
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 24px 0;
|
||||
color: #856404;
|
||||
}}
|
||||
.security-note strong {{
|
||||
color: #664d03;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Welcome to {app_name}!</h1>
|
||||
<p>Thanks for joining us, {user_name}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Please verify your email address</h2>
|
||||
<p>To complete your registration and start using {app_name}, please verify your email address by clicking the button below:</p>
|
||||
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="{verification_link}" class="button">Verify Email Address</a>
|
||||
</div>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<div class="link-text">{verification_link}</div>
|
||||
|
||||
<div class="security-note">
|
||||
<strong>Security Note:</strong> This verification link will expire in 24 hours. If you didn't create this account, please ignore this email and the account will be automatically deleted.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p><strong>This email was sent to:</strong> {to_email}</p>
|
||||
<p>If you have any questions, please contact our support team.</p>
|
||||
<p>© 2024 {app_name}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
},
|
||||
|
||||
"mfa": {
|
||||
"subject": "Security Code for {app_name}",
|
||||
"html": """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Security Code</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}}
|
||||
.header h1 {{
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.content {{
|
||||
padding: 40px 30px;
|
||||
}}
|
||||
.device-info {{
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #2196f3;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
color: #1565c0;
|
||||
}}
|
||||
.code {{
|
||||
background: #f8f9fa;
|
||||
border: 3px solid #667eea;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 12px;
|
||||
color: #667eea;
|
||||
border-radius: 12px;
|
||||
margin: 32px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}}
|
||||
.footer {{
|
||||
background: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}}
|
||||
.warning {{
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 24px 0;
|
||||
}}
|
||||
.warning strong {{
|
||||
color: #491217;
|
||||
}}
|
||||
.expiry-info {{
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔐 Security Code</h1>
|
||||
<p>Hi {user_name}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New device login detected</h2>
|
||||
|
||||
<div class="device-info">
|
||||
<strong>Device Details:</strong><br>
|
||||
<strong>Name:</strong> {device_name}<br>
|
||||
<strong>IP Address:</strong> {ip_address}<br>
|
||||
<strong>Time:</strong> {login_time}
|
||||
</div>
|
||||
|
||||
<p>Please enter this security code to complete your login:</p>
|
||||
|
||||
<div class="code">{mfa_code}</div>
|
||||
|
||||
<div class="expiry-info">
|
||||
⏱️ This code will expire in 10 minutes
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Important Security Notice:</strong><br>
|
||||
If you didn't attempt to log in from this device, please:
|
||||
<ul style="margin: 12px 0; padding-left: 20px;">
|
||||
<li>Change your password immediately</li>
|
||||
<li>Review your account activity</li>
|
||||
<li>Contact our support team</li>
|
||||
</ul>
|
||||
Never share this code with anyone, including {app_name} support.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p><strong>This email was sent to:</strong> {to_email}</p>
|
||||
<p>For security questions, please contact our support team immediately.</p>
|
||||
<p>© 2024 {app_name}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
},
|
||||
|
||||
"password_reset": {
|
||||
"subject": "Reset your {app_name} password",
|
||||
"html": """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Password Reset</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}}
|
||||
.content {{ padding: 40px 30px; }}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0;
|
||||
}}
|
||||
.footer {{
|
||||
background: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔑 Password Reset</h1>
|
||||
<p>Reset your password for {app_name}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Reset your password</h2>
|
||||
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
||||
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="{reset_link}" class="button">Reset Password</a>
|
||||
</div>
|
||||
|
||||
<p>This link will expire in 1 hour for security reasons.</p>
|
||||
<p>If you didn't request a password reset, please ignore this email and your password will remain unchanged.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This email was sent to {to_email}</p>
|
||||
<p>© 2024 {app_name}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request # type: ignore
|
||||
from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request, BackgroundTasks # type: ignore
|
||||
from fastapi.middleware.cors import CORSMiddleware # type: ignore
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials # type: ignore
|
||||
from fastapi.responses import JSONResponse, StreamingResponse# type: ignore
|
||||
@ -26,6 +26,8 @@ from pydantic import BaseModel, EmailStr, field_validator # type: ignore
|
||||
from prometheus_client import Summary # type: ignore
|
||||
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
|
||||
from prometheus_client import CollectorRegistry, Counter # type: ignore
|
||||
import secrets
|
||||
import os
|
||||
|
||||
# =============================
|
||||
# Import custom modules
|
||||
@ -43,6 +45,8 @@ from database import RedisDatabase, redis_manager, DatabaseManager
|
||||
from metrics import Metrics
|
||||
from llm_manager import llm_manager
|
||||
import entities
|
||||
from email_service import email_service
|
||||
from device_manager import DeviceManager
|
||||
|
||||
# =============================
|
||||
# Import Pydantic models
|
||||
@ -58,7 +62,10 @@ from models import (
|
||||
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType,
|
||||
|
||||
# Supporting models
|
||||
Location, Skill, WorkExperience, Education
|
||||
Location, MFARequest, MFAVerifyRequest, ResendVerificationRequest, Skill, WorkExperience, Education,
|
||||
|
||||
# Email
|
||||
EmailVerificationRequest
|
||||
)
|
||||
|
||||
|
||||
@ -369,14 +376,20 @@ api_router = APIRouter(prefix="/api/1.0")
|
||||
# ============================
|
||||
|
||||
@api_router.post("/auth/login")
|
||||
async def login(
|
||||
async def enhanced_login(
|
||||
request: LoginRequest,
|
||||
http_request: Request,
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Secure login endpoint with password verification"""
|
||||
"""Enhanced login with device detection and MFA"""
|
||||
try:
|
||||
# Initialize authentication manager
|
||||
# Initialize managers
|
||||
auth_manager = AuthenticationManager(database)
|
||||
device_manager = DeviceManager(database)
|
||||
|
||||
# Parse device information
|
||||
device_info = device_manager.parse_device_info(http_request)
|
||||
device_id = device_info["device_id"]
|
||||
|
||||
# Verify credentials
|
||||
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(
|
||||
@ -391,33 +404,42 @@ async def login(
|
||||
content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
|
||||
)
|
||||
|
||||
# Update last login timestamp
|
||||
# Check if device is trusted
|
||||
is_trusted = await device_manager.is_trusted_device(user_data["id"], device_id)
|
||||
|
||||
if not is_trusted:
|
||||
# New device detected - require MFA
|
||||
logger.info(f"🔐 New device detected for {request.login}, MFA required")
|
||||
return create_success_response({
|
||||
"mfaRequired": True,
|
||||
"deviceId": device_id,
|
||||
"deviceName": device_info["device_name"],
|
||||
"message": "New device detected. Please verify your identity via email."
|
||||
})
|
||||
|
||||
# Trusted device - proceed with normal login
|
||||
await device_manager.update_device_last_used(user_data["id"], device_id)
|
||||
await auth_manager.update_last_login(user_data["id"])
|
||||
|
||||
logger.info(f"🔑 User {request.login} logged in successfully")
|
||||
|
||||
# Create tokens
|
||||
access_token = create_access_token(data={"sub": user_data["id"]})
|
||||
refresh_token = create_access_token(
|
||||
data={"sub": user_data["id"], "type": "refresh"},
|
||||
expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS)
|
||||
expires_delta=timedelta(days=30)
|
||||
)
|
||||
|
||||
# Get user object based on type
|
||||
# Get user object
|
||||
user = None
|
||||
if user_data["type"] == "candidate":
|
||||
logger.info(f"🔑 User {request.login} is a candidate")
|
||||
candidate_data = await database.get_candidate(user_data["id"])
|
||||
if candidate_data:
|
||||
user = Candidate.model_validate(candidate_data)
|
||||
elif user_data["type"] == "employer":
|
||||
logger.info(f"🔑 User {request.login} is an employer")
|
||||
employer_data = await database.get_employer(user_data["id"])
|
||||
if employer_data:
|
||||
user = Employer.model_validate(employer_data)
|
||||
user = Employer.model_validate(employer_data)
|
||||
|
||||
if not user:
|
||||
logger.error(f"❌ User object not found for {user_data['id']}")
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=create_error_response("USER_NOT_FOUND", "User profile not found")
|
||||
@ -428,17 +450,20 @@ async def login(
|
||||
accessToken=access_token,
|
||||
refreshToken=refresh_token,
|
||||
user=user,
|
||||
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp())
|
||||
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=24)).timestamp())
|
||||
)
|
||||
|
||||
logger.info(f"🔑 User {request.login} logged in successfully from trusted device")
|
||||
|
||||
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Login error: {e}")
|
||||
logger.error(f"❌ Enhanced login error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
|
||||
)
|
||||
|
||||
@api_router.post("/auth/logout")
|
||||
async def logout(
|
||||
access_token: str = Body(..., alias="accessToken"),
|
||||
@ -635,7 +660,505 @@ async def refresh_token_endpoint(
|
||||
# ============================
|
||||
# Candidate Endpoints
|
||||
# ============================
|
||||
@api_router.post("/candidates")
|
||||
async def create_candidate_with_verification(
|
||||
request: CreateCandidateRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Create a new candidate with email verification"""
|
||||
try:
|
||||
# Initialize authentication manager
|
||||
auth_manager = AuthenticationManager(database)
|
||||
|
||||
# Check if user already exists
|
||||
user_exists, conflict_field = await auth_manager.check_user_exists(
|
||||
request.email,
|
||||
request.username
|
||||
)
|
||||
|
||||
if user_exists and conflict_field:
|
||||
logger.warning(f"⚠️ Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}")
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content=create_error_response(
|
||||
"USER_EXISTS",
|
||||
f"A user with this {conflict_field} already exists"
|
||||
)
|
||||
)
|
||||
|
||||
# Generate candidate data (but don't activate yet)
|
||||
candidate_id = str(uuid.uuid4())
|
||||
current_time = datetime.now(timezone.utc)
|
||||
|
||||
candidate_data = {
|
||||
"id": candidate_id,
|
||||
"userType": "candidate",
|
||||
"email": request.email,
|
||||
"username": request.username,
|
||||
"firstName": request.firstName,
|
||||
"lastName": request.lastName,
|
||||
"fullName": f"{request.firstName} {request.lastName}",
|
||||
"phone": request.phone,
|
||||
"createdAt": current_time.isoformat(),
|
||||
"updatedAt": current_time.isoformat(),
|
||||
"status": "pending", # Not active until email verified
|
||||
}
|
||||
|
||||
# Generate verification token
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Store verification token with user data
|
||||
await database.store_email_verification_token(
|
||||
request.email,
|
||||
verification_token,
|
||||
"candidate",
|
||||
{
|
||||
"candidate_data": candidate_data,
|
||||
"password": request.password, # Store temporarily for verification
|
||||
"username": request.username
|
||||
}
|
||||
)
|
||||
|
||||
# Send verification email in background
|
||||
background_tasks.add_task(
|
||||
email_service.send_verification_email,
|
||||
request.email,
|
||||
verification_token,
|
||||
f"{request.firstName} {request.lastName}"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Candidate registration initiated for: {request.email}")
|
||||
|
||||
return create_success_response({
|
||||
"message": "Registration successful! Please check your email to verify your account.",
|
||||
"email": request.email,
|
||||
"verificationRequired": True
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Candidate creation error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response("CREATION_FAILED", "Failed to create candidate account")
|
||||
)
|
||||
|
||||
@api_router.post("/employers")
|
||||
async def create_employer_with_verification(
|
||||
request: CreateEmployerRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Create a new employer with email verification"""
|
||||
try:
|
||||
# Similar to candidate creation but for employer
|
||||
auth_manager = AuthenticationManager(database)
|
||||
|
||||
user_exists, conflict_field = await auth_manager.check_user_exists(
|
||||
request.email,
|
||||
request.username
|
||||
)
|
||||
|
||||
if user_exists and conflict_field:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content=create_error_response(
|
||||
"USER_EXISTS",
|
||||
f"A user with this {conflict_field} already exists"
|
||||
)
|
||||
)
|
||||
|
||||
employer_id = str(uuid.uuid4())
|
||||
current_time = datetime.now(timezone.utc)
|
||||
|
||||
employer_data = {
|
||||
"id": employer_id,
|
||||
"email": request.email,
|
||||
"companyName": request.companyName,
|
||||
"industry": request.industry,
|
||||
"companySize": request.companySize,
|
||||
"companyDescription": request.companyDescription,
|
||||
"websiteUrl": request.websiteUrl,
|
||||
"phone": request.phone,
|
||||
"createdAt": current_time.isoformat(),
|
||||
"updatedAt": current_time.isoformat(),
|
||||
"status": "pending", # Not active until verified
|
||||
"userType": "employer",
|
||||
"location": {
|
||||
"city": "",
|
||||
"country": "",
|
||||
"remote": False
|
||||
},
|
||||
"socialLinks": []
|
||||
}
|
||||
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
|
||||
await database.store_email_verification_token(
|
||||
request.email,
|
||||
verification_token,
|
||||
"employer",
|
||||
{
|
||||
"employer_data": employer_data,
|
||||
"password": request.password,
|
||||
"username": request.username
|
||||
}
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
email_service.send_verification_email,
|
||||
request.email,
|
||||
verification_token,
|
||||
request.companyName
|
||||
)
|
||||
|
||||
logger.info(f"✅ Employer registration initiated for: {request.email}")
|
||||
|
||||
return create_success_response({
|
||||
"message": "Registration successful! Please check your email to verify your account.",
|
||||
"email": request.email,
|
||||
"verificationRequired": True
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Employer creation error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response("CREATION_FAILED", "Failed to create employer account")
|
||||
)
|
||||
|
||||
@api_router.post("/auth/verify-email")
|
||||
async def verify_email(
|
||||
request: EmailVerificationRequest,
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Verify email address and activate account"""
|
||||
try:
|
||||
# Get verification data
|
||||
verification_data = await database.get_email_verification_token(request.token)
|
||||
|
||||
if not verification_data:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_error_response("INVALID_TOKEN", "Invalid or expired verification token")
|
||||
)
|
||||
|
||||
if verification_data.get("verified"):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_error_response("ALREADY_VERIFIED", "Email already verified")
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
expires_at = datetime.fromisoformat(verification_data["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires_at:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_error_response("TOKEN_EXPIRED", "Verification token has expired")
|
||||
)
|
||||
|
||||
# Extract user data
|
||||
user_type = verification_data["user_type"]
|
||||
user_data_container = verification_data["user_data"]
|
||||
|
||||
if user_type == "candidate":
|
||||
candidate_data = user_data_container["candidate_data"]
|
||||
password = user_data_container["password"]
|
||||
username = user_data_container["username"]
|
||||
|
||||
# Activate candidate
|
||||
candidate_data["status"] = "active"
|
||||
candidate = Candidate.model_validate(candidate_data)
|
||||
|
||||
# Create authentication record
|
||||
auth_manager = AuthenticationManager(database)
|
||||
await auth_manager.create_user_authentication(candidate.id, password)
|
||||
|
||||
# Store in database
|
||||
await database.set_candidate(candidate.id, candidate.model_dump())
|
||||
|
||||
# Add user lookup records
|
||||
user_auth_data = {
|
||||
"id": candidate.id,
|
||||
"type": "candidate",
|
||||
"email": candidate.email,
|
||||
"username": username
|
||||
}
|
||||
|
||||
await database.set_user(candidate.email, user_auth_data)
|
||||
await database.set_user(username, user_auth_data)
|
||||
await database.set_user_by_id(candidate.id, user_auth_data)
|
||||
|
||||
elif user_type == "employer":
|
||||
employer_data = user_data_container["employer_data"]
|
||||
password = user_data_container["password"]
|
||||
username = user_data_container["username"]
|
||||
|
||||
# Activate employer
|
||||
employer_data["status"] = "active"
|
||||
employer = Employer.model_validate(employer_data)
|
||||
|
||||
# Create authentication record
|
||||
auth_manager = AuthenticationManager(database)
|
||||
await auth_manager.create_user_authentication(employer.id, password)
|
||||
|
||||
# Store in database
|
||||
await database.set_employer(employer.id, employer.model_dump())
|
||||
|
||||
# Add user lookup records
|
||||
user_auth_data = {
|
||||
"id": employer.id,
|
||||
"type": "employer",
|
||||
"email": employer.email,
|
||||
"username": username
|
||||
}
|
||||
|
||||
await database.set_user(employer.email, user_auth_data)
|
||||
await database.set_user(username, user_auth_data)
|
||||
await database.set_user_by_id(employer.id, user_auth_data)
|
||||
|
||||
# Mark as verified
|
||||
await database.mark_email_verified(request.token)
|
||||
|
||||
logger.info(f"✅ Email verified and account activated for: {verification_data['email']}")
|
||||
|
||||
return create_success_response({
|
||||
"message": "Email verified successfully! Your account is now active.",
|
||||
"accountActivated": True,
|
||||
"userType": user_type
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Email verification error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response("VERIFICATION_FAILED", "Failed to verify email")
|
||||
)
|
||||
|
||||
@api_router.post("/auth/resend-verification")
|
||||
async def resend_verification_email(
|
||||
request: ResendVerificationRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Resend verification email"""
|
||||
try:
|
||||
# Check if user exists and is pending
|
||||
user_data = await database.get_user(request.email)
|
||||
|
||||
if user_data:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_error_response("ALREADY_VERIFIED", "Account is already verified")
|
||||
)
|
||||
|
||||
# Look for pending verification
|
||||
# This would require scanning verification tokens (implement if needed)
|
||||
# For now, return a generic success message
|
||||
|
||||
return create_success_response({
|
||||
"message": "If your email is in our system and pending verification, a new verification email has been sent."
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Resend verification error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response("RESEND_FAILED", "Failed to resend verification email")
|
||||
)
|
||||
|
||||
@api_router.post("/auth/mfa/request")
|
||||
async def request_mfa(
|
||||
request: MFARequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
http_request: Request,
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Request MFA for login from new device"""
|
||||
try:
|
||||
# Verify credentials first
|
||||
auth_manager = AuthenticationManager(database)
|
||||
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(
|
||||
request.email,
|
||||
request.password
|
||||
)
|
||||
|
||||
if not is_valid or not user_data:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content=create_error_response("AUTH_FAILED", "Invalid credentials")
|
||||
)
|
||||
|
||||
# Check if device is trusted
|
||||
device_manager = DeviceManager(database)
|
||||
device_info = device_manager.parse_device_info(http_request)
|
||||
|
||||
is_trusted = await device_manager.is_trusted_device(user_data["id"], request.device_id)
|
||||
|
||||
if is_trusted:
|
||||
# Device is trusted, proceed with normal login
|
||||
await device_manager.update_device_last_used(user_data["id"], request.device_id)
|
||||
|
||||
return create_success_response({
|
||||
"mfaRequired": False,
|
||||
"message": "Device is trusted, proceed with login"
|
||||
})
|
||||
|
||||
# Generate MFA code
|
||||
mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code
|
||||
|
||||
# Store MFA code
|
||||
await database.store_mfa_code(request.email, mfa_code, request.device_id)
|
||||
|
||||
# Get user name for email
|
||||
user_name = "User"
|
||||
if user_data["type"] == "candidate":
|
||||
candidate_data = await database.get_candidate(user_data["id"])
|
||||
if candidate_data:
|
||||
user_name = candidate_data.get("fullName", "User")
|
||||
elif user_data["type"] == "employer":
|
||||
employer_data = await database.get_employer(user_data["id"])
|
||||
if employer_data:
|
||||
user_name = employer_data.get("companyName", "User")
|
||||
|
||||
# Send MFA code via email
|
||||
background_tasks.add_task(
|
||||
email_service.send_mfa_email,
|
||||
request.email,
|
||||
mfa_code,
|
||||
request.device_name,
|
||||
user_name
|
||||
)
|
||||
|
||||
logger.info(f"🔐 MFA requested for {request.email} from new device {request.device_name}")
|
||||
|
||||
return create_success_response({
|
||||
"mfaRequired": True,
|
||||
"message": "MFA code sent to your email address",
|
||||
"deviceId": request.device_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ MFA request error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response("MFA_REQUEST_FAILED", "Failed to process MFA request")
|
||||
)
|
||||
|
||||
@api_router.post("/auth/mfa/verify")
|
||||
async def verify_mfa(
|
||||
request: MFAVerifyRequest,
|
||||
http_request: Request,
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Verify MFA code and complete login"""
|
||||
try:
|
||||
# Get MFA data
|
||||
mfa_data = await database.get_mfa_code(request.email, request.device_id)
|
||||
|
||||
if not mfa_data:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_error_response("INVALID_MFA", "Invalid or expired MFA code")
|
||||
)
|
||||
|
||||
if mfa_data.get("verified"):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_error_response("ALREADY_VERIFIED", "MFA code already used")
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
expires_at = datetime.fromisoformat(mfa_data["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires_at:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_error_response("MFA_EXPIRED", "MFA code has expired")
|
||||
)
|
||||
|
||||
# Check attempts
|
||||
if mfa_data.get("attempts", 0) >= 5:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content=create_error_response("TOO_MANY_ATTEMPTS", "Too many MFA attempts")
|
||||
)
|
||||
|
||||
# Verify code
|
||||
if mfa_data["code"] != request.code:
|
||||
await database.increment_mfa_attempts(request.email, request.device_id)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_error_response("INVALID_CODE", "Invalid MFA code")
|
||||
)
|
||||
|
||||
# Mark as verified
|
||||
await database.mark_mfa_verified(request.email, request.device_id)
|
||||
|
||||
# Get user data
|
||||
user_data = await database.get_user(request.email)
|
||||
if not user_data:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=create_error_response("USER_NOT_FOUND", "User not found")
|
||||
)
|
||||
|
||||
# Add device to trusted devices if requested
|
||||
if request.remember_device:
|
||||
device_manager = DeviceManager(database)
|
||||
device_info = device_manager.parse_device_info(http_request)
|
||||
await device_manager.add_trusted_device(
|
||||
user_data["id"],
|
||||
request.device_id,
|
||||
device_info
|
||||
)
|
||||
|
||||
# Update last login
|
||||
auth_manager = AuthenticationManager(database)
|
||||
await auth_manager.update_last_login(user_data["id"])
|
||||
|
||||
# Create tokens
|
||||
access_token = create_access_token(data={"sub": user_data["id"]})
|
||||
refresh_token = create_access_token(
|
||||
data={"sub": user_data["id"], "type": "refresh"},
|
||||
expires_delta=timedelta(days=30)
|
||||
)
|
||||
|
||||
# Get user object
|
||||
user = None
|
||||
if user_data["type"] == "candidate":
|
||||
candidate_data = await database.get_candidate(user_data["id"])
|
||||
if candidate_data:
|
||||
user = Candidate.model_validate(candidate_data)
|
||||
elif user_data["type"] == "employer":
|
||||
employer_data = await database.get_employer(user_data["id"])
|
||||
if employer_data:
|
||||
user = Employer.model_validate(employer_data)
|
||||
|
||||
if not user:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=create_error_response("USER_NOT_FOUND", "User profile not found")
|
||||
)
|
||||
|
||||
# Create response
|
||||
auth_response = AuthResponse(
|
||||
accessToken=access_token,
|
||||
refreshToken=refresh_token,
|
||||
user=user,
|
||||
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=24)).timestamp())
|
||||
)
|
||||
|
||||
logger.info(f"✅ MFA verified and login completed for {request.email}")
|
||||
|
||||
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ MFA verification error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response("MFA_VERIFICATION_FAILED", "Failed to verify MFA")
|
||||
)
|
||||
|
||||
@api_router.post("/candidates")
|
||||
async def create_candidate(
|
||||
request: CreateCandidateRequest,
|
||||
|
@ -189,6 +189,29 @@ class SortOrder(str, Enum):
|
||||
ASC = "asc"
|
||||
DESC = "desc"
|
||||
|
||||
|
||||
# ============================
|
||||
# MFA Models
|
||||
# ============================
|
||||
|
||||
class EmailVerificationRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
class MFARequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
device_id: str
|
||||
device_name: str
|
||||
|
||||
class MFAVerifyRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str
|
||||
device_id: str
|
||||
remember_device: bool = False
|
||||
|
||||
class ResendVerificationRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
# ============================
|
||||
# Supporting Models
|
||||
# ============================
|
||||
|
Loading…
x
Reference in New Issue
Block a user