backstory/frontend/src/hooks/AuthContext.tsx

888 lines
26 KiB
TypeScript

// Replace the existing AuthContext.tsx with these enhancements
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
JSX,
} from 'react';
import * as Types from '../types/types';
import { ApiClient, CreateEmployerRequest, GuestConversionRequest } from 'services/api-client';
import { formatApiRequest, toCamelCase } from 'types/conversion';
// ============================
// Enhanced Types and Interfaces
// ============================
interface AuthState {
user: Types.User | null;
guest: Types.Guest | null;
isAuthenticated: boolean;
isGuest: boolean;
isLoading: boolean;
isInitializing: boolean;
error: string | null;
mfaResponse: Types.MFARequestResponse | null;
}
interface LoginRequest {
login: string; // email or username
password: string;
}
interface EmailVerificationRequest {
token: string;
}
interface ResendVerificationRequest {
email: string;
}
interface PasswordResetRequest {
email: string;
}
// ============================
// Enhanced Token Storage Constants
// ============================
const TOKEN_STORAGE = {
ACCESS_TOKEN: 'accessToken',
REFRESH_TOKEN: 'refreshToken',
USER_DATA: 'userData',
TOKEN_EXPIRY: 'tokenExpiry',
USER_TYPE: 'userType',
IS_GUEST: 'isGuest',
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail',
} as const;
// ============================
// JWT Utilities
// ============================
type JwtPayload = {
exp?: number; // Expiry time in seconds
};
function parseJwtPayload(token: string): JwtPayload | null {
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;
}
// ============================
// Enhanced Storage Utilities
// ============================
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);
localStorage.removeItem(TOKEN_STORAGE.USER_TYPE);
localStorage.removeItem(TOKEN_STORAGE.IS_GUEST);
}
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 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);
}
}
function storeAuthData(authResponse: Types.AuthResponse, isGuest = false): 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());
localStorage.setItem(TOKEN_STORAGE.USER_TYPE, authResponse.user.userType);
localStorage.setItem(TOKEN_STORAGE.IS_GUEST, isGuest.toString());
}
function getStoredAuthData(): {
accessToken: string | null;
refreshToken: string | null;
userData: Types.User | null;
expiresAt: number | null;
userType: string | null;
isGuest: boolean;
} {
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);
const userType = localStorage.getItem(TOKEN_STORAGE.USER_TYPE);
const isGuestStr = localStorage.getItem(TOKEN_STORAGE.IS_GUEST);
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,
userType,
isGuest: isGuestStr === 'true',
};
}
type EmailVerificationResponse = {
message: string;
userType: string;
};
interface AuthenticationLogic extends AuthState {
apiClient: ApiClient;
login: (loginData: LoginRequest) => Promise<boolean>;
logout: () => Promise<void>;
verifyMFA: (mfaData: Types.MFAVerifyRequest) => Promise<boolean>;
resendMFACode: (email: string, deviceId: string, deviceName: string) => Promise<boolean>;
clearMFA: () => void;
verifyEmail: (
verificationData: EmailVerificationRequest
) => Promise<EmailVerificationResponse | null>;
resendEmailVerification: (email: string) => Promise<boolean>;
setPendingVerificationEmail: (email: string) => void;
getPendingVerificationEmail: () => string | null;
createEmployerAccount: (employerData: CreateEmployerRequest) => Promise<boolean>;
requestPasswordReset: (email: string) => Promise<boolean>;
refreshAuth: () => Promise<boolean>;
updateUserData: (updatedUser: Types.User) => void;
convertGuestToUser: (registrationData: GuestConversionRequest) => Promise<boolean>;
createGuestSession: () => Promise<boolean>;
}
function useAuthenticationLogic(): AuthenticationLogic {
const [authState, setAuthState] = useState<AuthState>({
user: null,
guest: null,
isAuthenticated: false,
isGuest: false,
isLoading: false,
isInitializing: true,
error: null,
mfaResponse: null,
});
const [apiClient] = useState(() => new ApiClient());
const initializationCompleted = useRef(false);
const guestCreationAttempted = 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]
);
// Create guest session
const createGuestSession = useCallback(async (): Promise<boolean> => {
if (guestCreationAttempted.current) {
return false;
}
guestCreationAttempted.current = true;
try {
console.log('🔄 Creating guest session...');
const guestAuth = await apiClient.createGuestSession();
if (guestAuth && guestAuth.user && guestAuth.user.userType === 'guest') {
storeAuthData(guestAuth, true);
apiClient.setAuthToken(guestAuth.accessToken);
setAuthState({
user: null,
guest: guestAuth.user as Types.Guest,
isAuthenticated: true,
isGuest: true,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null,
});
console.log('👤 Guest session created successfully:', guestAuth.user);
return true;
}
return false;
} catch (error) {
console.error('❌ Failed to create guest session:', error);
guestCreationAttempted.current = false;
// Set to unauthenticated state if guest creation fails
setAuthState(prev => ({
...prev,
user: null,
guest: null,
isAuthenticated: false,
isGuest: false,
isLoading: false,
isInitializing: false,
error: 'Failed to create guest session',
}));
return false;
}
}, [apiClient]);
// Initialize authentication state
const initializeAuth = useCallback(async () => {
if (initializationCompleted.current) {
return;
}
try {
const stored = getStoredAuthData();
// If no stored tokens, create guest session
if (!stored.accessToken || !stored.refreshToken || !stored.userData) {
console.log('🔄 No stored auth found, creating guest session...');
await createGuestSession();
return;
}
// For guests, always verify the session exists on server
if (stored.userType === 'guest' && stored.userData) {
console.log(stored.userData);
try {
// Make a quick API call to verify guest still exists
const response = await fetch(`${apiClient.getBaseUrl()}/users/${stored.userData.id}`, {
headers: { Authorization: `Bearer ${stored.accessToken}` },
});
if (!response.ok) {
console.log('🔄 Guest session invalid, creating new guest session...');
clearStoredAuth();
await createGuestSession();
return;
}
} catch (error) {
console.log('🔄 Guest verification failed, creating new guest session...');
clearStoredAuth();
await createGuestSession();
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) {
const isGuest = stored.userType === 'guest';
storeAuthData(refreshResult, isGuest);
apiClient.setAuthToken(refreshResult.accessToken);
setAuthState({
user: isGuest ? null : refreshResult.user,
guest: isGuest ? (refreshResult.user as Types.Guest) : null,
isAuthenticated: true,
isGuest,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null,
});
console.log('✅ Token refreshed successfully');
} else {
console.log('❌ Token refresh failed, creating new guest session...');
clearStoredAuth();
apiClient.clearAuthToken();
await createGuestSession();
}
} else {
// Access token is still valid
apiClient.setAuthToken(stored.accessToken);
const isGuest = stored.userType === 'guest';
setAuthState({
user: isGuest ? null : stored.userData,
guest: isGuest ? (stored.userData as Types.Guest) : null,
isAuthenticated: true,
isGuest,
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();
await createGuestSession();
} finally {
initializationCompleted.current = true;
}
}, [apiClient, refreshAccessToken, createGuestSession]);
// 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((): void => {
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,
}));
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 - convert from guest to authenticated user
const authResponse: Types.AuthResponse = result;
storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({
...prev,
user: authResponse.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('✅ Login successful, converted from guest to authenticated user');
return true;
}
} catch (error) {
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]
);
// Convert guest to permanent user
const convertGuestToUser = useCallback(
async (registrationData: GuestConversionRequest): Promise<boolean> => {
if (!authState.isGuest || !authState.guest) {
throw new Error('Not currently a guest user');
}
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await apiClient.convertGuestToUser(registrationData);
// Store new authentication
storeAuthData(result.auth, false);
apiClient.setAuthToken(result.auth.accessToken);
setAuthState(prev => ({
...prev,
user: result.auth.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
}));
console.log('✅ Guest successfully converted to permanent user');
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to convert guest account';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return false;
}
},
[apiClient, authState.isGuest, authState.guest]
);
// MFA verification
const verifyMFA = useCallback(
async (mfaData: Types.MFAVerifyRequest): 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, false);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({
...prev,
user: authResponse.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('✅ MFA verification successful, converted from guest');
return true;
}
return false;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'MFA verification failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return false;
}
},
[apiClient]
);
// Logout - returns to guest session
const logout = useCallback(async () => {
try {
// If authenticated, try to logout gracefully
if (authState.isAuthenticated && !authState.isGuest) {
const stored = getStoredAuthData();
if (stored.accessToken && stored.refreshToken) {
try {
await apiClient.logout(stored.accessToken, stored.refreshToken);
} catch (error) {
console.warn('Logout request failed, proceeding with local cleanup');
}
}
}
} catch (error) {
console.warn('Error during logout:', error);
} finally {
// Always clear stored auth and create new guest session
clearStoredAuth();
apiClient.clearAuthToken();
guestCreationAttempted.current = false;
// Create new guest session
await createGuestSession();
console.log('🔄 Logged out, created new guest session');
}
}, [apiClient, authState.isAuthenticated, authState.isGuest, createGuestSession]);
// Update user data
const updateUserData = useCallback(
(updatedUser: Types.User) => {
updateStoredUserData(updatedUser);
setAuthState(prev => ({
...prev,
user: authState.isGuest ? null : updatedUser,
guest: authState.isGuest ? (updatedUser as Types.Guest) : prev.guest,
}));
console.log('✅ User data updated');
},
[authState.isGuest]
);
// Email verification functions (unchanged)
const verifyEmail = useCallback(
async (
verificationData: EmailVerificationRequest
): Promise<EmailVerificationResponse | 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]
);
// Other existing methods remain the same...
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]
);
const setPendingVerificationEmail = useCallback((email: string) => {
localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email);
}, []);
const getPendingVerificationEmail = useCallback((): string | null => {
return localStorage.getItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL);
}, []);
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);
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) {
const isGuest = stored.userType === 'guest';
storeAuthData(refreshResult, isGuest);
apiClient.setAuthToken(refreshResult.accessToken);
setAuthState(prev => ({
...prev,
user: isGuest ? null : refreshResult.user,
guest: isGuest ? (refreshResult.user as Types.Guest) : null,
isAuthenticated: true,
isGuest,
isLoading: false,
error: null,
}));
return true;
} else {
await logout();
return false;
}
}, [refreshAccessToken, logout, 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,
}));
}, []);
return {
...authState,
apiClient,
login,
logout,
verifyMFA,
resendMFACode,
clearMFA,
verifyEmail,
resendEmailVerification,
setPendingVerificationEmail,
getPendingVerificationEmail,
createEmployerAccount,
requestPasswordReset,
refreshAuth,
updateUserData,
convertGuestToUser,
createGuestSession,
};
}
// ============================
// Enhanced Context Provider
// ============================
const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | null>(null);
function AuthProvider({ children }: { children: React.ReactNode }): JSX.Element {
const auth = useAuthenticationLogic();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
function useAuth(): ReturnType<typeof useAuthenticationLogic> {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// ============================
// Enhanced Protected Route Component
// ============================
interface ProtectedRouteProps {
children: React.ReactNode;
fallback?: React.ReactNode;
requiredUserType?: Types.UserType;
allowGuests?: boolean;
}
function ProtectedRoute({
children,
fallback = <div>Please log in to access this page.</div>,
requiredUserType,
allowGuests = false,
}: ProtectedRouteProps): JSX.Element {
const { isAuthenticated, isInitializing, user, isGuest } = useAuth();
// Show loading while checking stored tokens
if (isInitializing) {
return <div>Loading...</div>;
}
// Not authenticated at all (shouldn't happen with guest sessions)
if (!isAuthenticated) {
return <>{fallback}</>;
}
// Guest access control
if (isGuest && !allowGuests) {
return <div>Please create an account or log in to access this page.</div>;
}
// Check user type if required (only for non-guests)
if (requiredUserType && !isGuest && user?.userType !== requiredUserType) {
return <div>Access denied. Required user type: {requiredUserType}</div>;
}
return <>{children}</>;
}
export type {
AuthState,
LoginRequest,
EmailVerificationRequest,
ResendVerificationRequest,
PasswordResetRequest,
GuestConversionRequest,
};
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
export { useAuthenticationLogic, AuthProvider, useAuth, ProtectedRoute };