Compare commits
3 Commits
35701d9719
...
d7a81481a2
Author | SHA1 | Date | |
---|---|---|---|
d7a81481a2 | |||
360673e60d | |||
32f81f6314 |
307
frontend/public/docs/authentication.md
Normal file
307
frontend/public/docs/authentication.md
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
This documents all authentication flows in Backstory. Here are the key flows explained:
|
||||||
|
|
||||||
|
# 🔐 Core Authentication Flows
|
||||||
|
1. Registration & Email Verification
|
||||||
|
|
||||||
|
`Registration → Email Sent → Email Verification → Account Activation → Login`
|
||||||
|
|
||||||
|
Includes resend verification with rate limiting
|
||||||
|
|
||||||
|
Handles expired tokens and error cases
|
||||||
|
|
||||||
|
2. Login on Trusted Device
|
||||||
|
|
||||||
|
`Login → Credentials Check → Device Trust Check → Immediate Access`
|
||||||
|
|
||||||
|
Fastest path with access/refresh tokens issued immediately
|
||||||
|
|
||||||
|
3. Login on New Device (MFA)
|
||||||
|
|
||||||
|
`Login → Credentials Check → New Device Detected → Auto-send MFA Email → MFA Dialog → Code Verification → Access Granted`
|
||||||
|
|
||||||
|
Optional device trust for future logins
|
||||||
|
|
||||||
|
4. App Initialization & Token Management
|
||||||
|
|
||||||
|
`App Start → Check Tokens → Auto-refresh if needed → Load Dashboard`
|
||||||
|
|
||||||
|
Handles expired tokens gracefully
|
||||||
|
|
||||||
|
# 🛡️ Security Features Covered
|
||||||
|
|
||||||
|
## Rate Limiting & Protection
|
||||||
|
|
||||||
|
* Login attempt limiting
|
||||||
|
* MFA resend limiting (max 3)
|
||||||
|
* Verification email rate limiting
|
||||||
|
* Account lockout for abuse
|
||||||
|
|
||||||
|
## Token Management
|
||||||
|
|
||||||
|
* Access token expiration handling
|
||||||
|
* Refresh token rotation
|
||||||
|
* Token blacklisting on logout
|
||||||
|
* Force logout on revoked tokens
|
||||||
|
|
||||||
|
## Device Security
|
||||||
|
|
||||||
|
* Device fingerprinting
|
||||||
|
* Trusted device management
|
||||||
|
* MFA for new devices
|
||||||
|
* Device removal capabilities
|
||||||
|
|
||||||
|
# 🔄 Key Decision Points
|
||||||
|
|
||||||
|
1. Has Valid Tokens? → Dashboard vs Login
|
||||||
|
2. Trusted Device? → Immediate access vs MFA
|
||||||
|
3. Account Active? → Login vs Error message
|
||||||
|
4. MFA Code Valid? → Success vs Retry/Lock
|
||||||
|
5. Token Expired? → Refresh vs Re-login
|
||||||
|
|
||||||
|
# 📱 User Experience Flows
|
||||||
|
|
||||||
|
## Happy Path (Returning User)
|
||||||
|
`App Start → Valid Tokens → Dashboard (2 steps)`
|
||||||
|
|
||||||
|
## New User Journey
|
||||||
|
`Registration → Email Verification → Login → Dashboard (4 steps)`
|
||||||
|
|
||||||
|
## New Device Login
|
||||||
|
`Login → MFA Email → Code Entry → Dashboard (3 steps)`
|
||||||
|
|
||||||
|
## 🔧 Implementation Notes
|
||||||
|
|
||||||
|
**Background Tasks**: Email sending doesn't block user flow
|
||||||
|
|
||||||
|
**Error Recovery**: Clear paths back to working states
|
||||||
|
|
||||||
|
**Admin Features**: User management and security monitoring
|
||||||
|
|
||||||
|
**Future Features**: Password reset flow is mapped out
|
||||||
|
|
||||||
|
# Flow Diagram
|
||||||
|
|
||||||
|
This diagram serves as the complete authentication architecture reference, showing every possible user journey and system state transition.
|
||||||
|
|
||||||
|
```
|
||||||
|
flowchart TD
|
||||||
|
%% ================================
|
||||||
|
%% REGISTRATION FLOWS
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
Start([User Visits App]) --> CheckTokens{Has Valid Tokens?}
|
||||||
|
CheckTokens -->|Yes| LoadUser[Load User Profile]
|
||||||
|
CheckTokens -->|No| LandingPage[Landing Page]
|
||||||
|
|
||||||
|
LandingPage --> RegisterChoice{Registration Type}
|
||||||
|
RegisterChoice --> CandidateReg[Candidate Registration Form]
|
||||||
|
RegisterChoice --> EmployerReg[Employer Registration Form]
|
||||||
|
RegisterChoice --> LoginPage[Login Page]
|
||||||
|
|
||||||
|
%% Candidate Registration Flow
|
||||||
|
CandidateReg --> CandidateValidation{Form Valid?}
|
||||||
|
CandidateValidation -->|No| CandidateReg
|
||||||
|
CandidateValidation -->|Yes| CandidateSubmit[POST /candidates]
|
||||||
|
CandidateSubmit --> CandidateCheck{User Exists?}
|
||||||
|
CandidateCheck -->|Yes| CandidateError[Show Error: User Exists]
|
||||||
|
CandidateError --> CandidateReg
|
||||||
|
CandidateCheck -->|No| CandidateEmailSent[Auto-send Verification Email]
|
||||||
|
CandidateEmailSent --> CandidateSuccess[Show Success Dialog]
|
||||||
|
|
||||||
|
%% Employer Registration Flow
|
||||||
|
EmployerReg --> EmployerValidation{Form Valid?}
|
||||||
|
EmployerValidation -->|No| EmployerReg
|
||||||
|
EmployerValidation -->|Yes| EmployerSubmit[POST /employers]
|
||||||
|
EmployerSubmit --> EmployerCheck{User Exists?}
|
||||||
|
EmployerCheck -->|Yes| EmployerError[Show Error: User Exists]
|
||||||
|
EmployerError --> EmployerReg
|
||||||
|
EmployerCheck -->|No| EmployerEmailSent[Auto-send Verification Email]
|
||||||
|
EmployerEmailSent --> EmployerSuccess[Show Success Dialog]
|
||||||
|
|
||||||
|
%% Email Verification Flow
|
||||||
|
CandidateSuccess --> CheckEmail[User Checks Email]
|
||||||
|
EmployerSuccess --> CheckEmail
|
||||||
|
CheckEmail --> ClickLink[Click Verification Link]
|
||||||
|
ClickLink --> VerifyEmail[GET /verify-email?token=xxx]
|
||||||
|
VerifyEmail --> TokenValid{Token Valid & Not Expired?}
|
||||||
|
TokenValid -->|No| VerifyError[Show Error: Invalid/Expired Token]
|
||||||
|
TokenValid -->|Yes| ActivateAccount[Activate Account in DB]
|
||||||
|
ActivateAccount --> VerifySuccess[Show Success: Account Activated]
|
||||||
|
VerifySuccess --> RedirectLogin[Redirect to Login]
|
||||||
|
|
||||||
|
%% Resend Verification
|
||||||
|
VerifyError --> ResendOption{Resend Verification?}
|
||||||
|
ResendOption -->|Yes| ResendEmail[POST /auth/resend-verification]
|
||||||
|
ResendEmail --> RateLimitCheck{Within Rate Limits?}
|
||||||
|
RateLimitCheck -->|No| ResendError[Show Rate Limit Error]
|
||||||
|
RateLimitCheck -->|Yes| FindPending{Pending Verification Found?}
|
||||||
|
FindPending -->|No| ResendGeneric[Generic Success Message]
|
||||||
|
FindPending -->|Yes| ResendSuccess[New Email Sent]
|
||||||
|
ResendSuccess --> CheckEmail
|
||||||
|
ResendGeneric --> CheckEmail
|
||||||
|
ResendOption -->|No| RegisterChoice
|
||||||
|
|
||||||
|
%% ================================
|
||||||
|
%% LOGIN FLOWS
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
RedirectLogin --> LoginPage
|
||||||
|
LoginPage --> LoginForm[Enter Email/Password]
|
||||||
|
LoginForm --> LoginSubmit[POST /auth/login]
|
||||||
|
LoginSubmit --> CredentialsValid{Credentials Valid?}
|
||||||
|
CredentialsValid -->|No| LoginError[Show Login Error]
|
||||||
|
LoginError --> LoginForm
|
||||||
|
|
||||||
|
CredentialsValid -->|Yes| AccountActive{Account Active?}
|
||||||
|
AccountActive -->|No| AccountError[Show Account Status Error]
|
||||||
|
AccountError --> LoginForm
|
||||||
|
|
||||||
|
AccountActive -->|Yes| DeviceCheck{Trusted Device?}
|
||||||
|
|
||||||
|
%% Trusted Device Flow
|
||||||
|
DeviceCheck -->|Yes| TrustedLogin[Update Last Login]
|
||||||
|
TrustedLogin --> IssueTokens[Issue Access + Refresh Tokens]
|
||||||
|
IssueTokens --> LoginSuccess[Store Tokens Locally]
|
||||||
|
LoginSuccess --> LoadUser
|
||||||
|
|
||||||
|
%% New Device Flow (MFA Required)
|
||||||
|
DeviceCheck -->|No| NewDevice[Detect New Device]
|
||||||
|
NewDevice --> GenerateMFA[Generate 6-digit MFA Code]
|
||||||
|
GenerateMFA --> SendMFAEmail[Auto-send MFA Email]
|
||||||
|
SendMFAEmail --> MFAResponse[Return MFA Required Response]
|
||||||
|
MFAResponse --> ShowMFADialog[Show MFA Input Dialog]
|
||||||
|
|
||||||
|
ShowMFADialog --> MFAInput[User Enters 6-digit Code]
|
||||||
|
MFAInput --> MFASubmit[POST /auth/mfa/verify]
|
||||||
|
MFASubmit --> MFAValid{Code Valid & Not Expired?}
|
||||||
|
MFAValid -->|No| MFAError[Show MFA Error]
|
||||||
|
MFAError --> MFARetry{Attempts < 5?}
|
||||||
|
MFARetry -->|Yes| MFAInput
|
||||||
|
MFARetry -->|No| MFALocked[Lock MFA Session]
|
||||||
|
MFALocked --> LoginForm
|
||||||
|
|
||||||
|
MFAValid -->|Yes| RememberDevice{Remember Device?}
|
||||||
|
RememberDevice -->|Yes| AddTrustedDevice[Add to Trusted Devices]
|
||||||
|
RememberDevice -->|No| SkipTrust[Skip Adding Device]
|
||||||
|
AddTrustedDevice --> MFASuccess[Update Last Login]
|
||||||
|
SkipTrust --> MFASuccess
|
||||||
|
MFASuccess --> IssueTokens
|
||||||
|
|
||||||
|
%% MFA Resend Flow
|
||||||
|
ShowMFADialog --> MFAResend{Need Resend?}
|
||||||
|
MFAResend -->|Yes| ResendMFA[POST /auth/mfa/resend]
|
||||||
|
ResendMFA --> ResendLimit{< 3 Resends?}
|
||||||
|
ResendLimit -->|No| ResendLocked[Max Resends Reached]
|
||||||
|
ResendLocked --> LoginForm
|
||||||
|
ResendLimit -->|Yes| NewMFACode[Generate New Code]
|
||||||
|
NewMFACode --> SendNewMFA[Send New Email]
|
||||||
|
SendNewMFA --> ShowMFADialog
|
||||||
|
|
||||||
|
%% ================================
|
||||||
|
%% APP INITIALIZATION & TOKEN MANAGEMENT
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
LoadUser --> TokenExpired{Access Token Expired?}
|
||||||
|
TokenExpired -->|No| Dashboard[Load Dashboard]
|
||||||
|
TokenExpired -->|Yes| RefreshCheck{Has Refresh Token?}
|
||||||
|
|
||||||
|
RefreshCheck -->|No| ClearTokens[Clear Local Storage]
|
||||||
|
ClearTokens --> LandingPage
|
||||||
|
|
||||||
|
RefreshCheck -->|Yes| RefreshAttempt[POST /auth/refresh]
|
||||||
|
RefreshAttempt --> RefreshValid{Refresh Token Valid?}
|
||||||
|
RefreshValid -->|No| ClearTokens
|
||||||
|
RefreshValid -->|Yes| NewTokens[Issue New Access Token]
|
||||||
|
NewTokens --> UpdateStorage[Update Local Storage]
|
||||||
|
UpdateStorage --> Dashboard
|
||||||
|
|
||||||
|
%% ================================
|
||||||
|
%% LOGOUT FLOWS
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
Dashboard --> LogoutChoice{Logout Type}
|
||||||
|
LogoutChoice --> SingleLogout[Logout This Device]
|
||||||
|
LogoutChoice --> LogoutAll[Logout All Devices]
|
||||||
|
|
||||||
|
SingleLogout --> LogoutRequest[POST /auth/logout]
|
||||||
|
LogoutRequest --> BlacklistTokens[Blacklist Tokens]
|
||||||
|
BlacklistTokens --> LogoutComplete[Clear Local Storage]
|
||||||
|
|
||||||
|
LogoutAll --> LogoutAllRequest[POST /auth/logout-all]
|
||||||
|
LogoutAllRequest --> RevokeAllTokens[Revoke All User Tokens]
|
||||||
|
RevokeAllTokens --> LogoutComplete
|
||||||
|
|
||||||
|
LogoutComplete --> LandingPage
|
||||||
|
|
||||||
|
%% ================================
|
||||||
|
%% ERROR HANDLING & EDGE CASES
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
Dashboard --> TokenRevoked{Token Blacklisted?}
|
||||||
|
TokenRevoked -->|Yes| ForceLogout[Force Logout]
|
||||||
|
ForceLogout --> ClearTokens
|
||||||
|
|
||||||
|
%% Rate Limiting
|
||||||
|
LoginForm --> RateLimit{Too Many Attempts?}
|
||||||
|
RateLimit -->|Yes| AccountLock[Temporary Account Lock]
|
||||||
|
AccountLock --> LockMessage[Show Lockout Message]
|
||||||
|
LockMessage --> WaitPeriod[Wait for Unlock]
|
||||||
|
WaitPeriod --> LoginForm
|
||||||
|
|
||||||
|
%% Network Errors
|
||||||
|
LoginSubmit --> NetworkError{Network Error?}
|
||||||
|
NetworkError -->|Yes| RetryLogin[Show Retry Option]
|
||||||
|
RetryLogin --> LoginForm
|
||||||
|
|
||||||
|
%% ================================
|
||||||
|
%% ADMIN FLOWS (Optional)
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
Dashboard --> AdminCheck{Is Admin?}
|
||||||
|
AdminCheck -->|Yes| AdminPanel[Admin Panel]
|
||||||
|
AdminPanel --> ManageVerifications[Manage Pending Verifications]
|
||||||
|
AdminPanel --> ViewSecurityLogs[View Security Logs]
|
||||||
|
AdminPanel --> ManageUsers[Manage User Accounts]
|
||||||
|
AdminCheck -->|No| Dashboard
|
||||||
|
|
||||||
|
%% ================================
|
||||||
|
%% PASSWORD RESET FLOW (Future)
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
LoginForm --> ForgotPassword[Forgot Password Link]
|
||||||
|
ForgotPassword --> ResetEmail[Enter Email for Reset]
|
||||||
|
ResetEmail --> ResetRequest[POST /auth/password-reset/request]
|
||||||
|
ResetRequest --> ResetEmailSent[Password Reset Email Sent]
|
||||||
|
ResetEmailSent --> ResetLink[Click Reset Link in Email]
|
||||||
|
ResetLink --> ResetForm[Enter New Password]
|
||||||
|
ResetForm --> ResetSubmit[POST /auth/password-reset/confirm]
|
||||||
|
ResetSubmit --> ResetSuccess[Password Reset Successfully]
|
||||||
|
ResetSuccess --> LoginForm
|
||||||
|
|
||||||
|
%% ================================
|
||||||
|
%% DEVICE MANAGEMENT
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
Dashboard --> DeviceSettings[Device Settings]
|
||||||
|
DeviceSettings --> ViewDevices[View Trusted Devices]
|
||||||
|
ViewDevices --> RemoveDevice[Remove Trusted Device]
|
||||||
|
RemoveDevice --> DeviceRemoved[Device Removed Successfully]
|
||||||
|
DeviceRemoved --> ViewDevices
|
||||||
|
|
||||||
|
%% ================================
|
||||||
|
%% STYLING
|
||||||
|
%% ================================
|
||||||
|
|
||||||
|
classDef startEnd fill:#e1f5fe
|
||||||
|
classDef process fill:#f3e5f5
|
||||||
|
classDef decision fill:#fff3e0
|
||||||
|
classDef error fill:#ffebee
|
||||||
|
classDef success fill:#e8f5e8
|
||||||
|
classDef security fill:#fce4ec
|
||||||
|
|
||||||
|
class Start,LandingPage startEnd
|
||||||
|
class LoginSuccess,VerifySuccess,MFASuccess,Dashboard success
|
||||||
|
class LoginError,VerifyError,MFAError,AccountError error
|
||||||
|
class DeviceCheck,TokenValid,CredentialsValid,MFAValid decision
|
||||||
|
class GenerateMFA,SendMFAEmail,BlacklistTokens security
|
||||||
|
```
|
@ -29,12 +29,13 @@ import {
|
|||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from 'hooks/AuthContext';
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
import { BackstoryPageProps } from './BackstoryTab';
|
import { BackstoryPageProps } from './BackstoryTab';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
// Email Verification Component
|
// Email Verification Component
|
||||||
const EmailVerificationPage = (props: BackstoryPageProps) => {
|
const EmailVerificationPage = (props: BackstoryPageProps) => {
|
||||||
const { apiClient } = useAuth();
|
const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [verificationToken, setVerificationToken] = useState('');
|
const [verificationToken, setVerificationToken] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
|
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [userType, setUserType] = useState<string>('');
|
const [userType, setUserType] = useState<string>('');
|
||||||
@ -57,62 +58,42 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/1.0/auth/verify-email', {
|
const result = await verifyEmail({ token });
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
if (result) {
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setMessage(data.data.message);
|
setMessage(result.message);
|
||||||
setUserType(data.data.userType);
|
setUserType(result.userType);
|
||||||
|
|
||||||
// Redirect to login after 3 seconds
|
// Redirect to login after 3 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/login';
|
navigate('/login');
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage(data.error?.message || 'Verification failed');
|
setMessage('Email verification failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage('Network error occurred. Please try again.');
|
setMessage('Email verification failed');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResendVerification = async () => {
|
const handleResendVerification = async () => {
|
||||||
// This would need the email address - you might want to add an input for it
|
const email = getPendingVerificationEmail();
|
||||||
// or store it in localStorage from the registration process
|
if (!email) {
|
||||||
try {
|
setMessage('No pending verification email found.');
|
||||||
setLoading(true);
|
return;
|
||||||
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();
|
try {
|
||||||
if (data.success) {
|
const success = await resendEmailVerification(email);
|
||||||
setMessage('Verification email sent! Please check your inbox.');
|
if (success) {
|
||||||
|
setMessage('Verification email sent successfully!');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage('Failed to resend verification email.');
|
setMessage('Failed to resend verification email.');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -167,18 +148,18 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{loading && (
|
{isLoading && (
|
||||||
<Box display="flex" justifyContent="center" my={3}>
|
<Box display="flex" justifyContent="center" my={3}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message && (
|
{(message || error) && (
|
||||||
<Alert
|
<Alert
|
||||||
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
|
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2 }}
|
||||||
>
|
>
|
||||||
{message}
|
{message || error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -189,7 +170,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => window.location.href = '/login'}
|
onClick={() => navigate('/login')}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
Go to Login
|
Go to Login
|
||||||
@ -202,7 +183,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={handleResendVerification}
|
onClick={handleResendVerification}
|
||||||
disabled={loading}
|
disabled={isLoading}
|
||||||
startIcon={<RefreshIcon />}
|
startIcon={<RefreshIcon />}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
@ -211,7 +192,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => window.location.href = '/login'}
|
onClick={() => navigate('/login')}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
Back to Login
|
Back to Login
|
||||||
@ -225,27 +206,34 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MFA Verification Component
|
// MFA Verification Component
|
||||||
const MFAVerificationDialog = ({
|
interface MFAVerificationDialogProps {
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
email,
|
|
||||||
deviceId,
|
|
||||||
deviceName,
|
|
||||||
onVerificationSuccess
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
email: string;
|
|
||||||
deviceId: string;
|
|
||||||
deviceName: string;
|
|
||||||
onVerificationSuccess: (authData: any) => void;
|
onVerificationSuccess: (authData: any) => void;
|
||||||
}) => {
|
}
|
||||||
const { apiClient } = useAuth();
|
const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
|
||||||
|
const {
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onVerificationSuccess
|
||||||
|
} = props;
|
||||||
|
const { verifyMFA, resendMFACode, clearMFA, mfaResponse, isLoading, error } = useAuth();
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [rememberDevice, setRememberDevice] = useState(false);
|
const [rememberDevice, setRememberDevice] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [localError, setLocalError] = useState('');
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
|
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Remove 'HTTP .*: ' from error string */
|
||||||
|
const jsonStr = error.replace(/^[^{]*/, '');
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
setErrorMessage(data.error.message);
|
||||||
|
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@ -254,7 +242,7 @@ const MFAVerificationDialog = ({
|
|||||||
setTimeLeft((prev) => {
|
setTimeLeft((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
setError('MFA code has expired. Please try logging in again.');
|
setLocalError('MFA code has expired. Please try logging in again.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return prev - 1;
|
return prev - 1;
|
||||||
@ -272,73 +260,61 @@ const MFAVerificationDialog = ({
|
|||||||
|
|
||||||
const handleVerifyMFA = async () => {
|
const handleVerifyMFA = async () => {
|
||||||
if (!code || code.length !== 6) {
|
if (!code || code.length !== 6) {
|
||||||
setError('Please enter a valid 6-digit code');
|
setLocalError('Please enter a valid 6-digit code');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
if (!mfaResponse || !mfaResponse.mfaData) {
|
||||||
setError('');
|
setLocalError('MFA data not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/1.0/auth/mfa/verify', {
|
const success = await verifyMFA({
|
||||||
method: 'POST',
|
email: mfaResponse.mfaData.email,
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
code,
|
code,
|
||||||
deviceId,
|
deviceId: mfaResponse.mfaData.deviceId,
|
||||||
rememberDevice,
|
rememberDevice,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
if (success) {
|
||||||
|
onVerificationSuccess({ success: true });
|
||||||
if (data.success) {
|
|
||||||
onVerificationSuccess(data.data);
|
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
|
||||||
setError(data.error?.message || 'Invalid verification code');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Network error occurred. Please try again.');
|
setLocalError('Verification failed. Please try again.');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResendCode = async () => {
|
const handleResendCode = async () => {
|
||||||
setLoading(true);
|
if (!mfaResponse || !mfaResponse.mfaData) {
|
||||||
try {
|
setLocalError('MFA data not available');
|
||||||
const response = await fetch('/api/1.0/auth/mfa/request', {
|
return;
|
||||||
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();
|
try {
|
||||||
if (data.success) {
|
const success = await resendMFACode(mfaResponse.mfaData.email, mfaResponse.mfaData.deviceId, mfaResponse.mfaData.deviceName);
|
||||||
|
if (success) {
|
||||||
setTimeLeft(600); // Reset timer
|
setTimeLeft(600); // Reset timer
|
||||||
setError('');
|
setLocalError('');
|
||||||
alert('New verification code sent to your email');
|
alert('New verification code sent to your email');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to resend code');
|
setLocalError('Failed to resend code');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
clearMFA();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mfaResponse || !mfaResponse.mfaData) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
<SecurityIcon color="primary" />
|
<SecurityIcon color="primary" />
|
||||||
@ -350,14 +326,14 @@ const MFAVerificationDialog = ({
|
|||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Alert severity="info" sx={{ mb: 3 }}>
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
We've detected a login from a new device: <strong>{deviceName}</strong>
|
We've detected a login from a new device: <strong>{mfaResponse.mfaData.deviceName}</strong>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
We've sent a 6-digit verification code to:
|
We've sent a 6-digit verification code to:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" color="primary" gutterBottom>
|
<Typography variant="h6" color="primary" gutterBottom>
|
||||||
{email}
|
{mfaResponse.mfaData.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
@ -367,7 +343,7 @@ const MFAVerificationDialog = ({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||||
setCode(value);
|
setCode(value);
|
||||||
setError('');
|
setLocalError('');
|
||||||
}}
|
}}
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
@ -379,8 +355,8 @@ const MFAVerificationDialog = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
sx={{ mt: 2, mb: 2 }}
|
sx={{ mt: 2, mb: 2 }}
|
||||||
error={!!error}
|
error={!!(localError || errorMessage)}
|
||||||
helperText={error}
|
helperText={localError || errorMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
@ -390,7 +366,7 @@ const MFAVerificationDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleResendCode}
|
onClick={handleResendCode}
|
||||||
disabled={loading || timeLeft > 540} // Allow resend after 1 minute
|
disabled={isLoading || timeLeft > 540} // Allow resend after 1 minute
|
||||||
>
|
>
|
||||||
Resend Code
|
Resend Code
|
||||||
</Button>
|
</Button>
|
||||||
@ -414,15 +390,15 @@ const MFAVerificationDialog = ({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions sx={{ p: 3 }}>
|
<DialogActions sx={{ p: 3 }}>
|
||||||
<Button onClick={onClose} disabled={loading}>
|
<Button onClick={handleClose} disabled={isLoading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleVerifyMFA}
|
onClick={handleVerifyMFA}
|
||||||
disabled={loading || !code || code.length !== 6 || timeLeft === 0}
|
disabled={isLoading || !code || code.length !== 6 || timeLeft === 0}
|
||||||
>
|
>
|
||||||
{loading ? <CircularProgress size={20} /> : 'Verify'}
|
{isLoading ? <CircularProgress size={20} /> : 'Verify'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -441,29 +417,17 @@ const RegistrationSuccessDialog = ({
|
|||||||
email: string;
|
email: string;
|
||||||
userType: string;
|
userType: string;
|
||||||
}) => {
|
}) => {
|
||||||
const [resendLoading, setResendLoading] = useState(false);
|
const { resendEmailVerification, isLoading } = useAuth();
|
||||||
const [resendMessage, setResendMessage] = useState('');
|
const [resendMessage, setResendMessage] = useState('');
|
||||||
|
|
||||||
const handleResendVerification = async () => {
|
const handleResendVerification = async () => {
|
||||||
setResendLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/1.0/auth/resend-verification', {
|
const success = await resendEmailVerification(email);
|
||||||
method: 'POST',
|
if (success) {
|
||||||
headers: {
|
setResendMessage('Verification email sent!');
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
},
|
} catch (error: any) {
|
||||||
body: JSON.stringify({ email }),
|
setResendMessage(error?.message || 'Network error. Please try again.');
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -509,8 +473,8 @@ const RegistrationSuccessDialog = ({
|
|||||||
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
|
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleResendVerification}
|
onClick={handleResendVerification}
|
||||||
disabled={resendLoading}
|
disabled={isLoading}
|
||||||
startIcon={resendLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
|
startIcon={isLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
|
||||||
>
|
>
|
||||||
Resend Email
|
Resend Email
|
||||||
</Button>
|
</Button>
|
||||||
@ -524,69 +488,46 @@ const RegistrationSuccessDialog = ({
|
|||||||
|
|
||||||
// Enhanced Login Component with MFA Support
|
// Enhanced Login Component with MFA Support
|
||||||
const LoginForm = () => {
|
const LoginForm = () => {
|
||||||
const { apiClient } = useAuth();
|
const { login, mfaResponse, isLoading, error } = useAuth();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [mfaRequired, setMfaRequired] = useState(false);
|
useEffect(() => {
|
||||||
const [mfaData, setMfaData] = useState<any>(null);
|
if (!error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Remove 'HTTP .*: ' from error string */
|
||||||
|
const jsonStr = error.replace(/^[^{]*/, '');
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
setErrorMessage(data.error.message);
|
||||||
|
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
const success = await login({
|
||||||
const response = await fetch('/api/1.0/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
login: email,
|
login: email,
|
||||||
password,
|
password
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
console.log(`login success: ${success}`);
|
||||||
|
if (success) {
|
||||||
if (data.success) {
|
// Redirect based on user type - this could be handled in AuthContext
|
||||||
if (data.data.mfaRequired) {
|
// or by a higher-level component that listens to auth state changes
|
||||||
// MFA required for new device
|
handleLoginSuccess();
|
||||||
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) => {
|
const handleMFASuccess = (authData: any) => {
|
||||||
handleLoginSuccess(authData);
|
handleLoginSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginSuccess = (authData: any) => {
|
const handleLoginSuccess = () => {
|
||||||
// Store tokens
|
// This could be handled by a router or parent component
|
||||||
localStorage.setItem('accessToken', authData.accessToken);
|
// For now, just showing the pattern
|
||||||
localStorage.setItem('refreshToken', authData.refreshToken);
|
console.log('Login successful - redirect to dashboard');
|
||||||
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 (
|
return (
|
||||||
@ -612,9 +553,9 @@ const LoginForm = () => {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{errorMessage && (
|
||||||
<Alert severity="error" sx={{ mt: 2 }}>
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
{error}
|
{errorMessage}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -622,23 +563,18 @@ const LoginForm = () => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={loading}
|
disabled={isLoading}
|
||||||
sx={{ mt: 3, mb: 2 }}
|
sx={{ mt: 3, mb: 2 }}
|
||||||
>
|
>
|
||||||
{loading ? <CircularProgress size={20} /> : 'Sign In'}
|
{isLoading ? <CircularProgress size={20} /> : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* MFA Dialog */}
|
{/* MFA Dialog */}
|
||||||
{mfaRequired && mfaData && (
|
|
||||||
<MFAVerificationDialog
|
<MFAVerificationDialog
|
||||||
open={mfaRequired}
|
open={mfaResponse?.mfaRequired || false}
|
||||||
onClose={() => setMfaRequired(false)}
|
onClose={() => { }} // This will be handled by clearMFA in the dialog
|
||||||
email={mfaData.email}
|
|
||||||
deviceId={mfaData.deviceId}
|
|
||||||
deviceName={mfaData.deviceName}
|
|
||||||
onVerificationSuccess={handleMFASuccess}
|
onVerificationSuccess={handleMFASuccess}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,742 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -27,6 +27,9 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const overrides: any = {
|
const overrides: any = {
|
||||||
|
p: { component: (element: any) =>{
|
||||||
|
return <div>{element.children}</div>
|
||||||
|
}},
|
||||||
pre: {
|
pre: {
|
||||||
component: (element: any) => {
|
component: (element: any) => {
|
||||||
const { className } = element.children.props;
|
const { className } = element.children.props;
|
||||||
|
@ -7,26 +7,40 @@ import { formatApiRequest, toCamelCase } from '../types/conversion';
|
|||||||
// Types and Interfaces
|
// Types and Interfaces
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
export interface AuthState {
|
|
||||||
|
interface AuthState {
|
||||||
user: Types.User | null;
|
user: Types.User | null;
|
||||||
guest: Types.Guest | null;
|
guest: Types.Guest | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
mfaResponse: Types.MFARequestResponse | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
interface LoginRequest {
|
||||||
login: string; // email or username
|
login: string; // email or username
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordResetRequest {
|
interface MFAVerificationRequest {
|
||||||
|
email: string;
|
||||||
|
code: string;
|
||||||
|
deviceId: string;
|
||||||
|
rememberDevice?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailVerificationRequest {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResendVerificationRequest {
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export API client types for convenience
|
interface PasswordResetRequest {
|
||||||
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Token Storage Constants
|
// Token Storage Constants
|
||||||
@ -37,7 +51,8 @@ const TOKEN_STORAGE = {
|
|||||||
REFRESH_TOKEN: 'refreshToken',
|
REFRESH_TOKEN: 'refreshToken',
|
||||||
USER_DATA: 'userData',
|
USER_DATA: 'userData',
|
||||||
TOKEN_EXPIRY: 'tokenExpiry',
|
TOKEN_EXPIRY: 'tokenExpiry',
|
||||||
GUEST_DATA: 'guestData'
|
GUEST_DATA: 'guestData',
|
||||||
|
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@ -195,14 +210,15 @@ function getStoredGuestData(): Types.Guest | null {
|
|||||||
// Main Authentication Hook
|
// Main Authentication Hook
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
export function useAuthenticationLogic() {
|
function useAuthenticationLogic() {
|
||||||
const [authState, setAuthState] = useState<AuthState>({
|
const [authState, setAuthState] = useState<AuthState>({
|
||||||
user: null,
|
user: null,
|
||||||
guest: null,
|
guest: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
error: null
|
error: null,
|
||||||
|
mfaResponse: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [apiClient] = useState(() => new ApiClient());
|
const [apiClient] = useState(() => new ApiClient());
|
||||||
@ -242,7 +258,8 @@ export function useAuthenticationLogic() {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
error: null
|
error: null,
|
||||||
|
mfaResponse: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -263,7 +280,8 @@ export function useAuthenticationLogic() {
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
error: null
|
error: null,
|
||||||
|
mfaResponse: null
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Token refreshed successfully');
|
console.log('Token refreshed successfully');
|
||||||
@ -278,7 +296,8 @@ export function useAuthenticationLogic() {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
error: null
|
error: null,
|
||||||
|
mfaResponse: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -291,7 +310,8 @@ export function useAuthenticationLogic() {
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
error: null
|
error: null,
|
||||||
|
mfaResponse: null
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Restored authentication from stored tokens');
|
console.log('Restored authentication from stored tokens');
|
||||||
@ -308,7 +328,8 @@ export function useAuthenticationLogic() {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
error: null
|
error: null,
|
||||||
|
mfaResponse: null
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
initializationCompleted.current = true;
|
initializationCompleted.current = true;
|
||||||
@ -348,12 +369,27 @@ export function useAuthenticationLogic() {
|
|||||||
return () => clearTimeout(refreshTimer);
|
return () => clearTimeout(refreshTimer);
|
||||||
}, [authState.isAuthenticated, initializeAuth]);
|
}, [authState.isAuthenticated, initializeAuth]);
|
||||||
|
|
||||||
|
// Enhanced login with MFA support
|
||||||
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
|
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
|
||||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null, mfaResponse: null, mfaData: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResponse = await apiClient.login(loginData);
|
const result = await apiClient.login({
|
||||||
|
login: loginData.login,
|
||||||
|
password: loginData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('mfaRequired' in result) {
|
||||||
|
// MFA required for new device
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
mfaResponse: result,
|
||||||
|
}));
|
||||||
|
return false; // Login not complete yet
|
||||||
|
} else {
|
||||||
|
// Normal login success
|
||||||
|
const authResponse: Types.AuthResponse = result;
|
||||||
storeAuthData(authResponse);
|
storeAuthData(authResponse);
|
||||||
apiClient.setAuthToken(authResponse.accessToken);
|
apiClient.setAuthToken(authResponse.accessToken);
|
||||||
|
|
||||||
@ -362,13 +398,54 @@ export function useAuthenticationLogic() {
|
|||||||
user: authResponse.user,
|
user: authResponse.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null
|
error: null,
|
||||||
|
mfaResponse: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('Login successful');
|
console.log('Login successful');
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Network error occurred. Please try again.';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage,
|
||||||
|
mfaResponse: null,
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
// MFA verification
|
||||||
|
const verifyMFA = useCallback(async (mfaData: MFAVerificationRequest): Promise<boolean> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.verifyMFA(mfaData);
|
||||||
|
|
||||||
|
if (result.accessToken) {
|
||||||
|
const authResponse: Types.AuthResponse = result;
|
||||||
|
storeAuthData(authResponse);
|
||||||
|
apiClient.setAuthToken(authResponse.accessToken);
|
||||||
|
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
user: authResponse.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
mfaResponse: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('MFA verification successful');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
const errorMessage = error instanceof Error ? error.message : 'MFA verification failed';
|
||||||
|
console.log(errorMessage);
|
||||||
setAuthState(prev => ({
|
setAuthState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -378,6 +455,91 @@ export function useAuthenticationLogic() {
|
|||||||
}
|
}
|
||||||
}, [apiClient]);
|
}, [apiClient]);
|
||||||
|
|
||||||
|
// Resend MFA code
|
||||||
|
const resendMFACode = useCallback(async (email: string, deviceId: string, deviceName: string): Promise<boolean> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.requestMFA({
|
||||||
|
email,
|
||||||
|
password: '', // This would need to be stored securely or re-entered
|
||||||
|
deviceId,
|
||||||
|
deviceName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to resend MFA code';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
// Clear MFA state
|
||||||
|
const clearMFA = useCallback(() => {
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
mfaResponse: null,
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Email verification
|
||||||
|
const verifyEmail = useCallback(async (verificationData: EmailVerificationRequest): Promise<{ message: string; userType: string } | null> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.verifyEmail(verificationData);
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||||
|
return {
|
||||||
|
message: result.message || 'Email verified successfully',
|
||||||
|
userType: result.userType || 'user'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Email verification failed';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
// Resend email verification
|
||||||
|
const resendEmailVerification = useCallback(async (email: string): Promise<boolean> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.resendVerificationEmail({ email });
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to resend verification email';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
// Store pending verification email
|
||||||
|
const setPendingVerificationEmail = useCallback((email: string) => {
|
||||||
|
localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get pending verification email
|
||||||
|
const getPendingVerificationEmail = useCallback((): string | null => {
|
||||||
|
return localStorage.getItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearStoredAuth();
|
clearStoredAuth();
|
||||||
apiClient.clearAuthToken();
|
apiClient.clearAuthToken();
|
||||||
@ -391,7 +553,8 @@ export function useAuthenticationLogic() {
|
|||||||
guest,
|
guest,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null
|
error: null,
|
||||||
|
mfaResponse: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('User logged out');
|
console.log('User logged out');
|
||||||
@ -413,13 +576,11 @@ export function useAuthenticationLogic() {
|
|||||||
const candidate = await apiClient.createCandidate(candidateData);
|
const candidate = await apiClient.createCandidate(candidateData);
|
||||||
console.log('Candidate created:', candidate);
|
console.log('Candidate created:', candidate);
|
||||||
|
|
||||||
// Auto-login after successful registration
|
// Store email for potential verification resend
|
||||||
const loginSuccess = await login({
|
setPendingVerificationEmail(candidateData.email);
|
||||||
login: candidateData.email,
|
|
||||||
password: candidateData.password
|
|
||||||
});
|
|
||||||
|
|
||||||
return loginSuccess;
|
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||||
setAuthState(prev => ({
|
setAuthState(prev => ({
|
||||||
@ -429,7 +590,7 @@ export function useAuthenticationLogic() {
|
|||||||
}));
|
}));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [apiClient, login]);
|
}, [apiClient, setPendingVerificationEmail]);
|
||||||
|
|
||||||
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
|
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
|
||||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
@ -438,12 +599,11 @@ export function useAuthenticationLogic() {
|
|||||||
const employer = await apiClient.createEmployer(employerData);
|
const employer = await apiClient.createEmployer(employerData);
|
||||||
console.log('Employer created:', employer);
|
console.log('Employer created:', employer);
|
||||||
|
|
||||||
const loginSuccess = await login({
|
// Store email for potential verification resend
|
||||||
login: employerData.email,
|
setPendingVerificationEmail(employerData.email);
|
||||||
password: employerData.password
|
|
||||||
});
|
|
||||||
|
|
||||||
return loginSuccess;
|
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||||
setAuthState(prev => ({
|
setAuthState(prev => ({
|
||||||
@ -453,7 +613,7 @@ export function useAuthenticationLogic() {
|
|||||||
}));
|
}));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [apiClient, login]);
|
}, [apiClient, setPendingVerificationEmail]);
|
||||||
|
|
||||||
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
|
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
|
||||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
@ -507,6 +667,13 @@ export function useAuthenticationLogic() {
|
|||||||
apiClient,
|
apiClient,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
verifyMFA,
|
||||||
|
resendMFACode,
|
||||||
|
clearMFA,
|
||||||
|
verifyEmail,
|
||||||
|
resendEmailVerification,
|
||||||
|
setPendingVerificationEmail,
|
||||||
|
getPendingVerificationEmail,
|
||||||
createCandidateAccount,
|
createCandidateAccount,
|
||||||
createEmployerAccount,
|
createEmployerAccount,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
@ -521,7 +688,7 @@ export function useAuthenticationLogic() {
|
|||||||
|
|
||||||
const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | null>(null);
|
const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const auth = useAuthenticationLogic();
|
const auth = useAuthenticationLogic();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -531,7 +698,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
function useAuth() {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
@ -549,7 +716,7 @@ interface ProtectedRouteProps {
|
|||||||
requiredUserType?: Types.UserType;
|
requiredUserType?: Types.UserType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProtectedRoute({
|
function ProtectedRoute({
|
||||||
children,
|
children,
|
||||||
fallback = <div>Please log in to access this page.</div>,
|
fallback = <div>Please log in to access this page.</div>,
|
||||||
requiredUserType
|
requiredUserType
|
||||||
@ -573,3 +740,13 @@ export function ProtectedRoute({
|
|||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AuthState, LoginRequest, MFAVerificationRequest, EmailVerificationRequest, ResendVerificationRequest, PasswordResetRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
|
||||||
|
|
||||||
|
export {
|
||||||
|
useAuthenticationLogic, AuthProvider, useAuth, ProtectedRoute
|
||||||
|
}
|
@ -142,6 +142,7 @@ const documents : DocType[] = [
|
|||||||
{ title: "BETA", route: "beta", description: "Details about the current beta version and upcoming features", icon: <CodeIcon /> },
|
{ title: "BETA", route: "beta", description: "Details about the current beta version and upcoming features", icon: <CodeIcon /> },
|
||||||
{ title: "Resume Generation Architecture", route: "resume-generation", description: "Technical overview of how resumes are processed and generated", icon: <LayersIcon /> },
|
{ title: "Resume Generation Architecture", route: "resume-generation", description: "Technical overview of how resumes are processed and generated", icon: <LayersIcon /> },
|
||||||
{ title: "Application Architecture", route: "about-app", description: "System design and technical stack information", icon: <LayersIcon /> },
|
{ title: "Application Architecture", route: "about-app", description: "System design and technical stack information", icon: <LayersIcon /> },
|
||||||
|
{ title: "Authentication Architecture", route: "authentication.md", description: "Complete authentication architecture", icon: <LayersIcon /> },
|
||||||
{ title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions", icon: <DashboardIcon /> },
|
{ title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions", icon: <DashboardIcon /> },
|
||||||
{ title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts", icon: <DashboardIcon /> },
|
{ title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts", icon: <DashboardIcon /> },
|
||||||
{ title: "Chat Mockup", route: "mockup-chat-system", description: "Mockup of chat system", icon: <DashboardIcon /> },
|
{ title: "Chat Mockup", route: "mockup-chat-system", description: "Mockup of chat system", icon: <DashboardIcon /> },
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Enhanced API Client with Streaming Support and Date Conversion
|
* API Client with Streaming Support and Date Conversion
|
||||||
*
|
*
|
||||||
* This demonstrates how to use the generated types with the conversion utilities
|
* This demonstrates how to use the generated types with the conversion utilities
|
||||||
* for seamless frontend-backend communication, including streaming responses and
|
* for seamless frontend-backend communication, including streaming responses and
|
||||||
@ -54,11 +54,6 @@ interface StreamingResponse {
|
|||||||
promise: Promise<Types.ChatMessage[]>;
|
promise: Promise<Types.ChatMessage[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
|
||||||
login: string; // email or username
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCandidateRequest {
|
export interface CreateCandidateRequest {
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
@ -261,37 +256,38 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* Request MFA for new device
|
* Request MFA for new device
|
||||||
*/
|
*/
|
||||||
async requestMFA(request: MFARequest): Promise<MFARequestResponse> {
|
async requestMFA(request: MFARequest): Promise<Types.MFARequestResponse> {
|
||||||
const response = await fetch(`${this.baseUrl}/auth/mfa/request`, {
|
const response = await fetch(`${this.baseUrl}/auth/mfa/request`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
body: JSON.stringify(formatApiRequest(request))
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<MFARequestResponse>(response);
|
return handleApiResponse<Types.MFARequestResponse>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify MFA code
|
* Verify MFA code
|
||||||
*/
|
*/
|
||||||
async verifyMFA(request: MFAVerifyRequest): Promise<Types.AuthResponse> {
|
async verifyMFA(request: Types.MFAVerifyRequest): Promise<Types.AuthResponse> {
|
||||||
|
const formattedRequest = formatApiRequest(request)
|
||||||
const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, {
|
const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
body: JSON.stringify(formatApiRequest(request))
|
body: JSON.stringify(formattedRequest)
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.AuthResponse>(response);
|
return handleApiResponse<Types.AuthResponse>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced login with device detection
|
* login with device detection
|
||||||
*/
|
*/
|
||||||
async loginEnhanced(email: string, password: string): Promise<Types.AuthResponse | MFARequestResponse> {
|
async login(auth: Types.LoginRequest): Promise<Types.AuthResponse | Types.MFARequestResponse> {
|
||||||
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
body: JSON.stringify(formatApiRequest({ login: email, password }))
|
body: JSON.stringify(formatApiRequest(auth))
|
||||||
});
|
});
|
||||||
|
|
||||||
// This could return either a full auth response or MFA request
|
// This could return either a full auth response or MFA request
|
||||||
@ -307,7 +303,7 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* Logout with token revocation
|
* Logout with token revocation
|
||||||
*/
|
*/
|
||||||
async logoutEnhanced(accessToken: string, refreshToken: string): Promise<{ message: string; tokensRevoked: any }> {
|
async logout(accessToken: string, refreshToken: string): Promise<{ message: string; tokensRevoked: any }> {
|
||||||
const response = await fetch(`${this.baseUrl}/auth/logout`, {
|
const response = await fetch(`${this.baseUrl}/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
@ -495,27 +491,6 @@ class ApiClient {
|
|||||||
// ============================
|
// ============================
|
||||||
// Authentication Methods
|
// Authentication Methods
|
||||||
// ============================
|
// ============================
|
||||||
async login(request: LoginRequest): Promise<Types.AuthResponse> {
|
|
||||||
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.defaultHeaders,
|
|
||||||
body: JSON.stringify(formatApiRequest(request))
|
|
||||||
});
|
|
||||||
|
|
||||||
// AuthResponse doesn't typically have date fields, use standard handler
|
|
||||||
return handleApiResponse<Types.AuthResponse>(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout(accessToken: string, refreshToken: string): Promise<Types.ApiResponse> {
|
|
||||||
const response = await fetch(`${this.baseUrl}/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.defaultHeaders,
|
|
||||||
body: JSON.stringify(formatApiRequest({ accessToken, refreshToken }))
|
|
||||||
});
|
|
||||||
|
|
||||||
return handleApiResponse<Types.ApiResponse>(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshToken(refreshToken: string): Promise<Types.AuthResponse> {
|
async refreshToken(refreshToken: string): Promise<Types.AuthResponse> {
|
||||||
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -1094,7 +1069,7 @@ class ApiClient {
|
|||||||
|
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Enhanced Request/Response Types
|
// Request/Response Types
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
export interface CreateCandidateWithVerificationRequest {
|
export interface CreateCandidateWithVerificationRequest {
|
||||||
@ -1133,13 +1108,6 @@ export interface MFARequest {
|
|||||||
deviceName: string;
|
deviceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MFAVerifyRequest {
|
|
||||||
email: string;
|
|
||||||
code: string;
|
|
||||||
deviceId: string;
|
|
||||||
rememberDevice: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegistrationResponse {
|
export interface RegistrationResponse {
|
||||||
message: string;
|
message: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -1152,12 +1120,6 @@ export interface EmailVerificationResponse {
|
|||||||
userType: string;
|
userType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MFARequestResponse {
|
|
||||||
mfaRequired: boolean;
|
|
||||||
message: string;
|
|
||||||
deviceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrustedDevice {
|
export interface TrustedDevice {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
@ -1202,7 +1164,7 @@ export interface PendingVerification {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
// Registration with email verification
|
// Registration with email verification
|
||||||
const apiClient = new EnhancedApiClient();
|
const apiClient = new ApiClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.createCandidateWithVerification({
|
const result = await apiClient.createCandidateWithVerification({
|
||||||
@ -1226,9 +1188,9 @@ try {
|
|||||||
console.error('Registration failed:', error);
|
console.error('Registration failed:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced login with MFA support
|
// login with MFA support
|
||||||
try {
|
try {
|
||||||
const loginResult = await apiClient.loginEnhanced('user@example.com', 'password');
|
const loginResult = await apiClient.login('user@example.com', 'password');
|
||||||
|
|
||||||
if ('mfaRequired' in loginResult && loginResult.mfaRequired) {
|
if ('mfaRequired' in loginResult && loginResult.mfaRequired) {
|
||||||
// Show MFA dialog
|
// Show MFA dialog
|
||||||
|
@ -132,7 +132,7 @@ export function formatApiRequest<T extends Record<string, any>>(data: T): Record
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatted;
|
return toSnakeCase(formatted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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-06-01T01:48:43.853130
|
// Generated on: 2025-06-01T20:40:46.797024
|
||||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@ -145,6 +145,7 @@ export interface BaseUser {
|
|||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseUserWithType {
|
export interface BaseUserWithType {
|
||||||
@ -160,6 +161,7 @@ export interface BaseUserWithType {
|
|||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
|
isAdmin?: boolean;
|
||||||
userType: "candidate" | "employer" | "guest";
|
userType: "candidate" | "employer" | "guest";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,6 +178,7 @@ export interface Candidate {
|
|||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
|
isAdmin?: boolean;
|
||||||
userType: "candidate";
|
userType: "candidate";
|
||||||
username: string;
|
username: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -395,6 +398,7 @@ export interface Employer {
|
|||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
|
isAdmin?: boolean;
|
||||||
userType: "employer";
|
userType: "employer";
|
||||||
companyName: string;
|
companyName: string;
|
||||||
industry: string;
|
industry: string;
|
||||||
@ -539,13 +543,31 @@ export interface Location {
|
|||||||
address?: string;
|
address?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MFARequest {
|
export interface LoginRequest {
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MFAData {
|
||||||
|
message: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
codeSent: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MFARequest {
|
||||||
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MFARequestResponse {
|
||||||
|
mfaRequired: boolean;
|
||||||
|
mfaData?: MFAData;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MFAVerifyRequest {
|
export interface MFAVerifyRequest {
|
||||||
email: string;
|
email: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
@ -350,6 +350,154 @@ class RedisDatabase:
|
|||||||
await self.redis.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# MFA and Email Verification operations
|
# MFA and Email Verification operations
|
||||||
|
async def find_verification_token_by_email(self, email: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find pending verification token by email address"""
|
||||||
|
try:
|
||||||
|
pattern = "email_verification:*"
|
||||||
|
cursor = 0
|
||||||
|
email_lower = email.lower()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
token_data = await self.redis.get(key)
|
||||||
|
if token_data:
|
||||||
|
verification_info = json.loads(token_data)
|
||||||
|
if (verification_info.get("email", "").lower() == email_lower and
|
||||||
|
not verification_info.get("verified", False)):
|
||||||
|
# Extract token from key
|
||||||
|
token = key.replace("email_verification:", "")
|
||||||
|
verification_info["token"] = token
|
||||||
|
return verification_info
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error finding verification token by email {email}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_pending_verifications_count(self) -> int:
|
||||||
|
"""Get count of pending email verifications (admin function)"""
|
||||||
|
try:
|
||||||
|
pattern = "email_verification:*"
|
||||||
|
cursor = 0
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
token_data = await self.redis.get(key)
|
||||||
|
if token_data:
|
||||||
|
verification_info = json.loads(token_data)
|
||||||
|
if not verification_info.get("verified", False):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error counting pending verifications: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def cleanup_expired_verification_tokens(self) -> int:
|
||||||
|
"""Clean up expired verification tokens and return count of cleaned tokens"""
|
||||||
|
try:
|
||||||
|
pattern = "email_verification:*"
|
||||||
|
cursor = 0
|
||||||
|
cleaned_count = 0
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
token_data = await self.redis.get(key)
|
||||||
|
if token_data:
|
||||||
|
verification_info = json.loads(token_data)
|
||||||
|
expires_at = datetime.fromisoformat(verification_info.get("expires_at", ""))
|
||||||
|
|
||||||
|
if current_time > expires_at:
|
||||||
|
await self.redis.delete(key)
|
||||||
|
cleaned_count += 1
|
||||||
|
logger.debug(f"🧹 Cleaned expired verification token for {verification_info.get('email')}")
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
if cleaned_count > 0:
|
||||||
|
logger.info(f"🧹 Cleaned up {cleaned_count} expired verification tokens")
|
||||||
|
|
||||||
|
return cleaned_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error cleaning up expired verification tokens: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def get_verification_attempts_count(self, email: str) -> int:
|
||||||
|
"""Get the number of verification emails sent for an email in the last 24 hours"""
|
||||||
|
try:
|
||||||
|
key = f"verification_attempts:{email.lower()}"
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
attempts_data = json.loads(data)
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
window_start = current_time - timedelta(hours=24)
|
||||||
|
|
||||||
|
# Filter out old attempts
|
||||||
|
recent_attempts = [
|
||||||
|
attempt for attempt in attempts_data
|
||||||
|
if datetime.fromisoformat(attempt) > window_start
|
||||||
|
]
|
||||||
|
|
||||||
|
return len(recent_attempts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting verification attempts count for {email}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def record_verification_attempt(self, email: str) -> bool:
|
||||||
|
"""Record a verification email attempt"""
|
||||||
|
try:
|
||||||
|
key = f"verification_attempts:{email.lower()}"
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Get existing attempts
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
attempts_data = json.loads(data) if data else []
|
||||||
|
|
||||||
|
# Add current attempt
|
||||||
|
attempts_data.append(current_time.isoformat())
|
||||||
|
|
||||||
|
# Keep only last 24 hours of attempts
|
||||||
|
window_start = current_time - timedelta(hours=24)
|
||||||
|
recent_attempts = [
|
||||||
|
attempt for attempt in attempts_data
|
||||||
|
if datetime.fromisoformat(attempt) > window_start
|
||||||
|
]
|
||||||
|
|
||||||
|
# Store with 24 hour expiration
|
||||||
|
await self.redis.setex(
|
||||||
|
key,
|
||||||
|
24 * 60 * 60, # 24 hours
|
||||||
|
json.dumps(recent_attempts)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error recording verification attempt for {email}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def store_email_verification_token(self, email: str, token: str, user_type: str, user_data: dict) -> bool:
|
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"""
|
"""Store email verification token with user data"""
|
||||||
try:
|
try:
|
||||||
@ -410,6 +558,7 @@ class RedisDatabase:
|
|||||||
async def store_mfa_code(self, email: str, code: str, device_id: str) -> bool:
|
async def store_mfa_code(self, email: str, code: str, device_id: str) -> bool:
|
||||||
"""Store MFA code for verification"""
|
"""Store MFA code for verification"""
|
||||||
try:
|
try:
|
||||||
|
logger.info("🔐 Storing MFA code for email: %s", email )
|
||||||
key = f"mfa_code:{email.lower()}:{device_id}"
|
key = f"mfa_code:{email.lower()}:{device_id}"
|
||||||
mfa_data = {
|
mfa_data = {
|
||||||
"code": code,
|
"code": code,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
from typing import Tuple
|
||||||
from logger import logger
|
from logger import logger
|
||||||
from email.mime.text import MIMEText # type: ignore
|
from email.mime.text import MIMEText # type: ignore
|
||||||
from email.mime.multipart import MIMEMultipart # type: ignore
|
from email.mime.multipart import MIMEMultipart # type: ignore
|
||||||
@ -11,165 +12,16 @@ from database import RedisDatabase
|
|||||||
|
|
||||||
class EmailService:
|
class EmailService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com")
|
# Configure these in your .env file
|
||||||
|
self.smtp_server = os.getenv("SMTP_SERVER")
|
||||||
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||||
self.email_user = os.getenv("EMAIL_USER", "your-app@example.com")
|
self.email_user = os.getenv("EMAIL_USER")
|
||||||
self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password")
|
self.email_password = os.getenv("EMAIL_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.from_name = os.getenv("FROM_NAME", "Backstory")
|
||||||
self.app_name = os.getenv("APP_NAME", "Backstory")
|
self.app_name = os.getenv("APP_NAME", "Backstory")
|
||||||
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com")
|
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com")
|
||||||
|
if not self.smtp_server or self.smtp_port == 0 or self.email_user is None or self.email_password is None:
|
||||||
|
raise ValueError("SMTP configuration is not set in the environment variables")
|
||||||
|
|
||||||
def _get_template(self, template_name: str) -> dict:
|
def _get_template(self, template_name: str) -> dict:
|
||||||
"""Get email template by name"""
|
"""Get email template by name"""
|
||||||
@ -279,6 +131,8 @@ class EnhancedEmailService:
|
|||||||
async def _send_email(self, to_email: str, subject: str, html_content: str):
|
async def _send_email(self, to_email: str, subject: str, html_content: str):
|
||||||
"""Send email using SMTP with improved error handling"""
|
"""Send email using SMTP with improved error handling"""
|
||||||
try:
|
try:
|
||||||
|
if not self.email_user:
|
||||||
|
raise ValueError("Email user is not configured")
|
||||||
# Create message
|
# Create message
|
||||||
msg = MIMEMultipart('alternative')
|
msg = MIMEMultipart('alternative')
|
||||||
msg['From'] = f"{self.from_name} <{self.email_user}>"
|
msg['From'] = f"{self.from_name} <{self.email_user}>"
|
||||||
@ -292,6 +146,9 @@ class EnhancedEmailService:
|
|||||||
|
|
||||||
# Send email with connection pooling and retry logic
|
# Send email with connection pooling and retry logic
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
|
if not self.smtp_server or self.smtp_port == 0 or not self.email_user or not self.email_password:
|
||||||
|
raise ValueError("SMTP configuration is not set in the environment variables")
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
||||||
@ -365,3 +222,65 @@ class EmailRateLimiter:
|
|||||||
ttl_minutes * 60,
|
ttl_minutes * 60,
|
||||||
json.dumps([timestamp.isoformat()])
|
json.dumps([timestamp.isoformat()])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class VerificationEmailRateLimiter:
|
||||||
|
def __init__(self, database: RedisDatabase):
|
||||||
|
self.database = database
|
||||||
|
self.max_attempts_per_hour = 3 # Maximum 3 emails per hour
|
||||||
|
self.max_attempts_per_day = 10 # Maximum 10 emails per day
|
||||||
|
self.cooldown_minutes = 5 # 5 minute cooldown between emails
|
||||||
|
|
||||||
|
async def can_send_verification_email(self, email: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Check if verification email can be sent based on rate limiting
|
||||||
|
Returns (can_send, reason_if_not)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
email_lower = email.lower()
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Check daily limit
|
||||||
|
daily_count = await self.database.get_verification_attempts_count(email)
|
||||||
|
if daily_count >= self.max_attempts_per_day:
|
||||||
|
return False, f"Daily limit reached. You can request up to {self.max_attempts_per_day} verification emails per day."
|
||||||
|
|
||||||
|
# Check hourly limit
|
||||||
|
hour_ago = current_time - timedelta(hours=1)
|
||||||
|
hourly_key = f"verification_attempts:{email_lower}"
|
||||||
|
data = await self.database.redis.get(hourly_key)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
attempts_data = json.loads(data)
|
||||||
|
recent_attempts = [
|
||||||
|
attempt for attempt in attempts_data
|
||||||
|
if datetime.fromisoformat(attempt) > hour_ago
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(recent_attempts) >= self.max_attempts_per_hour:
|
||||||
|
return False, f"Hourly limit reached. You can request up to {self.max_attempts_per_hour} verification emails per hour."
|
||||||
|
|
||||||
|
# Check cooldown period
|
||||||
|
if recent_attempts:
|
||||||
|
last_attempt = max(datetime.fromisoformat(attempt) for attempt in recent_attempts)
|
||||||
|
time_since_last = current_time - last_attempt
|
||||||
|
|
||||||
|
if time_since_last.total_seconds() < self.cooldown_minutes * 60:
|
||||||
|
remaining_minutes = self.cooldown_minutes - int(time_since_last.total_seconds() / 60)
|
||||||
|
return False, f"Please wait {remaining_minutes} more minute(s) before requesting another email."
|
||||||
|
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error checking verification email rate limit: {e}")
|
||||||
|
# On error, be conservative and deny
|
||||||
|
return False, "Rate limit check failed. Please try again later."
|
||||||
|
|
||||||
|
async def record_email_sent(self, email: str):
|
||||||
|
"""Record that a verification email was sent"""
|
||||||
|
await self.database.record_verification_attempt(email)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
|
||||||
|
|
@ -10,90 +10,93 @@ EMAIL_TEMPLATES = {
|
|||||||
<title>Email Verification</title>
|
<title>Email Verification</title>
|
||||||
<style>
|
<style>
|
||||||
body {{
|
body {{
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #2E2E2E;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #f5f5f5;
|
background-color: #D3CDBF;
|
||||||
}}
|
}}
|
||||||
.container {{
|
.container {{
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background-color: white;
|
background-color: #FFFFFF;
|
||||||
border-radius: 12px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(26, 37, 54, 0.15);
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}}
|
}}
|
||||||
.header {{
|
.header {{
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #1A2536 0%, #4A7A7D 100%);
|
||||||
color: white;
|
color: #D3CDBF;
|
||||||
padding: 40px 30px;
|
padding: 40px 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}}
|
}}
|
||||||
.header h1 {{
|
.header h1 {{
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
font-size: 28px;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
color: #D3CDBF;
|
||||||
}}
|
}}
|
||||||
.header p {{
|
.header p {{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
font-size: 16px;
|
font-size: 1rem;
|
||||||
|
color: #D3CDBF;
|
||||||
}}
|
}}
|
||||||
.content {{
|
.content {{
|
||||||
padding: 40px 30px;
|
padding: 40px 30px;
|
||||||
}}
|
}}
|
||||||
.content h2 {{
|
.content h2 {{
|
||||||
margin: 0 0 20px 0;
|
margin: 0 0 20px 0;
|
||||||
color: #333;
|
color: #2E2E2E;
|
||||||
font-size: 24px;
|
font-size: 1.75rem;
|
||||||
|
font-weight: 500;
|
||||||
}}
|
}}
|
||||||
.button {{
|
.button {{
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #667eea;
|
background: #D4A017;
|
||||||
color: white;
|
color: #FFFFFF;
|
||||||
padding: 16px 32px;
|
padding: 16px 32px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
font-size: 16px;
|
font-size: 1rem;
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease;
|
||||||
}}
|
}}
|
||||||
.button:hover {{
|
.button:hover {{
|
||||||
background: #5a6fd8;
|
background: rgba(212, 160, 23, 0.8);
|
||||||
}}
|
}}
|
||||||
.link-text {{
|
.link-text {{
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
color: #667eea;
|
color: #4A7A7D;
|
||||||
background-color: #f8f9ff;
|
background-color: rgba(74, 122, 125, 0.1);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-family: monospace;
|
font-family: 'Roboto', monospace;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}}
|
}}
|
||||||
.footer {{
|
.footer {{
|
||||||
background: #f8f9fa;
|
background: #D3CDBF;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
color: #6c757d;
|
color: #1A2536;
|
||||||
border-top: 1px solid #e9ecef;
|
border-top: 1px solid rgba(26, 37, 54, 0.1);
|
||||||
}}
|
}}
|
||||||
.security-note {{
|
.security-note {{
|
||||||
background: #fff3cd;
|
background: rgba(212, 160, 23, 0.1);
|
||||||
border: 1px solid #ffeaa7;
|
border: 1px solid #D4A017;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
color: #856404;
|
color: #2E2E2E;
|
||||||
}}
|
}}
|
||||||
.security-note strong {{
|
.security-note strong {{
|
||||||
color: #664d03;
|
color: #1A2536;
|
||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -140,83 +143,90 @@ EMAIL_TEMPLATES = {
|
|||||||
<title>Security Code</title>
|
<title>Security Code</title>
|
||||||
<style>
|
<style>
|
||||||
body {{
|
body {{
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #2E2E2E;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #f5f5f5;
|
background-color: #D3CDBF;
|
||||||
}}
|
}}
|
||||||
.container {{
|
.container {{
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 40px auto;
|
margin: 40px auto;
|
||||||
background-color: white;
|
background-color: #FFFFFF;
|
||||||
border-radius: 12px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(26, 37, 54, 0.15);
|
||||||
}}
|
}}
|
||||||
.header {{
|
.header {{
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #1A2536 0%, #4A7A7D 100%);
|
||||||
color: white;
|
color: #D3CDBF;
|
||||||
padding: 40px 30px;
|
padding: 40px 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}}
|
}}
|
||||||
.header h1 {{
|
.header h1 {{
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
font-size: 28px;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
color: #D3CDBF;
|
||||||
}}
|
}}
|
||||||
.content {{
|
.content {{
|
||||||
padding: 40px 30px;
|
padding: 40px 30px;
|
||||||
}}
|
}}
|
||||||
|
.content h2 {{
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #2E2E2E;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}}
|
||||||
.device-info {{
|
.device-info {{
|
||||||
background: #e3f2fd;
|
background: rgba(74, 122, 125, 0.1);
|
||||||
border: 1px solid #2196f3;
|
border: 1px solid #4A7A7D;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
color: #1565c0;
|
color: #1A2536;
|
||||||
}}
|
}}
|
||||||
.code {{
|
.code {{
|
||||||
background: #f8f9fa;
|
background: #D3CDBF;
|
||||||
border: 3px solid #667eea;
|
border: 3px solid #D4A017;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 36px;
|
font-size: 2.25rem;
|
||||||
font-weight: bold;
|
font-weight: 500;
|
||||||
letter-spacing: 12px;
|
letter-spacing: 12px;
|
||||||
color: #667eea;
|
color: #1A2536;
|
||||||
border-radius: 12px;
|
border-radius: 4px;
|
||||||
margin: 32px 0;
|
margin: 32px 0;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Roboto', 'Courier New', monospace;
|
||||||
}}
|
}}
|
||||||
.footer {{
|
.footer {{
|
||||||
background: #f8f9fa;
|
background: #D3CDBF;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
color: #6c757d;
|
color: #1A2536;
|
||||||
border-top: 1px solid #e9ecef;
|
border-top: 1px solid rgba(26, 37, 54, 0.1);
|
||||||
}}
|
}}
|
||||||
.warning {{
|
.warning {{
|
||||||
background: #f8d7da;
|
background: rgba(212, 160, 23, 0.1);
|
||||||
border: 1px solid #f5c6cb;
|
border: 1px solid #D4A017;
|
||||||
color: #721c24;
|
color: #2E2E2E;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}}
|
}}
|
||||||
.warning strong {{
|
.warning strong {{
|
||||||
color: #491217;
|
color: #1A2536;
|
||||||
}}
|
}}
|
||||||
.expiry-info {{
|
.expiry-info {{
|
||||||
background: #fff3cd;
|
background: rgba(74, 122, 125, 0.1);
|
||||||
border: 1px solid #ffeaa7;
|
border: 1px solid #4A7A7D;
|
||||||
color: #856404;
|
color: #1A2536;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
@ -277,44 +287,75 @@ EMAIL_TEMPLATES = {
|
|||||||
<title>Password Reset</title>
|
<title>Password Reset</title>
|
||||||
<style>
|
<style>
|
||||||
body {{
|
body {{
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #2E2E2E;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #f5f5f5;
|
background-color: #D3CDBF;
|
||||||
}}
|
}}
|
||||||
.container {{
|
.container {{
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 40px auto;
|
margin: 40px auto;
|
||||||
background-color: white;
|
background-color: #FFFFFF;
|
||||||
border-radius: 12px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(26, 37, 54, 0.15);
|
||||||
}}
|
}}
|
||||||
.header {{
|
.header {{
|
||||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
background: linear-gradient(135deg, #4A7A7D 0%, #1A2536 100%);
|
||||||
color: white;
|
color: #D3CDBF;
|
||||||
padding: 40px 30px;
|
padding: 40px 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}}
|
}}
|
||||||
.content {{ padding: 40px 30px; }}
|
.header h1 {{
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #D3CDBF;
|
||||||
|
}}
|
||||||
|
.content {{
|
||||||
|
padding: 40px 30px;
|
||||||
|
}}
|
||||||
|
.content h2 {{
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #2E2E2E;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}}
|
||||||
.button {{
|
.button {{
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #ff6b6b;
|
background: #D4A017;
|
||||||
color: white;
|
color: #FFFFFF;
|
||||||
padding: 16px 32px;
|
padding: 16px 32px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}}
|
||||||
|
.button:hover {{
|
||||||
|
background: rgba(212, 160, 23, 0.8);
|
||||||
}}
|
}}
|
||||||
.footer {{
|
.footer {{
|
||||||
background: #f8f9fa;
|
background: #D3CDBF;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
color: #6c757d;
|
color: #1A2536;
|
||||||
|
border-top: 1px solid rgba(26, 37, 54, 0.1);
|
||||||
|
}}
|
||||||
|
.security-note {{
|
||||||
|
background: rgba(212, 160, 23, 0.1);
|
||||||
|
border: 1px solid #D4A017;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 24px 0;
|
||||||
|
color: #2E2E2E;
|
||||||
|
}}
|
||||||
|
.security-note strong {{
|
||||||
|
color: #1A2536;
|
||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -332,11 +373,14 @@ EMAIL_TEMPLATES = {
|
|||||||
<a href="{reset_link}" class="button">Reset Password</a>
|
<a href="{reset_link}" class="button">Reset Password</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>This link will expire in 1 hour for security reasons.</p>
|
<div class="security-note">
|
||||||
<p>If you didn't request a password reset, please ignore this email and your password will remain unchanged.</p>
|
<strong>Security Information:</strong><br>
|
||||||
|
This link will expire in 1 hour for security reasons. If you didn't request a password reset, please ignore this email and your password will remain unchanged.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>This email was sent to {to_email}</p>
|
<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>
|
<p>© 2024 {app_name}. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,15 @@
|
|||||||
from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated
|
from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated
|
||||||
from pydantic import BaseModel, Field, EmailStr, HttpUrl, model_validator # type: ignore
|
from pydantic import BaseModel, Field, EmailStr, HttpUrl, model_validator, field_validator # type: ignore
|
||||||
from pydantic.types import constr, conint # type: ignore
|
from pydantic.types import constr, conint # type: ignore
|
||||||
from datetime import datetime, date, UTC
|
from datetime import datetime, date, UTC
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import uuid
|
import uuid
|
||||||
|
from auth_utils import (
|
||||||
|
AuthenticationManager,
|
||||||
|
validate_password_strength,
|
||||||
|
sanitize_login_input,
|
||||||
|
SecurityConfig
|
||||||
|
)
|
||||||
|
|
||||||
# Generic type variable
|
# Generic type variable
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
@ -190,24 +196,62 @@ class SortOrder(str, Enum):
|
|||||||
DESC = "desc"
|
DESC = "desc"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
login: str # Can be email or username
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator('login')
|
||||||
|
def sanitize_login(cls, v):
|
||||||
|
return sanitize_login_input(v)
|
||||||
|
|
||||||
|
@field_validator('password')
|
||||||
|
def validate_password_not_empty(cls, v):
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError('Password cannot be empty')
|
||||||
|
return v
|
||||||
|
|
||||||
# ============================
|
# ============================
|
||||||
# MFA Models
|
# MFA Models
|
||||||
# ============================
|
# ============================
|
||||||
|
|
||||||
|
|
||||||
class EmailVerificationRequest(BaseModel):
|
class EmailVerificationRequest(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
|
|
||||||
class MFARequest(BaseModel):
|
class MFARequest(BaseModel):
|
||||||
email: EmailStr
|
username: str
|
||||||
password: str
|
password: str
|
||||||
device_id: str
|
device_id: str = Field(..., alias="deviceId")
|
||||||
device_name: str
|
device_name: str = Field(..., alias="deviceName")
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class MFAVerifyRequest(BaseModel):
|
class MFAVerifyRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
code: str
|
code: str
|
||||||
device_id: str
|
device_id: str = Field(..., alias="deviceId")
|
||||||
remember_device: bool = False
|
remember_device: bool = Field(False, alias="rememberDevice")
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
class MFAData(BaseModel):
|
||||||
|
message: str
|
||||||
|
device_id: str = Field(..., alias="deviceId")
|
||||||
|
device_name: str = Field(..., alias="deviceName")
|
||||||
|
code_sent: str = Field(..., alias="codeSent")
|
||||||
|
email: str
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
class MFARequestResponse(BaseModel):
|
||||||
|
mfa_required: bool = Field(..., alias="mfaRequired")
|
||||||
|
mfa_data: Optional[MFAData] = Field(None, alias="mfaData")
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class ResendVerificationRequest(BaseModel):
|
class ResendVerificationRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
@ -401,6 +445,7 @@ class BaseUser(BaseModel):
|
|||||||
last_login: Optional[datetime] = Field(None, alias="lastLogin")
|
last_login: Optional[datetime] = Field(None, alias="lastLogin")
|
||||||
profile_image: Optional[str] = Field(None, alias="profileImage")
|
profile_image: Optional[str] = Field(None, alias="profileImage")
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
|
is_admin: bool = Field(default=False, alias="isAdmin")
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"populate_by_name": True, # Allow both field names and aliases
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
Loading…
x
Reference in New Issue
Block a user