diff --git a/.gitignore b/.gitignore index 0d213be..84bbe28 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ chromadb/** chromadb-prod/** dev-keys/** **/__pycache__/** +.vscode diff --git a/frontend/src/BackstoryApp.tsx b/frontend/src/BackstoryApp.tsx index 1fa48e3..ab84fdd 100644 --- a/frontend/src/BackstoryApp.tsx +++ b/frontend/src/BackstoryApp.tsx @@ -5,10 +5,11 @@ import { backstoryTheme } from './BackstoryTheme'; import { SeverityType } from 'components/Snack'; import { ConversationHandle } from 'components/Conversation'; -import { UserProvider } from 'hooks/useUser'; import { CandidateRoute } from 'routes/CandidateRoute'; import { BackstoryLayout } from 'components/layout/BackstoryLayout'; import { ChatQuery } from 'types/types'; +import { AuthProvider } from 'hooks/AuthContext'; +import { AppStateProvider } from 'hooks/GlobalContext'; import './BackstoryApp.css'; import '@fontsource/roboto/300.css'; @@ -39,18 +40,20 @@ const BackstoryApp = () => { // Render appropriate routes based on user type return ( - - - } /> - {/* Static/shared routes */} - - } - /> - - + + + + } /> + {/* Static/shared routes */} + + } + /> + + + ); }; diff --git a/frontend/src/components/CandidateInfo.tsx b/frontend/src/components/CandidateInfo.tsx index 9a98c6e..dd81379 100644 --- a/frontend/src/components/CandidateInfo.tsx +++ b/frontend/src/components/CandidateInfo.tsx @@ -7,9 +7,8 @@ import { useTheme, } from '@mui/material'; import { useMediaQuery } from '@mui/material'; -import { useUser } from "../hooks/useUser"; -import { Candidate } from '../types/types'; -import { CopyBubble } from "./CopyBubble"; +import { Candidate } from 'types/types'; +import { CopyBubble } from "components/CopyBubble"; import { rest } from 'lodash'; interface CandidateInfoProps { diff --git a/frontend/src/components/Conversation.tsx b/frontend/src/components/Conversation.tsx index 19f7dc1..641bbc1 100644 --- a/frontend/src/components/Conversation.tsx +++ b/frontend/src/components/Conversation.tsx @@ -13,12 +13,13 @@ import { DeleteConfirmation } from 'components/DeleteConfirmation'; import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import { BackstoryElementProps } from './BackstoryTab'; import { connectionBase } from 'utils/Global'; -import { useUser } from "hooks/useUser"; +import { useAuth } from "hooks/AuthContext"; import { StreamingResponse } from 'services/api-client'; import { ChatMessage, ChatMessageBase, ChatContext, ChatSession, ChatQuery } from 'types/types'; import { PaginatedResponse } from 'types/conversion'; import './Conversation.css'; +import { useSelectedCandidate } from 'hooks/GlobalContext'; const defaultMessage: ChatMessage = { type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: "" @@ -69,7 +70,7 @@ const Conversation = forwardRef((props: C sx, type, } = props; - const { candidate, apiClient } = useUser() + const { apiClient } = useAuth() const [processing, setProcessing] = useState(false); const [countdown, setCountdown] = useState(0); const [conversation, setConversation] = useState([]); @@ -77,7 +78,6 @@ const Conversation = forwardRef((props: C const [filteredConversation, setFilteredConversation] = useState([]); const [processingMessage, setProcessingMessage] = useState(undefined); const [streamingMessage, setStreamingMessage] = useState(undefined); - const timerRef = useRef(null); const [noInteractions, setNoInteractions] = useState(true); const viewableElementRef = useRef(null); const backstoryTextRef = useRef(null); diff --git a/frontend/src/components/GenerateImage.tsx b/frontend/src/components/GenerateImage.tsx index 01ffdd5..1236d13 100644 --- a/frontend/src/components/GenerateImage.tsx +++ b/frontend/src/components/GenerateImage.tsx @@ -3,9 +3,8 @@ import Box from '@mui/material/Box'; import PropagateLoader from 'react-spinners/PropagateLoader'; import { Quote } from 'components/Quote'; import { BackstoryElementProps } from 'components/BackstoryTab'; -import { useUser } from 'hooks/useUser'; import { Candidate, ChatSession } from 'types/types'; -import { useSecureAuth } from 'hooks/useSecureAuth'; +import { useAuth } from 'hooks/AuthContext'; interface GenerateImageProps extends BackstoryElementProps { prompt: string; @@ -13,7 +12,7 @@ interface GenerateImageProps extends BackstoryElementProps { }; const GenerateImage = (props: GenerateImageProps) => { - const { user } = useSecureAuth(); + const { user } = useAuth(); const { setSnack, chatSession, prompt } = props; const [processing, setProcessing] = useState(false); const [status, setStatus] = useState(''); diff --git a/frontend/src/components/layout/BackstoryLayout.tsx b/frontend/src/components/layout/BackstoryLayout.tsx index 814ba45..b6227e4 100644 --- a/frontend/src/components/layout/BackstoryLayout.tsx +++ b/frontend/src/components/layout/BackstoryLayout.tsx @@ -16,11 +16,11 @@ import { Header } from 'components/layout/Header'; import { Scrollable } from 'components/Scrollable'; import { Footer } from 'components/layout/Footer'; import { Snack, SetSnackType } from 'components/Snack'; -import { useUser } from 'hooks/useUser'; import { User } from 'types/types'; import { getBackstoryDynamicRoutes } from 'components/layout/BackstoryRoutes'; import { LoadingComponent } from "components/LoadingComponent"; -import { useSecureAuth } from 'hooks/useSecureAuth'; +import { AuthProvider, useAuth, ProtectedRoute } from 'hooks/AuthContext'; +import { useSelectedCandidate } from 'hooks/GlobalContext'; type NavigationLinkType = { name: string; @@ -78,8 +78,6 @@ const getNavigationLinks = (user: User | null): NavigationLinkType[] => { } switch (user.userType) { - case 'viewer': - return DefaultNavItems.concat(ViewerNavItems); case 'candidate': return DefaultNavItems.concat(CandidateNavItems); case 'employer': @@ -137,8 +135,8 @@ const BackstoryLayout: React.FC = (props: BackstoryLayoutP const { setSnack, page, chatRef, snackRef, submitQuery } = props; const navigate = useNavigate(); const location = useLocation(); - const { guest, candidate } = useUser(); - const { user } = useSecureAuth(); + const { guest, user } = useAuth(); + const { selectedCandidate } = useSelectedCandidate(); const [navigationLinks, setNavigationLinks] = useState([]); useEffect(() => { @@ -157,7 +155,7 @@ const BackstoryLayout: React.FC = (props: BackstoryLayoutP return ( -
+
({ }, })); +const MobileMenuTabs = styled(Tabs)(({ theme }) => ({ + '& .MuiTabs-flexContainer': { + flexDirection: 'column', + }, + '& .MuiTab-root': { + minHeight: 48, + textTransform: 'uppercase', + justifyContent: 'flex-start', + alignItems: 'center', + padding: theme.spacing(1, 2), + gap: theme.spacing(1), + color: theme.palette.text.primary, + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + '&.Mui-selected': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + '& .MuiTypography-root': { + color: theme.palette.primary.contrastText, + }, + '& .MuiSvgIcon-root': { + color: theme.palette.primary.contrastText, + }, + }, + }, + '& .MuiTabs-indicator': { + display: 'none', + }, +})); + +const UserMenuContainer = styled(Paper)(({ theme }) => ({ + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[8], + overflow: 'hidden', + minWidth: 200, +})); + +const UserMenuTabs = styled(Tabs)(({ theme }) => ({ + '& .MuiTabs-flexContainer': { + flexDirection: 'column', + }, + '& .MuiTab-root': { + minHeight: 48, + textTransform: 'uppercase', + justifyContent: 'flex-start', + alignItems: 'center', + padding: theme.spacing(1, 2), + gap: theme.spacing(1), + color: theme.palette.text.primary, + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + '&.Mui-selected': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }, + '& .MuiTabs-indicator': { + display: 'none', + }, +})); + +const MenuItemBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + width: '100%', + '& .MuiSvgIcon-root': { + color: theme.palette.primary.main, + }, +})); + +const MenuDivider = styled(Divider)(({ theme }) => ({ + margin: theme.spacing(0.5, 0), + backgroundColor: theme.palette.divider, +})); + interface HeaderProps { transparent?: boolean; className?: string; navigate: NavigateFunction; navigationLinks: NavigationLinkType[]; - showLogin?: boolean; currentPath: string; sessionId?: string | null; setSnack: SetSnackType, } const Header: React.FC = (props: HeaderProps) => { - const { user, logout } = useSecureAuth(); + const { user, logout } = useAuth(); const candidate: Candidate | null = (user && user.userType === "candidate") ? user as Candidate : null; const employer: Employer | null = (user && user.userType === "employer") ? user as Employer : null; - const viewer: Viewer | null = (user && user.userType === "viewer") ? user as Viewer : null; const { transparent = false, className, navigate, navigationLinks, - showLogin, sessionId, setSnack, } = props; @@ -146,8 +219,10 @@ const Header: React.FC = (props: HeaderProps) => { {name: "Home", path: "/", label: }, ...navigationLinks ]; + // State for page navigation const [ currentTab, setCurrentTab ] = useState("/"); + const [userMenuTab, setUserMenuTab] = useState(""); // State for mobile drawer const [mobileOpen, setMobileOpen] = useState(false); @@ -156,6 +231,43 @@ const Header: React.FC = (props: HeaderProps) => { const [userMenuAnchor, setUserMenuAnchor] = useState(null); const userMenuOpen = Boolean(userMenuAnchor); + // User menu items + const userMenuItems = [ + { + id: 'profile', + label: 'Profile', + icon: , + action: () => navigate(`/${user?.userType}/profile`) + }, + { + id: 'dashboard', + label: 'Dashboard', + icon: , + action: () => navigate(`/${user?.userType}/dashboard`) + }, + { + id: 'settings', + label: 'Settings', + icon: , + action: () => navigate(`/${user?.userType}/settings`) + }, + { + id: 'divider', + label: '', + icon: null, + action: () => { } + }, + { + id: 'logout', + label: 'Logout', + icon: , + action: () => { + logout(); + navigate('/'); + } + } + ]; + useEffect(() => { const parts = location.pathname.split('/'); let tab = '/'; @@ -173,12 +285,14 @@ const Header: React.FC = (props: HeaderProps) => { const handleUserMenuClose = () => { setUserMenuAnchor(null); + setUserMenuTab(""); }; - const handleLogout = () => { - handleUserMenuClose(); - logout(); - navigate('/'); + const handleUserMenuAction = (item: typeof userMenuItems[0]) => { + if (item.id !== 'divider') { + item.action(); + handleUserMenuClose(); + } }; const handleDrawerToggle = () => { @@ -216,31 +330,48 @@ const Header: React.FC = (props: HeaderProps) => { const renderDrawerContent = () => { return ( <> - - {navLinks.map((link) => ( + value={currentTab} + onChange={(e, newValue) => setCurrentTab(newValue)} + > + {navLinks.map((link) => ( - {link.icon && {link.icon}} - {link.name} - + + {link.path === '/' ? ( + + ) : ( + link.icon && link.icon + )} + + {link.path === '/' ? 'Backstory' : (link.label && typeof link.label === 'object' ? link.name : (link.label || link.name))} + + } - onClick={(e) => { handleDrawerToggle() ; setCurrentTab(link.path); navigate(link.path);} } + onClick={(e) => { + handleDrawerToggle(); + setCurrentTab(link.path); + navigate(link.path); + }} /> ))} - - - {!user && (showLogin === undefined || showLogin !== false) && ( + + + {!user && ( @@ -250,12 +381,39 @@ const Header: React.FC = (props: HeaderProps) => { ); }; + // Render user menu content + const renderUserMenu = () => { + return ( + + setUserMenuTab(newValue)} + > + {userMenuItems.map((item, index) => ( + item.id === 'divider' ? ( + + ) : ( + + {item.icon} + {item.label} + + } + onClick={() => handleUserMenuAction(item)} + /> + ) + ))} + + + ); + }; + // Render user account section const renderUserSection = () => { - if (showLogin !== undefined && showLogin === false) { - return <>; - } - if (!user) { return ( <> @@ -295,31 +453,11 @@ const Header: React.FC = (props: HeaderProps) => { - = (props: HeaderProps) => { vertical: 'top', horizontal: 'right', }} - sx={{ - "& .MuiList-root": { gap: 0 }, - "& .MuiMenuItem-root": { gap: 1, display: "flex", flexDirection: "row", width: "100%" }, - "& .MuiSvgIcon-root": { color: "#D4A017" } - }} + TransitionComponent={Fade} > - { handleUserMenuClose(); navigate(`/${user.userType}/profile`) }}> - - Profile - - { handleUserMenuClose(); navigate(`/${user.userType}/dashboard`) }}> - - Dashboard - - { handleUserMenuClose(); navigate(`/${user.userType}settings`) }}> - - Settings - - - - - Logout - - + {renderUserMenu()} + ); }; diff --git a/frontend/src/hooks/AuthContext.tsx b/frontend/src/hooks/AuthContext.tsx new file mode 100644 index 0000000..2128e12 --- /dev/null +++ b/frontend/src/hooks/AuthContext.tsx @@ -0,0 +1,575 @@ +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 +// ============================ + +export interface AuthState { + user: Types.User | null; + guest: Types.Guest | null; + isAuthenticated: boolean; + isLoading: boolean; + isInitializing: boolean; + error: string | null; +} + +export interface LoginRequest { + login: string; // email or username + password: string; +} + +export interface PasswordResetRequest { + email: string; +} + +// Re-export API client types for convenience +export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client'; + +// ============================ +// Token Storage Constants +// ============================ + +const TOKEN_STORAGE = { + ACCESS_TOKEN: 'accessToken', + REFRESH_TOKEN: 'refreshToken', + USER_DATA: 'userData', + TOKEN_EXPIRY: 'tokenExpiry', + GUEST_DATA: 'guestData' +} 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(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(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 +// ============================ + +export function useAuthenticationLogic() { + const [authState, setAuthState] = useState({ + user: null, + guest: null, + isAuthenticated: false, + isLoading: false, + isInitializing: true, + error: null + }); + + const [apiClient] = useState(() => new ApiClient()); + const initializationCompleted = useRef(false); + + // Token refresh function + const refreshAccessToken = useCallback(async (refreshToken: string): Promise => { + 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 + }); + 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 + }); + + 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 + }); + } + } else { + // Access token is still valid + apiClient.setAuthToken(stored.accessToken); + + setAuthState({ + user: stored.userData, + guest, + isAuthenticated: true, + isLoading: false, + isInitializing: false, + error: 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 + }); + } 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]); + + const login = useCallback(async (loginData: LoginRequest): Promise => { + setAuthState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const authResponse = await apiClient.login(loginData); + + storeAuthData(authResponse); + apiClient.setAuthToken(authResponse.accessToken); + + setAuthState(prev => ({ + ...prev, + user: authResponse.user, + isAuthenticated: true, + isLoading: false, + error: null + })); + + console.log('Login successful'); + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Login failed'; + setAuthState(prev => ({ + ...prev, + isLoading: false, + error: errorMessage + })); + return false; + } + }, [apiClient]); + + 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 + })); + + 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 => { + setAuthState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const candidate = await apiClient.createCandidate(candidateData); + console.log('Candidate created:', candidate); + + // Auto-login after successful registration + const loginSuccess = await login({ + login: candidateData.email, + password: candidateData.password + }); + + return loginSuccess; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; + setAuthState(prev => ({ + ...prev, + isLoading: false, + error: errorMessage + })); + return false; + } + }, [apiClient, login]); + + const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise => { + setAuthState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const employer = await apiClient.createEmployer(employerData); + console.log('Employer created:', employer); + + const loginSuccess = await login({ + login: employerData.email, + password: employerData.password + }); + + return loginSuccess; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; + setAuthState(prev => ({ + ...prev, + isLoading: false, + error: errorMessage + })); + return false; + } + }, [apiClient, login]); + + const requestPasswordReset = useCallback(async (email: string): Promise => { + 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 => { + 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, + createCandidateAccount, + createEmployerAccount, + requestPasswordReset, + refreshAuth, + updateUserData + }; +} + +// ============================ +// Context Provider +// ============================ + +const AuthContext = createContext | null>(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const auth = useAuthenticationLogic(); + + return ( + + {children} + + ); +} + +export 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; +} + +export function ProtectedRoute({ + children, + fallback =
Please log in to access this page.
, + requiredUserType +}: ProtectedRouteProps) { + const { isAuthenticated, isInitializing, user } = useAuth(); + + // Show loading while checking stored tokens + if (isInitializing) { + return
Loading...
; + } + + // Not authenticated + if (!isAuthenticated) { + return <>{fallback}; + } + + // Check user type if required + if (requiredUserType && user?.userType !== requiredUserType) { + return
Access denied. Required user type: {requiredUserType}
; + } + + return <>{children}; +} \ No newline at end of file diff --git a/frontend/src/hooks/GlobalContext.tsx b/frontend/src/hooks/GlobalContext.tsx new file mode 100644 index 0000000..5e34e82 --- /dev/null +++ b/frontend/src/hooks/GlobalContext.tsx @@ -0,0 +1,287 @@ +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import * as Types from 'types/types'; +import { formatApiRequest, toCamelCase } from 'types/conversion'; + +// ============================ +// App State Interface +// ============================ + +export interface AppState { + selectedCandidate: Types.Candidate | null; + selectedJob: Types.Job | null; + selectedEmployer: Types.Employer | null; + // Add more global state as needed: + // currentView: string; + // filters: Record; + // searchQuery: string; +} + +export interface AppStateActions { + setSelectedCandidate: (candidate: Types.Candidate | null) => void; + setSelectedJob: (job: Types.Job | null) => void; + setSelectedEmployer: (employer: Types.Employer | null) => void; + clearSelections: () => void; + // Future actions can be added here +} + +export type AppStateContextType = AppState & AppStateActions; + +// ============================ +// Storage Constants +// ============================ + +const APP_STORAGE = { + SELECTED_CANDIDATE: 'selectedCandidate', + SELECTED_JOB: 'selectedJob', + SELECTED_EMPLOYER: 'selectedEmployer' +} as const; + +// ============================ +// Storage Utilities with Date Conversion +// ============================ + +function storeCandidate(candidate: Types.Candidate | null): void { + try { + if (candidate) { + const candidateForStorage = formatApiRequest(candidate); + localStorage.setItem(APP_STORAGE.SELECTED_CANDIDATE, JSON.stringify(candidateForStorage)); + } else { + localStorage.removeItem(APP_STORAGE.SELECTED_CANDIDATE); + } + } catch (error) { + console.error('Failed to store selected candidate:', error); + } +} + +function getStoredCandidate(): Types.Candidate | null { + try { + const candidateStr = localStorage.getItem(APP_STORAGE.SELECTED_CANDIDATE); + if (candidateStr) { + const rawData = JSON.parse(candidateStr); + return toCamelCase(rawData); + } + } catch (error) { + console.error('Failed to parse stored candidate:', error); + localStorage.removeItem(APP_STORAGE.SELECTED_CANDIDATE); + } + return null; +} + +function storeJob(job: Types.Job | null): void { + try { + if (job) { + const jobForStorage = formatApiRequest(job); + localStorage.setItem(APP_STORAGE.SELECTED_JOB, JSON.stringify(jobForStorage)); + } else { + localStorage.removeItem(APP_STORAGE.SELECTED_JOB); + } + } catch (error) { + console.error('Failed to store selected job:', error); + } +} + +function getStoredJob(): Types.Job | null { + try { + const jobStr = localStorage.getItem(APP_STORAGE.SELECTED_JOB); + if (jobStr) { + const rawData = JSON.parse(jobStr); + return toCamelCase(rawData); + } + } catch (error) { + console.error('Failed to parse stored job:', error); + localStorage.removeItem(APP_STORAGE.SELECTED_JOB); + } + return null; +} + +function storeEmployer(employer: Types.Employer | null): void { + try { + if (employer) { + const employerForStorage = formatApiRequest(employer); + localStorage.setItem(APP_STORAGE.SELECTED_EMPLOYER, JSON.stringify(employerForStorage)); + } else { + localStorage.removeItem(APP_STORAGE.SELECTED_EMPLOYER); + } + } catch (error) { + console.error('Failed to store selected employer:', error); + } +} + +function getStoredEmployer(): Types.Employer | null { + try { + const employerStr = localStorage.getItem(APP_STORAGE.SELECTED_EMPLOYER); + if (employerStr) { + const rawData = JSON.parse(employerStr); + return toCamelCase(rawData); + } + } catch (error) { + console.error('Failed to parse stored employer:', error); + localStorage.removeItem(APP_STORAGE.SELECTED_EMPLOYER); + } + return null; +} + +function clearAllStoredSelections(): void { + localStorage.removeItem(APP_STORAGE.SELECTED_CANDIDATE); + localStorage.removeItem(APP_STORAGE.SELECTED_JOB); + localStorage.removeItem(APP_STORAGE.SELECTED_EMPLOYER); +} + +// ============================ +// App State Hook +// ============================ + +export function useAppStateLogic(): AppStateContextType { + const [selectedCandidate, setSelectedCandidateState] = useState(null); + const [selectedJob, setSelectedJobState] = useState(null); + const [selectedEmployer, setSelectedEmployerState] = useState(null); + + // Initialize state from localStorage on mount + useEffect(() => { + const storedCandidate = getStoredCandidate(); + const storedJob = getStoredJob(); + const storedEmployer = getStoredEmployer(); + + if (storedCandidate) { + setSelectedCandidateState(storedCandidate); + console.log('Restored selected candidate from storage:', storedCandidate); + } + + if (storedJob) { + setSelectedJobState(storedJob); + console.log('Restored selected job from storage:', storedJob); + } + + if (storedEmployer) { + setSelectedEmployerState(storedEmployer); + console.log('Restored selected employer from storage:', storedEmployer); + } + }, []); + + const setSelectedCandidate = useCallback((candidate: Types.Candidate | null) => { + setSelectedCandidateState(candidate); + storeCandidate(candidate); + + if (candidate) { + console.log('Selected candidate:', candidate); + } else { + console.log('Cleared selected candidate'); + } + }, []); + + const setSelectedJob = useCallback((job: Types.Job | null) => { + setSelectedJobState(job); + storeJob(job); + + if (job) { + console.log('Selected job:', job); + } else { + console.log('Cleared selected job'); + } + }, []); + + const setSelectedEmployer = useCallback((employer: Types.Employer | null) => { + setSelectedEmployerState(employer); + storeEmployer(employer); + + if (employer) { + console.log('Selected employer:', employer); + } else { + console.log('Cleared selected employer'); + } + }, []); + + const clearSelections = useCallback(() => { + setSelectedCandidateState(null); + setSelectedJobState(null); + setSelectedEmployerState(null); + clearAllStoredSelections(); + console.log('Cleared all selections'); + }, []); + + return { + selectedCandidate, + selectedJob, + selectedEmployer, + setSelectedCandidate, + setSelectedJob, + setSelectedEmployer, + clearSelections + }; +} + +// ============================ +// Context Provider +// ============================ + +const AppStateContext = createContext(null); + +export function AppStateProvider({ children }: { children: React.ReactNode }) { + const appState = useAppStateLogic(); + + return ( + + {children} + + ); +} + +export function useAppState() { + const context = useContext(AppStateContext); + if (!context) { + throw new Error('useAppState must be used within an AppStateProvider'); + } + return context; +} + +// ============================ +// Convenience Hooks for Specific State +// ============================ + +/** + * Hook specifically for candidate selection + * Useful when a component only cares about candidate state + */ +export function useSelectedCandidate() { + const { selectedCandidate, setSelectedCandidate } = useAppState(); + return { selectedCandidate, setSelectedCandidate }; +} + +/** + * Hook specifically for job selection + */ +export function useSelectedJob() { + const { selectedJob, setSelectedJob } = useAppState(); + return { selectedJob, setSelectedJob }; +} + +/** + * Hook specifically for employer selection + */ +export function useSelectedEmployer() { + const { selectedEmployer, setSelectedEmployer } = useAppState(); + return { selectedEmployer, setSelectedEmployer }; +} + +// ============================ +// Development Utilities +// ============================ + +/** + * Debug utility to log current app state (development only) + */ +export function useAppStateDebug() { + const appState = useAppState(); + + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + console.group('🔍 App State Debug'); + console.log('Selected Candidate:', appState.selectedCandidate); + console.log('Selected Job:', appState.selectedJob); + console.log('Selected Employer:', appState.selectedEmployer); + console.groupEnd(); + } + }, [appState.selectedCandidate, appState.selectedJob, appState.selectedEmployer]); + + return appState; +} \ No newline at end of file diff --git a/frontend/src/hooks/useSecureAuth.tsx b/frontend/src/hooks/useSecureAuth.tsx deleted file mode 100644 index d36e8d5..0000000 --- a/frontend/src/hooks/useSecureAuth.tsx +++ /dev/null @@ -1,786 +0,0 @@ -// Persistent Authentication Hook with localStorage Integration and Date Conversion -// Automatically restoring login state on page refresh with proper date handling - -import React, { createContext, useContext,useState, useCallback, useEffect, useRef } from 'react'; -import * as Types from 'types/types'; -import { useUser } from 'hooks/useUser'; -import { CreateCandidateRequest, CreateEmployerRequest, CreateViewerRequest, LoginRequest } from 'services/api-client'; -import { formatApiRequest } from 'types/conversion'; -// Import date conversion functions -import { - convertCandidateFromApi, - convertEmployerFromApi, - convertViewerFromApi, - convertFromApi, -} from 'types/types'; - - -export interface AuthState { - user: Types.User | null; - isAuthenticated: boolean; - isLoading: boolean; - isInitializing: boolean; - error: string | null; -} - -// Token storage utilities -const TOKEN_STORAGE = { - ACCESS_TOKEN: 'accessToken', - REFRESH_TOKEN: 'refreshToken', - USER_DATA: 'userData', - TOKEN_EXPIRY: 'tokenExpiry' -} as const; - -// JWT token 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); -} - -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); -} - -/** - * Convert user data to storage format (dates to ISO strings) - */ -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 - } -} - -/** - * Convert stored user data back to proper format (ISO strings to dates) - */ -function parseStoredUserData(userDataStr: string): Types.User | null { - try { - const rawUserData = JSON.parse(userDataStr); - - // Determine user type and apply appropriate conversion - const userType = rawUserData.userType || - (rawUserData.companyName ? 'employer' : - rawUserData.firstName ? 'candidate' : 'viewer'); - - switch (userType) { - case 'candidate': - return convertCandidateFromApi(rawUserData) as Types.Candidate; - case 'employer': - return convertEmployerFromApi(rawUserData) as Types.Employer; - case 'viewer': - return convertViewerFromApi(rawUserData) as Types.Viewer; - default: - // Fallback: try to determine by fields present - if (rawUserData.companyName) { - return convertEmployerFromApi(rawUserData) as Types.Employer; - } else if (rawUserData.skills || rawUserData.experience) { - return convertCandidateFromApi(rawUserData) as Types.Candidate; - } else { - return convertViewerFromApi(rawUserData) as Types.Viewer; - } - } - } 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); - // Clear corrupted data - clearStoredAuth(); - } - - return { accessToken, refreshToken, userData, expiresAt }; -} - -/** - * Update stored user data (useful when user profile is updated) - */ -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); - } -} - -export function useSecureAuth() { - const [authState, setAuthState] = useState({ - user: null, - isAuthenticated: false, - isLoading: false, - isInitializing: true, // Start as true, will be set to false after checking tokens - error: null - }); - - const {apiClient} = useUser(); - const initializationCompleted = useRef(false); - - // Token refresh function with date conversion - const refreshAccessToken = useCallback(async (refreshToken: string): Promise => { - try { - const response = await apiClient.refreshToken(refreshToken); - - // Ensure user data has proper date conversion - if (response.user) { - const userType = response.user.userType || - (response.user.companyName ? 'employer' : - response.user.firstName ? 'candidate' : 'viewer'); - - response.user = convertFromApi(response.user, userType); - } - - return response; - } catch (error) { - console.error('Token refresh failed:', error); - return null; - } - }, [apiClient]); - - // Initialize authentication state from stored tokens - const initializeAuth = useCallback(async () => { - if (initializationCompleted.current) { - return; - } - - try { - const stored = getStoredAuthData(); - - // If no stored tokens, user is not authenticated - if (!stored.accessToken || !stored.refreshToken || !stored.userData) { - setAuthState(prev => ({ - ...prev, - isInitializing: false, - isAuthenticated: false, - user: null - })); - return; - } - - // Check if access token is expired - if (isTokenExpired(stored.accessToken)) { - console.log('Access token expired, attempting refresh...'); - - // Try to refresh the token - const refreshResult = await refreshAccessToken(stored.refreshToken); - - if (refreshResult) { - // Successfully refreshed - store with proper date conversion - storeAuthData(refreshResult); - apiClient.setAuthToken(refreshResult.accessToken); - - console.log("User (refreshed) =>", refreshResult.user); - - setAuthState({ - user: refreshResult.user, - isAuthenticated: true, - isLoading: false, - isInitializing: false, - error: null - }); - - console.log('Token refreshed successfully with date conversion'); - } else { - // Refresh failed, clear stored data - console.log('Token refresh failed, clearing stored auth'); - clearStoredAuth(); - apiClient.clearAuthToken(); - - setAuthState({ - user: null, - isAuthenticated: false, - isLoading: false, - isInitializing: false, - error: null - }); - } - } else { - // Access token is still valid - user data already has date conversion applied - apiClient.setAuthToken(stored.accessToken); - - console.log("User (from storage) =>", stored.userData); - setAuthState({ - user: stored.userData, // Already converted by parseStoredUserData - isAuthenticated: true, - isLoading: false, - isInitializing: false, - error: null - }); - - console.log('Restored authentication from stored tokens with date conversion'); - } - } catch (error) { - console.error('Error initializing auth:', error); - clearStoredAuth(); - apiClient.clearAuthToken(); - - setAuthState({ - user: null, - isAuthenticated: false, - isLoading: false, - isInitializing: false, - error: 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; - } - - // Calculate time until token expires (with 5 minute buffer) - const expiryTime = stored.expiresAt * 1000; - const currentTime = Date.now(); - const timeUntilExpiry = expiryTime - currentTime - (5 * 60 * 1000); // 5 minute buffer - - if (timeUntilExpiry <= 0) { - // Token is already expired or will expire soon, refresh immediately - initializeAuth(); - return; - } - - // Set up automatic refresh before token expires - const refreshTimer = setTimeout(() => { - console.log('Auto-refreshing token before expiry...'); - initializeAuth(); - }, timeUntilExpiry); - - return () => clearTimeout(refreshTimer); - }, [authState.isAuthenticated, initializeAuth]); - - const login = useCallback(async (loginData: LoginRequest): Promise => { - setAuthState(prev => ({ ...prev, isLoading: true, error: null })); - - try { - const authResponse = await apiClient.login(loginData); - - // Ensure user data has proper date conversion before storing - if (authResponse.user) { - const userType = authResponse.user.userType || - (authResponse.user.companyName ? 'employer' : - authResponse.user.firstName ? 'candidate' : 'viewer'); - - authResponse.user = convertFromApi(authResponse.user, userType); - } - - // Store tokens and user data with date conversion - storeAuthData(authResponse); - - // Update API client with new token - apiClient.setAuthToken(authResponse.accessToken); - - setAuthState({ - user: authResponse.user, - isAuthenticated: true, - isLoading: false, - isInitializing: false, - error: null - }); - - console.log('Login successful with date conversion applied'); - return true; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Login failed'; - setAuthState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage - })); - return false; - } - }, [apiClient]); - - const logout = useCallback(() => { - // Clear stored authentication data - clearStoredAuth(); - - // Clear API client token - apiClient.clearAuthToken(); - - setAuthState({ - user: null, - isAuthenticated: false, - isLoading: false, - isInitializing: false, - error: null - }); - - console.log('User logged out'); - }, [apiClient]); - - // Update user data in both state and localStorage (with date conversion) - const updateUserData = useCallback((updatedUser: Types.User) => { - // Update localStorage with proper date formatting - updateStoredUserData(updatedUser); - - // Update state - setAuthState(prev => ({ - ...prev, - user: updatedUser - })); - - console.log('User data updated with date conversion'); - }, []); - - const createViewerAccount = useCallback(async (viewerData: CreateViewerRequest): Promise => { - setAuthState(prev => ({ ...prev, isLoading: true, error: null })); - - try { - // Validate password strength - const passwordValidation = apiClient.validatePasswordStrength(viewerData.password); - if (!passwordValidation.isValid) { - throw new Error(passwordValidation.issues.join(', ')); - } - - // Validate email - if (!apiClient.validateEmail(viewerData.email)) { - throw new Error('Please enter a valid email address'); - } - - // Validate username - const usernameValidation = apiClient.validateUsername(viewerData.username); - if (!usernameValidation.isValid) { - throw new Error(usernameValidation.issues.join(', ')); - } - - // Create viewer - API client automatically applies date conversion - const viewer = await apiClient.createViewer(viewerData); - console.log('Viewer created with date conversion:', viewer); - - // Auto-login after successful registration - const loginSuccess = await login({ - login: viewerData.email, - password: viewerData.password - }); - - return loginSuccess; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; - setAuthState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage - })); - return false; - } - }, [apiClient, login]); - - const createCandidateAccount = useCallback(async (candidateData: CreateCandidateRequest): Promise => { - setAuthState(prev => ({ ...prev, isLoading: true, error: null })); - - try { - // Validate password strength - const passwordValidation = apiClient.validatePasswordStrength(candidateData.password); - if (!passwordValidation.isValid) { - throw new Error(passwordValidation.issues.join(', ')); - } - - // Validate email - if (!apiClient.validateEmail(candidateData.email)) { - throw new Error('Please enter a valid email address'); - } - - // Validate username - const usernameValidation = apiClient.validateUsername(candidateData.username); - if (!usernameValidation.isValid) { - throw new Error(usernameValidation.issues.join(', ')); - } - - // Create candidate - API client automatically applies date conversion - const candidate = await apiClient.createCandidate(candidateData); - console.log('Candidate created with date conversion:', candidate); - - // Auto-login after successful registration - const loginSuccess = await login({ - login: candidateData.email, - password: candidateData.password - }); - - return loginSuccess; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; - setAuthState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage - })); - return false; - } - }, [apiClient, login]); - - const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise => { - setAuthState(prev => ({ ...prev, isLoading: true, error: null })); - - try { - // Validate password strength - const passwordValidation = apiClient.validatePasswordStrength(employerData.password); - if (!passwordValidation.isValid) { - throw new Error(passwordValidation.issues.join(', ')); - } - - // Validate email - if (!apiClient.validateEmail(employerData.email)) { - throw new Error('Please enter a valid email address'); - } - - // Validate username - const usernameValidation = apiClient.validateUsername(employerData.username); - if (!usernameValidation.isValid) { - throw new Error(usernameValidation.issues.join(', ')); - } - - // Create employer - API client automatically applies date conversion - const employer = await apiClient.createEmployer(employerData); - console.log('Employer created with date conversion:', employer); - - // Auto-login after successful registration - const loginSuccess = await login({ - login: employerData.email, - password: employerData.password - }); - - return loginSuccess; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; - setAuthState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage - })); - return false; - } - }, [apiClient, login]); - - const requestPasswordReset = useCallback(async (email: string): Promise => { - 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]); - - // Force a token refresh (useful for manual refresh) - const refreshAuth = useCallback(async (): Promise => { - 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({ - user: refreshResult.user, - isAuthenticated: true, - isLoading: false, - isInitializing: false, - error: null - }); - - return true; - } else { - logout(); - return false; - } - }, [apiClient, refreshAccessToken, logout]); - - return { - ...authState, - login, - logout, - createViewerAccount, - createCandidateAccount, - createEmployerAccount, - requestPasswordReset, - refreshAuth, - updateUserData // New function to update user data with proper storage - }; -} - -// ============================ -// Auth Context Provider (Optional) -// ============================ - -const AuthContext = createContext | null>(null); - -export function AuthProvider({ children }: { children: React.ReactNode }) { - const auth = useSecureAuth(); - - return ( - - {children} - - ); -} - -export 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?: 'candidate' | 'employer' | 'viewer'; -} - -export function ProtectedRoute({ - children, - fallback =
Please log in to access this page.
, - requiredUserType -}: ProtectedRouteProps) { - const { isAuthenticated, isInitializing, user } = useAuth(); - - // Show loading while checking stored tokens - if (isInitializing) { - return
Loading...
; - } - - // Not authenticated - if (!isAuthenticated) { - return <>{fallback}; - } - - // Check user type if required - if (requiredUserType && user?.userType !== requiredUserType) { - return
Access denied. Required user type: {requiredUserType}
; - } - - return <>{children}; -} - -// ============================ -// Usage Examples with Date Conversion -// ============================ - -/* -// App.tsx - Root level auth provider -function App() { - return ( - - - - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - ); -} - -// Component using auth with proper date handling -function Header() { - const { user, isAuthenticated, logout, isInitializing } = useAuth(); - - if (isInitializing) { - return
Loading...
; - } - - return ( -
- {isAuthenticated ? ( -
-
- Welcome, {user?.firstName || user?.companyName}! - {user?.createdAt && ( - Member since {user.createdAt.toLocaleDateString()} - )} - {user?.lastLogin && ( - Last login: {user.lastLogin.toLocaleString()} - )} -
- -
- ) : ( -
- Login - Register -
- )} -
- ); -} - -// Profile component with date operations -function UserProfile() { - const { user, updateUserData } = useAuth(); - - if (!user) return null; - - // All date operations work properly because dates are Date objects - const accountAge = user.createdAt ? - Math.floor((Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24)) : 0; - - const handleProfileUpdate = async (updates: Partial) => { - // When updating user data, it will be properly stored with date conversion - const updatedUser = { ...user, ...updates, updatedAt: new Date() }; - updateUserData(updatedUser); - }; - - return ( -
-

