From 35701d971937493aff3bd32fe9ede1dd21bc8841 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Sat, 31 May 2025 19:25:04 -0700 Subject: [PATCH] Implementing MFA --- Dockerfile | 3 + .../src/components/DeleteConfirmation.tsx | 2 +- .../EmailVerificationComponents.tsx | 711 +++++++++ frontend/src/components/MFA.tsx | 742 ++++++++++ frontend/src/components/RegistrationForms.tsx | 1308 +++++++++++++++++ frontend/src/pages/LoginPage.tsx | 611 ++++---- frontend/src/pages/RegisterPage.tsx | 34 - frontend/src/services/api-client.ts | 508 ++++++- frontend/src/types/types.ts | 24 +- src/backend/database.py | 136 +- src/backend/device_manager.py | 86 ++ src/backend/email_service.py | 367 +++++ src/backend/email_templates.py | 347 +++++ src/backend/main.py | 555 ++++++- src/backend/models.py | 23 + 15 files changed, 5100 insertions(+), 357 deletions(-) create mode 100644 frontend/src/components/EmailVerificationComponents.tsx create mode 100644 frontend/src/components/MFA.tsx create mode 100644 frontend/src/components/RegistrationForms.tsx delete mode 100644 frontend/src/pages/RegisterPage.tsx create mode 100644 src/backend/device_manager.py create mode 100644 src/backend/email_service.py create mode 100644 src/backend/email_templates.py diff --git a/Dockerfile b/Dockerfile index 9ecfaa1..a77ca3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -190,6 +190,9 @@ RUN pip install "redis[hiredis]>=4.5.0" # New backend implementation RUN pip install fastapi uvicorn "python-jose[cryptography]" bcrypt python-multipart +# Needed for email verification +RUN pip install pyyaml user-agents cryptography + # Automatic type conversion pydantic -> typescript RUN pip install pydantic typing-inspect jinja2 RUN apt-get update \ diff --git a/frontend/src/components/DeleteConfirmation.tsx b/frontend/src/components/DeleteConfirmation.tsx index eac6e31..f87d959 100644 --- a/frontend/src/components/DeleteConfirmation.tsx +++ b/frontend/src/components/DeleteConfirmation.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { IconButton, Dialog, diff --git a/frontend/src/components/EmailVerificationComponents.tsx b/frontend/src/components/EmailVerificationComponents.tsx new file mode 100644 index 0000000..280aff1 --- /dev/null +++ b/frontend/src/components/EmailVerificationComponents.tsx @@ -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(''); + + 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 ( + + + + + {status === 'pending' && ( + <> + + + Verifying Email + + + Please wait while we verify your email address... + + + )} + + {status === 'success' && ( + <> + + + Email Verified! + + + Your {userType} account has been successfully activated. + + + )} + + {status === 'error' && ( + <> + + + Verification Failed + + + We couldn't verify your email address. + + + )} + + + {loading && ( + + + + )} + + {message && ( + + {message} + + )} + + {status === 'success' && ( + + + You will be redirected to the login page in a few seconds... + + + + )} + + {status === 'error' && ( + + + + + )} + + + + ); +} + +// 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 ( + + + + + + Verify Your Identity + + + + + + + We've detected a login from a new device: {deviceName} + + + + We've sent a 6-digit verification code to: + + + {email} + + + { + 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} + /> + + + + Code expires in: {formatTime(timeLeft)} + + + + + setRememberDevice(e.target.checked)} + /> + } + label="Remember this device for 90 days" + /> + + + + If you didn't attempt to log in, please change your password immediately. + + + + + + + + + + ); +} + +// 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 ( + + + + + + Check Your Email + + + + We've sent a verification link to: + + + + {email} + + + + + Next steps: +
+ 1. Check your email inbox (and spam folder) +
+ 2. Click the verification link +
+ 3. Your {userType} account will be activated +
+
+ + {resendMessage && ( + + {resendMessage} + + )} +
+ + + + + +
+ ); +} + +// 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(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 ( + + setEmail(e.target.value)} + autoComplete="email" + autoFocus + /> + setPassword(e.target.value)} + autoComplete="current-password" + /> + + {error && ( + + {error} + + )} + + + + {/* MFA Dialog */} + {mfaRequired && mfaData && ( + setMfaRequired(false)} + email={mfaData.email} + deviceId={mfaData.deviceId} + deviceName={mfaData.deviceName} + onVerificationSuccess={handleMFASuccess} + /> + )} + + ); +} + +// Device Management Component +const TrustedDevicesManager = () => { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + + // This would need API endpoints to manage trusted devices + useEffect(() => { + // Load trusted devices + setLoading(false); + }, []); + + return ( + + + + + Trusted Devices + + + + Manage devices that you've marked as trusted. You won't need to verify + your identity when signing in from these devices. + + + {devices.length === 0 ? ( + + No trusted devices yet. When you log in from a new device and choose + to remember it, it will appear here. + + ) : ( + + {devices.map((device, index) => ( + + + + + {device.deviceName} + + + Added: {new Date(device.addedAt).toLocaleDateString()} + + + Last used: {new Date(device.lastUsed).toLocaleDateString()} + + + + + + ))} + + )} + + + ); +} + +export { EmailVerificationPage, MFAVerificationDialog, TrustedDevicesManager, RegistrationSuccessDialog, LoginForm }; \ No newline at end of file diff --git a/frontend/src/components/MFA.tsx b/frontend/src/components/MFA.tsx new file mode 100644 index 0000000..51cc208 --- /dev/null +++ b/frontend/src/components/MFA.tsx @@ -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(''); + + 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 ( + + + + + {status === 'pending' && ( + <> + + + Verifying Email + + + Please wait while we verify your email address... + + + )} + + {status === 'success' && ( + <> + + + Email Verified! + + + Your {userType} account has been successfully activated. + + + )} + + {status === 'error' && ( + <> + + + Verification Failed + + + We couldn't verify your email address. + + + )} + + + {loading && ( + + + + )} + + {message && ( + + {message} + + )} + + {status === 'success' && ( + + + You will be redirected to the login page in a few seconds... + + + + )} + + {status === 'error' && ( + + + + + )} + + + + ); +} + +// 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 ( + + + + + + Verify Your Identity + + + + + + + We've detected a login from a new device: {deviceName} + + + + We've sent a 6-digit verification code to: + + + {email} + + + { + 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} + /> + + + + Code expires in: {formatTime(timeLeft)} + + + + + setRememberDevice(e.target.checked)} + /> + } + label="Remember this device for 90 days" + /> + + + + If you didn't attempt to log in, please change your password immediately. + + + + + + + + + + ); +} + +// 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 ( + + + + + + Check Your Email + + + + We've sent a verification link to: + + + + {email} + + + + + Next steps: +
+ 1. Check your email inbox (and spam folder) +
+ 2. Click the verification link +
+ 3. Your {userType} account will be activated +
+
+ + {resendMessage && ( + + {resendMessage} + + )} +
+ + + + + +
+ ); +} + +// 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(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 ( + + setEmail(e.target.value)} + autoComplete="email" + autoFocus + /> + setPassword(e.target.value)} + autoComplete="current-password" + /> + + {error && ( + + {error} + + )} + + + + {/* MFA Dialog */} + {mfaRequired && mfaData && ( + setMfaRequired(false)} + email={mfaData.email} + deviceId={mfaData.deviceId} + deviceName={mfaData.deviceName} + onVerificationSuccess={handleMFASuccess} + /> + )} + + ); +} + +// Device Management Component +export function TrustedDevicesManager() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + + // This would need API endpoints to manage trusted devices + useEffect(() => { + // Load trusted devices + setLoading(false); + }, []); + + return ( + + + + + Trusted Devices + + + + Manage devices that you've marked as trusted. You won't need to verify + your identity when signing in from these devices. + + + {devices.length === 0 ? ( + + No trusted devices yet. When you log in from a new device and choose + to remember it, it will appear here. + + ) : ( + + {devices.map((device, index) => ( + + + + + {device.deviceName} + + + Added: {new Date(device.addedAt).toLocaleDateString()} + + + Last used: {new Date(device.lastUsed).toLocaleDateString()} + + + + + + ))} + + )} + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/RegistrationForms.tsx b/frontend/src/components/RegistrationForms.tsx new file mode 100644 index 0000000..cb9e575 --- /dev/null +++ b/frontend/src/components/RegistrationForms.tsx @@ -0,0 +1,1308 @@ +import React, { useState } from 'react'; +import { ApiClient } from 'services/api-client'; +import { RegistrationSuccessDialog } from 'components/EmailVerificationComponents'; +import { useAuth } from 'hooks/AuthContext'; +import { useNavigate } from 'react-router-dom'; + +// Candidate Registration Form +const CandidateRegistrationForm = () => { + const { apiClient } = useAuth(); + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + email: '', + username: '', + password: '', + confirmPassword: '', + firstName: '', + lastName: '', + phone: '' + }); + + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + const [showSuccess, setShowSuccess] = useState(false); + const [registrationResult, setRegistrationResult] = useState(null); + + const validateForm = () => { + const newErrors: Record = {}; + + // Email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!formData.email) { + newErrors.email = 'Email is required'; + } else if (!emailRegex.test(formData.email)) { + newErrors.email = 'Please enter a valid email address'; + } + + // Username validation + if (!formData.username) { + newErrors.username = 'Username is required'; + } else if (formData.username.length < 3) { + newErrors.username = 'Username must be at least 3 characters'; + } else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) { + newErrors.username = 'Username can only contain letters, numbers, and underscores'; + } + + // Password validation + if (!formData.password) { + newErrors.password = 'Password is required'; + } else { + const passwordErrors = validatePassword(formData.password); + if (passwordErrors.length > 0) { + newErrors.password = passwordErrors.join(', '); + } + } + + // Confirm password + if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match'; + } + + // Name validation + if (!formData.firstName.trim()) { + newErrors.firstName = 'First name is required'; + } + if (!formData.lastName.trim()) { + newErrors.lastName = 'Last name is required'; + } + + // Phone validation (optional but must be valid if provided) + if (formData.phone && !/^[\+]?[1-9][\d]{0,15}$/.test(formData.phone.replace(/\s/g, ''))) { + newErrors.phone = 'Please enter a valid phone number'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const validatePassword = (password: string): string[] => { + const errors: string[] = []; + + if (password.length < 8) { + errors.push('at least 8 characters'); + } + if (!/[a-z]/.test(password)) { + errors.push('one lowercase letter'); + } + if (!/[A-Z]/.test(password)) { + errors.push('one uppercase letter'); + } + if (!/\d/.test(password)) { + errors.push('one number'); + } + if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) { + errors.push('one special character'); + } + + return errors.length > 0 ? [`Password must contain ${errors.join(', ')}`] : []; + }; + + const handleInputChange = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + + // Clear error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: '' })); + } + }; + + const handleSubmit = async () => { + + if (!validateForm()) { + return; + } + + setLoading(true); + try { + const result = await apiClient.createCandidateWithVerification({ + email: formData.email, + username: formData.username, + password: formData.password, + firstName: formData.firstName, + lastName: formData.lastName, + phone: formData.phone || undefined + }); + + // Set pending verification + apiClient.setPendingEmailVerification(formData.email); + + setRegistrationResult(result); + setShowSuccess(true); + + } catch (error: any) { + if (error.message.includes('already exists')) { + if (error.message.includes('email')) { + setErrors({ email: 'An account with this email already exists' }); + } else if (error.message.includes('username')) { + setErrors({ username: 'This username is already taken' }); + } + } else { + setErrors({ general: error.message || 'Registration failed. Please try again.' }); + } + } finally { + setLoading(false); + } + }; + + const getPasswordStrength = (password: string) => { + const validations = [ + password.length >= 8, + /[a-z]/.test(password), + /[A-Z]/.test(password), + /\d/.test(password), + /[!@#$%^&*(),.?":{}|<>]/.test(password) + ]; + + const strength = validations.filter(Boolean).length; + + if (strength < 2) return { level: 'weak', color: '#f44336', width: '20%' }; + if (strength < 4) return { level: 'medium', color: '#ff9800', width: '60%' }; + return { level: 'strong', color: '#4caf50', width: '100%' }; + }; + + const passwordStrength = formData.password ? getPasswordStrength(formData.password) : null; + + return ( +
+
+
+

