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
|
# New backend implementation
|
||||||
RUN pip install fastapi uvicorn "python-jose[cryptography]" bcrypt python-multipart
|
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
|
# Automatic type conversion pydantic -> typescript
|
||||||
RUN pip install pydantic typing-inspect jinja2
|
RUN pip install pydantic typing-inspect jinja2
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
IconButton,
|
IconButton,
|
||||||
Dialog,
|
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 { BackstoryPageProps } from 'components/BackstoryTab';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { LoginForm } from "components/EmailVerificationComponents";
|
||||||
|
import { CandidateRegistrationForm, EmployerRegistrationForm } from "components/RegistrationForms";
|
||||||
|
|
||||||
type UserRegistrationType = 'candidate' | 'employer';
|
type UserRegistrationType = 'candidate' | 'employer';
|
||||||
|
|
||||||
interface LoginRequest {
|
interface LoginRequest {
|
||||||
@ -412,327 +415,329 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tabValue === 0 && (
|
{tabValue === 0 && (
|
||||||
<Box component="form" onSubmit={handleLogin}>
|
<LoginForm />
|
||||||
<Typography variant="h5" gutterBottom>
|
// <Box component="form" onSubmit={handleLogin}>
|
||||||
Sign In
|
// <Typography variant="h5" gutterBottom>
|
||||||
</Typography>
|
// Sign In
|
||||||
|
// </Typography>
|
||||||
|
|
||||||
<TextField
|
// <TextField
|
||||||
fullWidth
|
// fullWidth
|
||||||
label="Username or Email"
|
// label="Username or Email"
|
||||||
type="text"
|
// type="text"
|
||||||
value={loginForm.login}
|
// value={loginForm.login}
|
||||||
onChange={handleLoginChange}
|
// onChange={handleLoginChange}
|
||||||
margin="normal"
|
// margin="normal"
|
||||||
required
|
// required
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
variant="outlined"
|
// variant="outlined"
|
||||||
placeholder="Enter username or email"
|
// placeholder="Enter username or email"
|
||||||
/>
|
// />
|
||||||
|
|
||||||
<TextField
|
// <TextField
|
||||||
fullWidth
|
// fullWidth
|
||||||
label="Password"
|
// label="Password"
|
||||||
type={showLoginPassword ? 'text' : 'password'}
|
// type={showLoginPassword ? 'text' : 'password'}
|
||||||
value={loginForm.password}
|
// value={loginForm.password}
|
||||||
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
|
// onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
|
||||||
margin="normal"
|
// margin="normal"
|
||||||
required
|
// required
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
variant="outlined"
|
// variant="outlined"
|
||||||
autoComplete='current-password'
|
// autoComplete='current-password'
|
||||||
slotProps={{
|
// slotProps={{
|
||||||
input: {
|
// input: {
|
||||||
endAdornment: (
|
// endAdornment: (
|
||||||
<InputAdornment position="end">
|
// <InputAdornment position="end">
|
||||||
<IconButton
|
// <IconButton
|
||||||
aria-label="toggle password visibility"
|
// aria-label="toggle password visibility"
|
||||||
onClick={toggleLoginPasswordVisibility}
|
// onClick={toggleLoginPasswordVisibility}
|
||||||
edge="end"
|
// edge="end"
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
>
|
// >
|
||||||
{showLoginPassword ? <VisibilityOff /> : <Visibility />}
|
// {showLoginPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
</IconButton>
|
// </IconButton>
|
||||||
</InputAdornment>
|
// </InputAdornment>
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
}}
|
// }}
|
||||||
/>
|
// />
|
||||||
|
|
||||||
<Button
|
// <Button
|
||||||
type="submit"
|
// type="submit"
|
||||||
fullWidth
|
// fullWidth
|
||||||
variant="contained"
|
// variant="contained"
|
||||||
sx={{ mt: 3, mb: 2 }}
|
// sx={{ mt: 3, mb: 2 }}
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <Person />}
|
// startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <Person />}
|
||||||
>
|
// >
|
||||||
{loading ? 'Signing In...' : 'Sign In'}
|
// {loading ? 'Signing In...' : 'Sign In'}
|
||||||
</Button>
|
// </Button>
|
||||||
</Box>
|
// </Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tabValue === 1 && (
|
{tabValue === 1 && (
|
||||||
<Box component="form" onSubmit={handleRegister}>
|
<CandidateRegistrationForm />
|
||||||
<Typography variant="h5" gutterBottom>
|
// <Box component="form" onSubmit={handleRegister}>
|
||||||
Create Account
|
// <Typography variant="h5" gutterBottom>
|
||||||
</Typography>
|
// Create Account
|
||||||
|
// </Typography>
|
||||||
|
|
||||||
{/* User Type Selection */}
|
// {/* User Type Selection */}
|
||||||
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
|
// <FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
|
||||||
<FormLabel component="legend" sx={{ mb: 2 }}>
|
// <FormLabel component="legend" sx={{ mb: 2 }}>
|
||||||
<Typography variant="h6">Select Account Type</Typography>
|
// <Typography variant="h6">Select Account Type</Typography>
|
||||||
</FormLabel>
|
// </FormLabel>
|
||||||
<RadioGroup
|
// <RadioGroup
|
||||||
value={registerForm.userType}
|
// value={registerForm.userType}
|
||||||
onChange={handleUserTypeChange}
|
// onChange={handleUserTypeChange}
|
||||||
sx={{ gap: 1 }}
|
// sx={{ gap: 1 }}
|
||||||
>
|
// >
|
||||||
{(['candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
|
// {(['candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
|
||||||
const info = getUserTypeInfo(userType);
|
// const info = getUserTypeInfo(userType);
|
||||||
return (
|
// return (
|
||||||
<FormControlLabel
|
// <FormControlLabel
|
||||||
key={userType}
|
// key={userType}
|
||||||
value={userType}
|
// value={userType}
|
||||||
disabled={loading || userType === 'employer'}
|
// disabled={loading || userType === 'employer'}
|
||||||
control={<Radio />}
|
// control={<Radio />}
|
||||||
label={
|
// label={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
// <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||||
{info.icon}
|
// {info.icon}
|
||||||
<Box>
|
// <Box>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
// <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||||
{info.title}
|
// {info.title}
|
||||||
{userType === 'employer' && (
|
// {userType === 'employer' && (
|
||||||
<Chip
|
// <Chip
|
||||||
label="Coming Soon"
|
// label="Coming Soon"
|
||||||
size="small"
|
// size="small"
|
||||||
color="warning"
|
// color="warning"
|
||||||
sx={{ ml: 1 }}
|
// sx={{ ml: 1 }}
|
||||||
/>
|
// />
|
||||||
)}
|
// )}
|
||||||
</Typography>
|
// </Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
// <Typography variant="body2" color="text.secondary">
|
||||||
{info.description}
|
// {info.description}
|
||||||
</Typography>
|
// </Typography>
|
||||||
</Box>
|
// </Box>
|
||||||
</Box>
|
// </Box>
|
||||||
}
|
// }
|
||||||
sx={{
|
// sx={{
|
||||||
border: '1px solid',
|
// border: '1px solid',
|
||||||
borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
|
// borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
|
||||||
borderRadius: 1,
|
// borderRadius: 1,
|
||||||
p: 1,
|
// p: 1,
|
||||||
m: 0,
|
// m: 0,
|
||||||
bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
|
// bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
|
||||||
'&:hover': {
|
// '&:hover': {
|
||||||
bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
|
// bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
|
||||||
},
|
// },
|
||||||
opacity: userType === 'employer' ? 0.6 : 1
|
// opacity: userType === 'employer' ? 0.6 : 1
|
||||||
}}
|
// }}
|
||||||
/>
|
// />
|
||||||
);
|
// );
|
||||||
})}
|
// })}
|
||||||
</RadioGroup>
|
// </RadioGroup>
|
||||||
</FormControl>
|
// </FormControl>
|
||||||
|
|
||||||
{/* Employer Placeholder */}
|
// {/* Employer Placeholder */}
|
||||||
{registerForm.userType === 'employer' && (
|
// {registerForm.userType === 'employer' && (
|
||||||
<Alert severity="info" sx={{ mb: 3 }}>
|
// <Alert severity="info" sx={{ mb: 3 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
// <Typography variant="h6" gutterBottom>
|
||||||
Employer Registration Coming Soon
|
// Employer Registration Coming Soon
|
||||||
</Typography>
|
// </Typography>
|
||||||
<Typography variant="body2">
|
// <Typography variant="body2">
|
||||||
We're currently building our employer features. If you're interested in posting jobs
|
// 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
|
// and finding talent, please contact our support team at support@backstory.com for
|
||||||
early access.
|
// early access.
|
||||||
</Typography>
|
// </Typography>
|
||||||
</Alert>
|
// </Alert>
|
||||||
)}
|
// )}
|
||||||
|
|
||||||
{/* Basic Information Fields */}
|
// {/* Basic Information Fields */}
|
||||||
{registerForm.userType !== 'employer' && (
|
// {registerForm.userType !== 'employer' && (
|
||||||
<>
|
// <>
|
||||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
// <Grid container spacing={2} sx={{ mb: 2 }}>
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
// <Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
// <TextField
|
||||||
fullWidth
|
// fullWidth
|
||||||
label="First Name"
|
// label="First Name"
|
||||||
value={registerForm.firstName}
|
// value={registerForm.firstName}
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
|
// onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
|
||||||
required
|
// required
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
variant="outlined"
|
// variant="outlined"
|
||||||
/>
|
// />
|
||||||
</Grid>
|
// </Grid>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
// <Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
// <TextField
|
||||||
fullWidth
|
// fullWidth
|
||||||
label="Last Name"
|
// label="Last Name"
|
||||||
value={registerForm.lastName}
|
// value={registerForm.lastName}
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
|
// onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
|
||||||
required
|
// required
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
variant="outlined"
|
// variant="outlined"
|
||||||
/>
|
// />
|
||||||
</Grid>
|
// </Grid>
|
||||||
</Grid>
|
// </Grid>
|
||||||
|
|
||||||
<TextField
|
// <TextField
|
||||||
fullWidth
|
// fullWidth
|
||||||
label="Username"
|
// label="Username"
|
||||||
value={registerForm.username}
|
// value={registerForm.username}
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
|
// onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
|
||||||
margin="normal"
|
// margin="normal"
|
||||||
required
|
// required
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
variant="outlined"
|
// variant="outlined"
|
||||||
/>
|
// />
|
||||||
|
|
||||||
<TextField
|
// <TextField
|
||||||
fullWidth
|
// fullWidth
|
||||||
label="Email"
|
// label="Email"
|
||||||
type="email"
|
// type="email"
|
||||||
value={registerForm.email}
|
// value={registerForm.email}
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
|
// onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
|
||||||
margin="normal"
|
// margin="normal"
|
||||||
required
|
// required
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
variant="outlined"
|
// variant="outlined"
|
||||||
/>
|
// />
|
||||||
|
|
||||||
{/* Conditional fields based on user type */}
|
// {/* Conditional fields based on user type */}
|
||||||
{registerForm.userType === 'candidate' && (
|
// {registerForm.userType === 'candidate' && (
|
||||||
<>
|
// <>
|
||||||
<PhoneInput
|
// <PhoneInput
|
||||||
label="Phone (Optional)"
|
// label="Phone (Optional)"
|
||||||
placeholder="Enter phone number"
|
// placeholder="Enter phone number"
|
||||||
defaultCountry='US'
|
// defaultCountry='US'
|
||||||
value={registerForm.phone}
|
// value={registerForm.phone}
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
onChange={(v) => setPhone(v as E164Number)}
|
// onChange={(v) => setPhone(v as E164Number)}
|
||||||
/>
|
// />
|
||||||
|
|
||||||
<LocationInput
|
// <LocationInput
|
||||||
value={location}
|
// value={location}
|
||||||
onChange={handleLocationChange}
|
// onChange={handleLocationChange}
|
||||||
showCity
|
// showCity
|
||||||
helperText="Include your city for more specific job matches"
|
// helperText="Include your city for more specific job matches"
|
||||||
/>
|
// />
|
||||||
</>
|
// </>
|
||||||
)}
|
// )}
|
||||||
|
|
||||||
<TextField
|
// <TextField
|
||||||
fullWidth
|
// fullWidth
|
||||||
label="Password"
|
// label="Password"
|
||||||
type={showRegisterPassword ? 'text' : 'password'}
|
// type={showRegisterPassword ? 'text' : 'password'}
|
||||||
value={registerForm.password}
|
// value={registerForm.password}
|
||||||
onChange={(e) => handlePasswordChange(e.target.value)}
|
// onChange={(e) => handlePasswordChange(e.target.value)}
|
||||||
margin="normal"
|
// margin="normal"
|
||||||
required
|
// required
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
variant="outlined"
|
// variant="outlined"
|
||||||
slotProps={{
|
// slotProps={{
|
||||||
input: {
|
// input: {
|
||||||
endAdornment: (
|
// endAdornment: (
|
||||||
<InputAdornment position="end">
|
// <InputAdornment position="end">
|
||||||
<IconButton
|
// <IconButton
|
||||||
aria-label="toggle password visibility"
|
// aria-label="toggle password visibility"
|
||||||
onClick={toggleRegisterPasswordVisibility}
|
// onClick={toggleRegisterPasswordVisibility}
|
||||||
edge="end"
|
// edge="end"
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
>
|
// >
|
||||||
{showRegisterPassword ? <VisibilityOff /> : <Visibility />}
|
// {showRegisterPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
</IconButton>
|
// </IconButton>
|
||||||
</InputAdornment>
|
// </InputAdornment>
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
}}
|
// }}
|
||||||
/>
|
// />
|
||||||
|
|
||||||
{/* Password Requirements */}
|
// {/* Password Requirements */}
|
||||||
{registerForm.password.length > 0 && (
|
// {registerForm.password.length > 0 && (
|
||||||
<Box sx={{ mt: 1, mb: 1 }}>
|
// <Box sx={{ mt: 1, mb: 1 }}>
|
||||||
<Button
|
// <Button
|
||||||
onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
|
// onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
|
||||||
startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
|
// startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
|
||||||
size="small"
|
// size="small"
|
||||||
sx={{ mb: 1 }}
|
// sx={{ mb: 1 }}
|
||||||
>
|
// >
|
||||||
Password Requirements
|
// Password Requirements
|
||||||
</Button>
|
// </Button>
|
||||||
<Collapse in={showPasswordRequirements}>
|
// <Collapse in={showPasswordRequirements}>
|
||||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
// <Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
<List dense>
|
// <List dense>
|
||||||
{passwordRequirements.map((requirement, index) => (
|
// {passwordRequirements.map((requirement, index) => (
|
||||||
<ListItem key={index} sx={{ py: 0.5 }}>
|
// <ListItem key={index} sx={{ py: 0.5 }}>
|
||||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
// <ListItemIcon sx={{ minWidth: 36 }}>
|
||||||
{requirement.met ? (
|
// {requirement.met ? (
|
||||||
<CheckCircle color="success" fontSize="small" />
|
// <CheckCircle color="success" fontSize="small" />
|
||||||
) : (
|
// ) : (
|
||||||
<Cancel color="error" fontSize="small" />
|
// <Cancel color="error" fontSize="small" />
|
||||||
)}
|
// )}
|
||||||
</ListItemIcon>
|
// </ListItemIcon>
|
||||||
<ListItemText
|
// <ListItemText
|
||||||
primary={requirement.label}
|
// primary={requirement.label}
|
||||||
sx={{
|
// sx={{
|
||||||
'& .MuiListItemText-primary': {
|
// '& .MuiListItemText-primary': {
|
||||||
fontSize: '0.875rem',
|
// fontSize: '0.875rem',
|
||||||
color: requirement.met ? 'success.main' : 'error.main'
|
// color: requirement.met ? 'success.main' : 'error.main'
|
||||||
}
|
// }
|
||||||
}}
|
// }}
|
||||||
/>
|
// />
|
||||||
</ListItem>
|
// </ListItem>
|
||||||
))}
|
// ))}
|
||||||
</List>
|
// </List>
|
||||||
</Paper>
|
// </Paper>
|
||||||
</Collapse>
|
// </Collapse>
|
||||||
</Box>
|
// </Box>
|
||||||
)}
|
// )}
|
||||||
|
|
||||||
<TextField
|
// <TextField
|
||||||
fullWidth
|
// fullWidth
|
||||||
label="Confirm Password"
|
// label="Confirm Password"
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
// type={showConfirmPassword ? 'text' : 'password'}
|
||||||
value={registerForm.confirmPassword}
|
// value={registerForm.confirmPassword}
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
|
// onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
|
||||||
margin="normal"
|
// margin="normal"
|
||||||
required
|
// required
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
variant="outlined"
|
// variant="outlined"
|
||||||
error={hasPasswordMatchError}
|
// error={hasPasswordMatchError}
|
||||||
helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
|
// helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
|
||||||
slotProps={{
|
// slotProps={{
|
||||||
input: {
|
// input: {
|
||||||
endAdornment: (
|
// endAdornment: (
|
||||||
<InputAdornment position="end">
|
// <InputAdornment position="end">
|
||||||
<IconButton
|
// <IconButton
|
||||||
aria-label="toggle confirm password visibility"
|
// aria-label="toggle confirm password visibility"
|
||||||
onClick={toggleConfirmPasswordVisibility}
|
// onClick={toggleConfirmPasswordVisibility}
|
||||||
edge="end"
|
// edge="end"
|
||||||
disabled={loading}
|
// disabled={loading}
|
||||||
>
|
// >
|
||||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
// {showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
</IconButton>
|
// </IconButton>
|
||||||
</InputAdornment>
|
// </InputAdornment>
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
}}
|
// }}
|
||||||
/>
|
// />
|
||||||
|
|
||||||
<Button
|
// <Button
|
||||||
type="submit"
|
// type="submit"
|
||||||
fullWidth
|
// fullWidth
|
||||||
variant="contained"
|
// variant="contained"
|
||||||
sx={{ mt: 3, mb: 2 }}
|
// sx={{ mt: 3, mb: 2 }}
|
||||||
disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
|
// disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
|
||||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
|
// startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
|
||||||
>
|
// >
|
||||||
{loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
|
// {loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
|
||||||
</Button>
|
// </Button>
|
||||||
</>
|
// </>
|
||||||
)}
|
// )}
|
||||||
</Box>
|
// </Box>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</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;
|
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
|
// 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
|
// React Hooks for Streaming with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// Generated TypeScript types from Pydantic models
|
// Generated TypeScript types from Pydantic models
|
||||||
// Source: src/backend/models.py
|
// 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
|
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@ -378,6 +378,10 @@ export interface Education {
|
|||||||
location?: Location;
|
location?: Location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmailVerificationRequest {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Employer {
|
export interface Employer {
|
||||||
id?: string;
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -535,6 +539,20 @@ export interface Location {
|
|||||||
address?: string;
|
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 {
|
export interface MessageReaction {
|
||||||
userId: string;
|
userId: string;
|
||||||
reaction: string;
|
reaction: string;
|
||||||
@ -609,6 +627,10 @@ export interface RefreshToken {
|
|||||||
revokedReason?: string;
|
revokedReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResendVerificationRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetrievalParameters {
|
export interface RetrievalParameters {
|
||||||
searchType: "similarity" | "mmr" | "hybrid" | "keyword";
|
searchType: "similarity" | "mmr" | "hybrid" | "keyword";
|
||||||
topK: number;
|
topK: number;
|
||||||
|
@ -349,6 +349,140 @@ class RedisDatabase:
|
|||||||
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
||||||
await self.redis.delete(key)
|
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
|
# Job Applications operations
|
||||||
async def get_job_application(self, application_id: str) -> Optional[Dict]:
|
async def get_job_application(self, application_id: str) -> Optional[Dict]:
|
||||||
"""Get job application by ID"""
|
"""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.middleware.cors import CORSMiddleware # type: ignore
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials # type: ignore
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials # type: ignore
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse# 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_client import Summary # type: ignore
|
||||||
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
|
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
|
||||||
from prometheus_client import CollectorRegistry, Counter # type: ignore
|
from prometheus_client import CollectorRegistry, Counter # type: ignore
|
||||||
|
import secrets
|
||||||
|
import os
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
# Import custom modules
|
# Import custom modules
|
||||||
@ -43,6 +45,8 @@ from database import RedisDatabase, redis_manager, DatabaseManager
|
|||||||
from metrics import Metrics
|
from metrics import Metrics
|
||||||
from llm_manager import llm_manager
|
from llm_manager import llm_manager
|
||||||
import entities
|
import entities
|
||||||
|
from email_service import email_service
|
||||||
|
from device_manager import DeviceManager
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
# Import Pydantic models
|
# Import Pydantic models
|
||||||
@ -58,7 +62,10 @@ from models import (
|
|||||||
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType,
|
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType,
|
||||||
|
|
||||||
# Supporting models
|
# 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")
|
@api_router.post("/auth/login")
|
||||||
async def login(
|
async def enhanced_login(
|
||||||
request: LoginRequest,
|
request: LoginRequest,
|
||||||
|
http_request: Request,
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
):
|
):
|
||||||
"""Secure login endpoint with password verification"""
|
"""Enhanced login with device detection and MFA"""
|
||||||
try:
|
try:
|
||||||
# Initialize authentication manager
|
# Initialize managers
|
||||||
auth_manager = AuthenticationManager(database)
|
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
|
# Verify credentials
|
||||||
is_valid, user_data, error_message = await auth_manager.verify_user_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")
|
content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update last login timestamp
|
# Check if device is trusted
|
||||||
await auth_manager.update_last_login(user_data["id"])
|
is_trusted = await device_manager.is_trusted_device(user_data["id"], device_id)
|
||||||
|
|
||||||
logger.info(f"🔑 User {request.login} logged in successfully")
|
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"])
|
||||||
|
|
||||||
# Create tokens
|
# Create tokens
|
||||||
access_token = create_access_token(data={"sub": user_data["id"]})
|
access_token = create_access_token(data={"sub": user_data["id"]})
|
||||||
refresh_token = create_access_token(
|
refresh_token = create_access_token(
|
||||||
data={"sub": user_data["id"], "type": "refresh"},
|
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
|
user = None
|
||||||
if user_data["type"] == "candidate":
|
if user_data["type"] == "candidate":
|
||||||
logger.info(f"🔑 User {request.login} is a candidate")
|
|
||||||
candidate_data = await database.get_candidate(user_data["id"])
|
candidate_data = await database.get_candidate(user_data["id"])
|
||||||
if candidate_data:
|
if candidate_data:
|
||||||
user = Candidate.model_validate(candidate_data)
|
user = Candidate.model_validate(candidate_data)
|
||||||
elif user_data["type"] == "employer":
|
elif user_data["type"] == "employer":
|
||||||
logger.info(f"🔑 User {request.login} is an employer")
|
|
||||||
employer_data = await database.get_employer(user_data["id"])
|
employer_data = await database.get_employer(user_data["id"])
|
||||||
if employer_data:
|
if employer_data:
|
||||||
user = Employer.model_validate(employer_data)
|
user = Employer.model_validate(employer_data)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
logger.error(f"❌ User object not found for {user_data['id']}")
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
content=create_error_response("USER_NOT_FOUND", "User profile not found")
|
content=create_error_response("USER_NOT_FOUND", "User profile not found")
|
||||||
@ -428,17 +450,20 @@ async def login(
|
|||||||
accessToken=access_token,
|
accessToken=access_token,
|
||||||
refreshToken=refresh_token,
|
refreshToken=refresh_token,
|
||||||
user=user,
|
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))
|
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Login error: {e}")
|
logger.error(f"❌ Enhanced login error: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
|
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
|
||||||
)
|
)
|
||||||
|
|
||||||
@api_router.post("/auth/logout")
|
@api_router.post("/auth/logout")
|
||||||
async def logout(
|
async def logout(
|
||||||
access_token: str = Body(..., alias="accessToken"),
|
access_token: str = Body(..., alias="accessToken"),
|
||||||
@ -635,6 +660,504 @@ async def refresh_token_endpoint(
|
|||||||
# ============================
|
# ============================
|
||||||
# Candidate Endpoints
|
# 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")
|
@api_router.post("/candidates")
|
||||||
async def create_candidate(
|
async def create_candidate(
|
||||||
|
@ -189,6 +189,29 @@ class SortOrder(str, Enum):
|
|||||||
ASC = "asc"
|
ASC = "asc"
|
||||||
DESC = "desc"
|
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
|
# Supporting Models
|
||||||
# ============================
|
# ============================
|
||||||
|
Loading…
x
Reference in New Issue
Block a user