Profile

-

Account created: {user.createdAt?.toLocaleDateString()}

-

Account age: {accountAge} days

- {user.lastLogin && ( -

Last login: {user.lastLogin.toLocaleString()}

- )} - - {'availabilityDate' in user && user.availabilityDate && ( -

Available from: {user.availabilityDate.toLocaleDateString()}

- )} - - {'experience' in user && user.experience?.map((exp, index) => ( -
-

{exp.position} at {exp.companyName}

-

- {exp.startDate.toLocaleDateString()} - - {exp.endDate ? exp.endDate.toLocaleDateString() : 'Present'} -

-
- ))} -
- ); -} - -// Auto-redirect based on auth state -function LoginPage() { - const { isAuthenticated, isInitializing } = useAuth(); - - useEffect(() => { - if (isAuthenticated) { - // Redirect to dashboard if already logged in - navigate('/dashboard'); - } - }, [isAuthenticated]); - - if (isInitializing) { - return
Checking authentication...
; - } - - return ; -} -*/ \ No newline at end of file diff --git a/frontend/src/hooks/useUser.tsx b/frontend/src/hooks/useUser.tsx deleted file mode 100644 index 3629997..0000000 --- a/frontend/src/hooks/useUser.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { createContext, useContext, useEffect, useState } from "react"; -import { SetSnackType } from '../components/Snack'; -import { User, Guest, Candidate } from 'types/types'; -import { ApiClient } from "services/api-client"; -import { debugConversion } from "types/conversion"; - -type UserContextType = { - apiClient: ApiClient; - guest: Guest; - candidate: Candidate | null; - setCandidate: (candidate: Candidate | null) => void; -}; - -const UserContext = createContext(undefined); - -const useUser = () => { - const ctx = useContext(UserContext); - if (!ctx) throw new Error("useUser must be used within a UserProvider"); - return ctx; -}; - -interface UserProviderProps { - children: React.ReactNode; - setSnack: SetSnackType; -}; - -const UserProvider: React.FC = (props: UserProviderProps) => { - const { children, setSnack } = props; - const [apiClient, setApiClient] = useState(new ApiClient()); - const [candidate, setCandidate] = useState(null); - const [guest, setGuest] = useState(null); - - useEffect(() => { - console.log("Candidate =>", candidate); - }, [candidate]); - - useEffect(() => { - console.log("Guest =>", guest); - }, [guest]); - - const createGuestSession = () => { - console.log("TODO: Convert this to query the server for the session instead of generating it."); - const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const guest: Guest = { - sessionId, - createdAt: new Date(), - lastActivity: new Date(), - ipAddress: 'unknown', - userAgent: navigator.userAgent - }; - setGuest(guest); - debugConversion(guest, 'Guest Session'); - }; - - const checkExistingAuth = () => { - const accessToken = localStorage.getItem('accessToken'); - const userData = localStorage.getItem('userData'); - if (accessToken && userData) { - try { - const user = JSON.parse(userData); - // Convert dates back to Date objects if they're stored as strings - if (user.createdAt && typeof user.createdAt === 'string') { - user.createdAt = new Date(user.createdAt); - } - if (user.updatedAt && typeof user.updatedAt === 'string') { - user.updatedAt = new Date(user.updatedAt); - } - if (user.lastLogin && typeof user.lastLogin === 'string') { - user.lastLogin = new Date(user.lastLogin); - } - setApiClient(new ApiClient(accessToken)); - } catch (e) { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('userData'); - } - } - }; - - // Create guest session on component mount - useEffect(() => { - createGuestSession(); - checkExistingAuth(); - }, []); - - if (guest === null) { - return <>; - } - - return ( - - {children} - - ); -}; - -export { - UserProvider, - useUser -}; \ No newline at end of file diff --git a/frontend/src/pages/CandidateChatPage.tsx b/frontend/src/pages/CandidateChatPage.tsx index ba07375..bf8e207 100644 --- a/frontend/src/pages/CandidateChatPage.tsx +++ b/frontend/src/pages/CandidateChatPage.tsx @@ -25,8 +25,8 @@ import { ChevronRight, Chat as ChatIcon } from '@mui/icons-material'; -import { useUser } from 'hooks/useUser'; -import { ChatMessageBase, ChatMessage, ChatSession } from 'types/types'; +import { useAuth } from 'hooks/AuthContext'; +import { ChatMessageBase, ChatMessage, ChatSession, ChatStatusType } from 'types/types'; import { ConversationHandle } from 'components/Conversation'; import { BackstoryPageProps } from 'components/BackstoryTab'; import { Message } from 'components/Message'; @@ -34,12 +34,14 @@ import { DeleteConfirmation } from 'components/DeleteConfirmation'; import { CandidateSessionsResponse } from 'services/api-client'; import { CandidateInfo } from 'components/CandidateInfo'; import { useNavigate } from 'react-router-dom'; +import { useSelectedCandidate } from 'hooks/GlobalContext'; const DRAWER_WIDTH = 300; const HANDLE_WIDTH = 48; const CandidateChatPage = forwardRef((props: BackstoryPageProps, ref) => { - const { apiClient, candidate } = useUser(); + const { apiClient } = useAuth(); + const { selectedCandidate } = useSelectedCandidate() const navigate = useNavigate(); const theme = useTheme(); const isMdUp = useMediaQuery(theme.breakpoints.up('md')); @@ -65,13 +67,13 @@ const CandidateChatPage = forwardRef((pr setDrawerOpen(isMdUp || !chatSession); }, [isMdUp, chatSession]); - // Load sessions for the candidate + // Load sessions for the selectedCandidate const loadSessions = async () => { - if (!candidate) return; + if (!selectedCandidate) return; try { setLoading(true); - const result = await apiClient.getCandidateChatSessions(candidate.username); + const result = await apiClient.getCandidateChatSessions(selectedCandidate.username); setSessions(result); } catch (error) { console.error('Failed to load sessions:', error); @@ -94,13 +96,13 @@ const CandidateChatPage = forwardRef((pr // Create new session const createNewSession = async () => { - if (!candidate) { return } + if (!selectedCandidate) { return } try { setLoading(true); const newSession = await apiClient.createCandidateChatSession( - candidate.username, + selectedCandidate.username, 'candidate_chat', - `Interview Discussion - ${candidate.username}` + `Interview Discussion - ${selectedCandidate.username}` ); setChatSession(newSession); setMessages([]); @@ -124,7 +126,7 @@ const CandidateChatPage = forwardRef((pr await apiClient.sendMessageStream( chatSession.id, { prompt: messageContent }, { - onMessage: (msg) => { + onMessage: (msg: ChatMessage) => { console.log("onMessage:", msg); if (msg.type === "response") { setMessages(prev => { @@ -141,10 +143,10 @@ const CandidateChatPage = forwardRef((pr console.log("onError:", error); setStreaming(false); }, - onStreaming: (chunk) => { + onStreaming: (chunk: ChatMessageBase) => { console.log("onStreaming:", chunk); }, - onStatusChange: (status) => { + onStatusChange: (status: ChatStatusType) => { console.log("onStatusChange:", status); }, onComplete: () => { @@ -166,7 +168,7 @@ const CandidateChatPage = forwardRef((pr // Load sessions when username changes useEffect(() => { loadSessions(); - }, [candidate]); + }, [selectedCandidate]); // Load messages when session changes useEffect(() => { @@ -175,7 +177,7 @@ const CandidateChatPage = forwardRef((pr } }, [chatSession]); - if (!candidate) { + if (!selectedCandidate) { navigate('/find-a-candidate'); } @@ -195,7 +197,7 @@ const CandidateChatPage = forwardRef((pr