backstory/frontend/src/hooks/AuthContext.tsx
2025-06-01 13:42:32 -07:00

752 lines
21 KiB
TypeScript

import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import * as Types from '../types/types';
import { ApiClient, CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
import { formatApiRequest, toCamelCase } from '../types/conversion';
// ============================
// Types and Interfaces
// ============================
interface AuthState {
user: Types.User | null;
guest: Types.Guest | null;
isAuthenticated: boolean;
isLoading: boolean;
isInitializing: boolean;
error: string | null;
mfaResponse: Types.MFARequestResponse | null;
}
interface LoginRequest {
login: string; // email or username
password: string;
}
interface MFAVerificationRequest {
email: string;
code: string;
deviceId: string;
rememberDevice?: boolean;
}
interface EmailVerificationRequest {
token: string;
}
interface ResendVerificationRequest {
email: string;
}
interface PasswordResetRequest {
email: string;
}
// ============================
// Token Storage Constants
// ============================
const TOKEN_STORAGE = {
ACCESS_TOKEN: 'accessToken',
REFRESH_TOKEN: 'refreshToken',
USER_DATA: 'userData',
TOKEN_EXPIRY: 'tokenExpiry',
GUEST_DATA: 'guestData',
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail'
} as const;
// ============================
// JWT Utilities
// ============================
function parseJwtPayload(token: string): any {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (error) {
console.error('Failed to parse JWT token:', error);
return null;
}
}
function isTokenExpired(token: string): boolean {
const payload = parseJwtPayload(token);
if (!payload || !payload.exp) {
return true;
}
// Check if token expires within the next 5 minutes (buffer time)
const expiryTime = payload.exp * 1000; // Convert to milliseconds
const bufferTime = 5 * 60 * 1000; // 5 minutes
const currentTime = Date.now();
return currentTime >= (expiryTime - bufferTime);
}
// ============================
// Storage Utilities with Date Conversion
// ============================
function clearStoredAuth(): void {
localStorage.removeItem(TOKEN_STORAGE.ACCESS_TOKEN);
localStorage.removeItem(TOKEN_STORAGE.REFRESH_TOKEN);
localStorage.removeItem(TOKEN_STORAGE.USER_DATA);
localStorage.removeItem(TOKEN_STORAGE.TOKEN_EXPIRY);
}
function prepareUserDataForStorage(user: Types.User): string {
try {
// Convert dates to ISO strings for storage
const userForStorage = formatApiRequest(user);
return JSON.stringify(userForStorage);
} catch (error) {
console.error('Failed to prepare user data for storage:', error);
return JSON.stringify(user); // Fallback to direct serialization
}
}
function parseStoredUserData(userDataStr: string): Types.User | null {
try {
const rawUserData = JSON.parse(userDataStr);
// Convert the data using toCamelCase which handles date conversion
const convertedData = toCamelCase<Types.User>(rawUserData);
return convertedData;
} catch (error) {
console.error('Failed to parse stored user data:', error);
return null;
}
}
function storeAuthData(authResponse: Types.AuthResponse): void {
localStorage.setItem(TOKEN_STORAGE.ACCESS_TOKEN, authResponse.accessToken);
localStorage.setItem(TOKEN_STORAGE.REFRESH_TOKEN, authResponse.refreshToken);
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(authResponse.user));
localStorage.setItem(TOKEN_STORAGE.TOKEN_EXPIRY, authResponse.expiresAt.toString());
}
function getStoredAuthData(): {
accessToken: string | null;
refreshToken: string | null;
userData: Types.User | null;
expiresAt: number | null;
} {
const accessToken = localStorage.getItem(TOKEN_STORAGE.ACCESS_TOKEN);
const refreshToken = localStorage.getItem(TOKEN_STORAGE.REFRESH_TOKEN);
const userDataStr = localStorage.getItem(TOKEN_STORAGE.USER_DATA);
const expiryStr = localStorage.getItem(TOKEN_STORAGE.TOKEN_EXPIRY);
let userData: Types.User | null = null;
let expiresAt: number | null = null;
try {
if (userDataStr) {
userData = parseStoredUserData(userDataStr);
}
if (expiryStr) {
expiresAt = parseInt(expiryStr, 10);
}
} catch (error) {
console.error('Failed to parse stored auth data:', error);
clearStoredAuth();
}
return { accessToken, refreshToken, userData, expiresAt };
}
function updateStoredUserData(user: Types.User): void {
try {
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(user));
} catch (error) {
console.error('Failed to update stored user data:', error);
}
}
// ============================
// Guest Session Utilities
// ============================
function createGuestSession(): Types.Guest {
const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const guest: Types.Guest = {
sessionId,
createdAt: new Date(),
lastActivity: new Date(),
ipAddress: 'unknown',
userAgent: navigator.userAgent
};
try {
localStorage.setItem(TOKEN_STORAGE.GUEST_DATA, JSON.stringify(formatApiRequest(guest)));
} catch (error) {
console.error('Failed to store guest session:', error);
}
return guest;
}
function getStoredGuestData(): Types.Guest | null {
try {
const guestDataStr = localStorage.getItem(TOKEN_STORAGE.GUEST_DATA);
if (guestDataStr) {
return toCamelCase<Types.Guest>(JSON.parse(guestDataStr));
}
} catch (error) {
console.error('Failed to parse stored guest data:', error);
localStorage.removeItem(TOKEN_STORAGE.GUEST_DATA);
}
return null;
}
// ============================
// Main Authentication Hook
// ============================
function useAuthenticationLogic() {
const [authState, setAuthState] = useState<AuthState>({
user: null,
guest: null,
isAuthenticated: false,
isLoading: false,
isInitializing: true,
error: null,
mfaResponse: null,
});
const [apiClient] = useState(() => new ApiClient());
const initializationCompleted = useRef(false);
// Token refresh function
const refreshAccessToken = useCallback(async (refreshToken: string): Promise<Types.AuthResponse | null> => {
try {
const response = await apiClient.refreshToken(refreshToken);
return response;
} catch (error) {
console.error('Token refresh failed:', error);
return null;
}
}, [apiClient]);
// Initialize authentication state
const initializeAuth = useCallback(async () => {
if (initializationCompleted.current) {
return;
}
try {
// Initialize guest session first
let guest = getStoredGuestData();
if (!guest) {
guest = createGuestSession();
}
const stored = getStoredAuthData();
// If no stored tokens, user is not authenticated but has guest session
if (!stored.accessToken || !stored.refreshToken || !stored.userData) {
setAuthState({
user: null,
guest,
isAuthenticated: false,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null,
});
return;
}
// Check if access token is expired
if (isTokenExpired(stored.accessToken)) {
console.log('Access token expired, attempting refresh...');
const refreshResult = await refreshAccessToken(stored.refreshToken);
if (refreshResult) {
storeAuthData(refreshResult);
apiClient.setAuthToken(refreshResult.accessToken);
setAuthState({
user: refreshResult.user,
guest,
isAuthenticated: true,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null
});
console.log('Token refreshed successfully');
} else {
console.log('Token refresh failed, clearing stored auth');
clearStoredAuth();
apiClient.clearAuthToken();
setAuthState({
user: null,
guest,
isAuthenticated: false,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null
});
}
} else {
// Access token is still valid
apiClient.setAuthToken(stored.accessToken);
setAuthState({
user: stored.userData,
guest,
isAuthenticated: true,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null
});
console.log('Restored authentication from stored tokens');
}
} catch (error) {
console.error('Error initializing auth:', error);
clearStoredAuth();
apiClient.clearAuthToken();
const guest = createGuestSession();
setAuthState({
user: null,
guest,
isAuthenticated: false,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null
});
} finally {
initializationCompleted.current = true;
}
}, [apiClient, refreshAccessToken]);
// Run initialization on mount
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
// Set up automatic token refresh
useEffect(() => {
if (!authState.isAuthenticated) {
return;
}
const stored = getStoredAuthData();
if (!stored.expiresAt) {
return;
}
const expiryTime = stored.expiresAt * 1000;
const currentTime = Date.now();
const timeUntilExpiry = expiryTime - currentTime - (5 * 60 * 1000); // 5 minute buffer
if (timeUntilExpiry <= 0) {
initializeAuth();
return;
}
const refreshTimer = setTimeout(() => {
console.log('Auto-refreshing token before expiry...');
initializeAuth();
}, timeUntilExpiry);
return () => clearTimeout(refreshTimer);
}, [authState.isAuthenticated, initializeAuth]);
// Enhanced login with MFA support
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null, mfaResponse: null, mfaData: null }));
try {
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);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({
...prev,
user: authResponse.user,
isAuthenticated: true,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('Login successful');
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) {
const errorMessage = error instanceof Error ? error.message : 'MFA verification failed';
console.log(errorMessage);
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [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(() => {
clearStoredAuth();
apiClient.clearAuthToken();
// Create new guest session after logout
const guest = createGuestSession();
setAuthState(prev => ({
...prev,
user: null,
guest,
isAuthenticated: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('User logged out');
}, [apiClient]);
const updateUserData = useCallback((updatedUser: Types.User) => {
updateStoredUserData(updatedUser);
setAuthState(prev => ({
...prev,
user: updatedUser
}));
console.log('User data updated');
}, []);
const createCandidateAccount = useCallback(async (candidateData: CreateCandidateRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const candidate = await apiClient.createCandidate(candidateData);
console.log('Candidate created:', candidate);
// Store email for potential verification resend
setPendingVerificationEmail(candidateData.email);
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient, setPendingVerificationEmail]);
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const employer = await apiClient.createEmployer(employerData);
console.log('Employer created:', employer);
// Store email for potential verification resend
setPendingVerificationEmail(employerData.email);
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient, setPendingVerificationEmail]);
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await apiClient.requestPasswordReset({ email });
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient]);
const refreshAuth = useCallback(async (): Promise<boolean> => {
const stored = getStoredAuthData();
if (!stored.refreshToken) {
return false;
}
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
const refreshResult = await refreshAccessToken(stored.refreshToken);
if (refreshResult) {
storeAuthData(refreshResult);
apiClient.setAuthToken(refreshResult.accessToken);
setAuthState(prev => ({
...prev,
user: refreshResult.user,
isAuthenticated: true,
isLoading: false,
error: null
}));
return true;
} else {
logout();
return false;
}
}, [refreshAccessToken, logout]);
return {
...authState,
apiClient,
login,
logout,
verifyMFA,
resendMFACode,
clearMFA,
verifyEmail,
resendEmailVerification,
setPendingVerificationEmail,
getPendingVerificationEmail,
createCandidateAccount,
createEmployerAccount,
requestPasswordReset,
refreshAuth,
updateUserData
};
}
// ============================
// Context Provider
// ============================
const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | null>(null);
function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useAuthenticationLogic();
return (
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// ============================
// Protected Route Component
// ============================
interface ProtectedRouteProps {
children: React.ReactNode;
fallback?: React.ReactNode;
requiredUserType?: Types.UserType;
}
function ProtectedRoute({
children,
fallback = <div>Please log in to access this page.</div>,
requiredUserType
}: ProtectedRouteProps) {
const { isAuthenticated, isInitializing, user } = useAuth();
// Show loading while checking stored tokens
if (isInitializing) {
return <div>Loading...</div>;
}
// Not authenticated
if (!isAuthenticated) {
return <>{fallback}</>;
}
// Check user type if required
if (requiredUserType && user?.userType !== requiredUserType) {
return <div>Access denied. Required user type: {requiredUserType}</div>;
}
return <>{children}</>;
}
export type {
AuthState, LoginRequest, MFAVerificationRequest, EmailVerificationRequest, ResendVerificationRequest, PasswordResetRequest
}
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
export {
useAuthenticationLogic, AuthProvider, useAuth, ProtectedRoute
}