+ Join as a Candidate +

+

+ Create your account to start finding your dream job +

+
+ +
+ {/* Email Field */} +
+ + handleInputChange('email', e.target.value)} + placeholder="your.email@example.com" + style={{ + width: '100%', + padding: '12px', + border: errors.email ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box', + transition: 'border-color 0.3s ease' + }} + onFocus={(e) => e.target.style.borderColor = '#1976d2'} + onBlur={(e) => e.target.style.borderColor = errors.email ? '#f44336' : '#ddd'} + /> + {errors.email && ( +
+ {errors.email} +
+ )} +
+ + {/* Username Field */} +
+ + handleInputChange('username', e.target.value.toLowerCase())} + placeholder="johndoe123" + style={{ + width: '100%', + padding: '12px', + border: errors.username ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.username && ( +
+ {errors.username} +
+ )} +
+ + {/* Name Fields */} +
+
+ + handleInputChange('firstName', e.target.value)} + placeholder="John" + style={{ + width: '100%', + padding: '12px', + border: errors.firstName ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.firstName && ( +
+ {errors.firstName} +
+ )} +
+ +
+ + handleInputChange('lastName', e.target.value)} + placeholder="Doe" + style={{ + width: '100%', + padding: '12px', + border: errors.lastName ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.lastName && ( +
+ {errors.lastName} +
+ )} +
+
+ + {/* Phone Field */} +
+ + handleInputChange('phone', e.target.value)} + placeholder="+1 (555) 123-4567" + style={{ + width: '100%', + padding: '12px', + border: errors.phone ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.phone && ( +
+ {errors.phone} +
+ )} +
+ + {/* Password Field */} +
+ + handleInputChange('password', e.target.value)} + placeholder="Create a strong password" + style={{ + width: '100%', + padding: '12px', + border: errors.password ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + + {/* Password Strength Indicator */} + {formData.password && passwordStrength && ( +
+
+
+
+
+ Password strength: {passwordStrength.level} +
+
+ )} + + {errors.password && ( +
+ {errors.password} +
+ )} +
+ + {/* Confirm Password Field */} +
+ + handleInputChange('confirmPassword', e.target.value)} + placeholder="Confirm your password" + style={{ + width: '100%', + padding: '12px', + border: errors.confirmPassword ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.confirmPassword && ( +
+ {errors.confirmPassword} +
+ )} +
+ + {/* General Error */} + {errors.general && ( +
+ {errors.general} +
+ )} + + {/* Submit Button */} + + + {/* Login Link */} + +
+
+ + {/* Success Dialog */} + {showSuccess && registrationResult && ( + setShowSuccess(false)} + email={registrationResult.email} + userType="candidate" + /> + )} + + +
+ ); +} + +// Employer Registration Form +const EmployerRegistrationForm = () => { + const [formData, setFormData] = useState({ + email: '', + username: '', + password: '', + confirmPassword: '', + companyName: '', + industry: '', + companySize: '', + companyDescription: '', + websiteUrl: '', + phone: '' + }); + + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + const [showSuccess, setShowSuccess] = useState(false); + const [registrationResult, setRegistrationResult] = useState(null); + + const apiClient = new ApiClient(); + + const industryOptions = [ + 'Technology', 'Healthcare', 'Finance', 'Education', 'Manufacturing', + 'Retail', 'Consulting', 'Media', 'Non-profit', 'Government', 'Other' + ]; + + const companySizeOptions = [ + '1-10 employees', '11-50 employees', '51-200 employees', + '201-500 employees', '501-1000 employees', '1000+ employees' + ]; + + const validateForm = () => { + const newErrors: Record = {}; + + // Email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!formData.email) { + newErrors.email = 'Email is required'; + } else if (!emailRegex.test(formData.email)) { + newErrors.email = 'Please enter a valid email address'; + } + + // Username validation + if (!formData.username) { + newErrors.username = 'Username is required'; + } else if (formData.username.length < 3) { + newErrors.username = 'Username must be at least 3 characters'; + } + + // Password validation + if (!formData.password) { + newErrors.password = 'Password is required'; + } else { + const passwordErrors = validatePassword(formData.password); + if (passwordErrors.length > 0) { + newErrors.password = passwordErrors.join(', '); + } + } + + // Confirm password + if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match'; + } + + // Company validation + if (!formData.companyName.trim()) { + newErrors.companyName = 'Company name is required'; + } + if (!formData.industry) { + newErrors.industry = 'Industry is required'; + } + if (!formData.companySize) { + newErrors.companySize = 'Company size is required'; + } + if (!formData.companyDescription.trim()) { + newErrors.companyDescription = 'Company description is required'; + } else if (formData.companyDescription.length < 50) { + newErrors.companyDescription = 'Company description must be at least 50 characters'; + } + + // Website URL validation (optional but must be valid if provided) + if (formData.websiteUrl) { + try { + new URL(formData.websiteUrl); + } catch { + newErrors.websiteUrl = 'Please enter a valid website URL'; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const validatePassword = (password: string): string[] => { + const errors: string[] = []; + + if (password.length < 8) { + errors.push('at least 8 characters'); + } + if (!/[a-z]/.test(password)) { + errors.push('one lowercase letter'); + } + if (!/[A-Z]/.test(password)) { + errors.push('one uppercase letter'); + } + if (!/\d/.test(password)) { + errors.push('one number'); + } + if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) { + errors.push('one special character'); + } + + return errors.length > 0 ? [`Password must contain ${errors.join(', ')}`] : []; + }; + + const handleInputChange = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + + // Clear error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: '' })); + } + }; + + const handleSubmit = async () => { + + if (!validateForm()) { + return; + } + + setLoading(true); + try { + const result = await apiClient.createEmployerWithVerification({ + email: formData.email, + username: formData.username, + password: formData.password, + companyName: formData.companyName, + industry: formData.industry, + companySize: formData.companySize, + companyDescription: formData.companyDescription, + websiteUrl: formData.websiteUrl || undefined, + phone: formData.phone || undefined + }); + + // Set pending verification + apiClient.setPendingEmailVerification(formData.email); + + setRegistrationResult(result); + setShowSuccess(true); + + } catch (error: any) { + if (error.message.includes('already exists')) { + if (error.message.includes('email')) { + setErrors({ email: 'An account with this email already exists' }); + } else if (error.message.includes('username')) { + setErrors({ username: 'This username is already taken' }); + } + } else { + setErrors({ general: error.message || 'Registration failed. Please try again.' }); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

+ Join as an Employer +

+

+ Create your company account to start hiring top talent +

+
+ +
+ {/* Account Information Section */} +
+

Account Information

+ + {/* Email and Username */} +
+
+ + handleInputChange('email', e.target.value)} + placeholder="company@example.com" + style={{ + width: '100%', + padding: '12px', + border: errors.email ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.email && ( +
+ {errors.email} +
+ )} +
+ +
+ + handleInputChange('username', e.target.value.toLowerCase())} + placeholder="company123" + style={{ + width: '100%', + padding: '12px', + border: errors.username ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.username && ( +
+ {errors.username} +
+ )} +
+
+ + {/* Password Fields */} +
+
+ + handleInputChange('password', e.target.value)} + placeholder="Create a strong password" + style={{ + width: '100%', + padding: '12px', + border: errors.password ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.password && ( +
+ {errors.password} +
+ )} +
+ +
+ + handleInputChange('confirmPassword', e.target.value)} + placeholder="Confirm your password" + style={{ + width: '100%', + padding: '12px', + border: errors.confirmPassword ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.confirmPassword && ( +
+ {errors.confirmPassword} +
+ )} +
+
+
+ + {/* Company Information Section */} +
+

Company Information

+ + {/* Company Name */} +
+ + handleInputChange('companyName', e.target.value)} + placeholder="Your Company Inc." + style={{ + width: '100%', + padding: '12px', + border: errors.companyName ? '2px solid #f44336' : '2px solid #ddd', + borderRadius: '8px', + fontSize: '16px', + outline: 'none', + boxSizing: 'border-box' + }} + /> + {errors.companyName && ( +
+ {errors.companyName} +
+ )} +
+ + {/* Industry and Company Size */} +
+
+ + + {errors.industry && ( +
+ {errors.industry} +
+ )} +
+ +
+ + + {errors.companySize && ( +
+ {errors.companySize} +
+ )} +
+
+ + {/* Company Description */} +
+ +