diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eaa0c09..013894f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "@types/react-dom": "^19.0.4", "@uiw/react-json-view": "^2.0.0-alpha.31", "@uiw/react-markdown-editor": "^6.1.4", + "country-state-city": "^3.2.1", "jsonrepair": "^3.12.0", "lodash": "^4.17.21", "lucide-react": "^0.511.0", @@ -8516,6 +8517,11 @@ "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", "peer": true }, + "node_modules/country-state-city": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/country-state-city/-/country-state-city-3.2.1.tgz", + "integrity": "sha512-kxbanqMc6izjhc/EHkGPCTabSPZ2G6eG4/97akAYHJUN4stzzFEvQPZoF8oXDQ+10gM/O/yUmISCR1ZVxyb6EA==" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0a5b23b..1f7aea4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@types/react-dom": "^19.0.4", "@uiw/react-json-view": "^2.0.0-alpha.31", "@uiw/react-markdown-editor": "^6.1.4", + "country-state-city": "^3.2.1", "jsonrepair": "^3.12.0", "lodash": "^4.17.21", "lucide-react": "^0.511.0", diff --git a/frontend/src/components/CandidateInfo.tsx b/frontend/src/components/CandidateInfo.tsx index 9bb16e4..9a98c6e 100644 --- a/frontend/src/components/CandidateInfo.tsx +++ b/frontend/src/components/CandidateInfo.tsx @@ -110,12 +110,16 @@ const CandidateInfo: React.FC = (props: CandidateInfoProps) - { candidate.location && - Location: {candidate.location.city}, {candidate.location.state || candidate.location.country} - } - { candidate.email && - Email: {candidate.email} - } + {candidate.location && + + Location: {candidate.location.city}, {candidate.location.state || candidate.location.country} + + } + {candidate.email && + + Email: {candidate.email} + + } { candidate.phone && Phone: {candidate.phone} } diff --git a/frontend/src/components/GenerateImage.tsx b/frontend/src/components/GenerateImage.tsx index 3bc8f44..01ffdd5 100644 --- a/frontend/src/components/GenerateImage.tsx +++ b/frontend/src/components/GenerateImage.tsx @@ -5,6 +5,7 @@ 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'; interface GenerateImageProps extends BackstoryElementProps { prompt: string; @@ -12,7 +13,7 @@ interface GenerateImageProps extends BackstoryElementProps { }; const GenerateImage = (props: GenerateImageProps) => { - const { user } = useUser(); + const { user } = useSecureAuth(); const { setSnack, chatSession, prompt } = props; const [processing, setProcessing] = useState(false); const [status, setStatus] = useState(''); diff --git a/frontend/src/components/LocationInput.tsx b/frontend/src/components/LocationInput.tsx new file mode 100644 index 0000000..f455ff7 --- /dev/null +++ b/frontend/src/components/LocationInput.tsx @@ -0,0 +1,361 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + TextField, + Autocomplete, + Typography, + Grid, + Chip, + FormControlLabel, + Checkbox +} from '@mui/material'; +import { LocationOn, Public, Home } from '@mui/icons-material'; +import { Country, State, City } from 'country-state-city'; +import type { ICountry, IState, ICity } from 'country-state-city'; +// Import from your types file - adjust path as needed +import type { Location } from 'types/types'; + +interface LocationInputProps { + value?: Partial; + onChange: (location: Partial) => void; + error?: boolean; + helperText?: string; + required?: boolean; + disabled?: boolean; + showCity?: boolean; +} + +const LocationInput: React.FC = ({ + value = {}, + onChange, + error = false, + helperText, + required = false, + disabled = false, + showCity = false +}) => { + // Get all countries from the library + const allCountries = Country.getAllCountries(); + + const [selectedCountry, setSelectedCountry] = useState( + value.country ? allCountries.find(c => c.name === value.country) || null : null + ); + const [selectedState, setSelectedState] = useState(null); + const [selectedCity, setSelectedCity] = useState(null); + const [isRemote, setIsRemote] = useState(value.remote || false); + + // Get states for selected country + const availableStates = selectedCountry ? State.getStatesOfCountry(selectedCountry.isoCode) : []; + + // Get cities for selected state + const availableCities = selectedCountry && selectedState + ? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode) + : []; + + // Initialize state and city from value prop + useEffect(() => { + if (selectedCountry && value.state) { + const stateMatch = availableStates.find(s => s.name === value.state); + setSelectedState(stateMatch || null); + } + }, [selectedCountry, value.state, availableStates]); + + useEffect(() => { + if (selectedCountry && selectedState && value.city && showCity) { + const cityMatch = availableCities.find(c => c.name === value.city); + setSelectedCity(cityMatch || null); + } + }, [selectedCountry, selectedState, value.city, availableCities, showCity]); + + // Update parent component when values change + useEffect(() => { + const newLocation: Partial = {}; + + if (selectedCountry) { + newLocation.country = selectedCountry.name; + } + + if (selectedState) { + newLocation.state = selectedState.name; + } + + if (selectedCity && showCity) { + newLocation.city = selectedCity.name; + } + + if (isRemote) { + newLocation.remote = isRemote; + } + + // Only call onChange if there's actual data or if clearing + if (Object.keys(newLocation).length > 0 || (value.country || value.state || value.city)) { + onChange(newLocation); + } + }, [selectedCountry, selectedState, selectedCity, isRemote, onChange, value.country, value.state, value.city, showCity]); + + const handleCountryChange = (event: any, newValue: ICountry | null) => { + setSelectedCountry(newValue); + // Clear state and city when country changes + setSelectedState(null); + setSelectedCity(null); + }; + + const handleStateChange = (event: any, newValue: IState | null) => { + setSelectedState(newValue); + // Clear city when state changes + setSelectedCity(null); + }; + + const handleCityChange = (event: any, newValue: ICity | null) => { + setSelectedCity(newValue); + }; + + const handleRemoteToggle = (event: React.ChangeEvent) => { + setIsRemote(event.target.checked); + }; + + return ( + + + + Location {required && *} + + + + {/* Country Selection */} + + option.name} + disabled={disabled} + renderInput={(params) => ( + + }} + /> + )} + renderOption={(props, option) => ( + + + {option.name} + + )} + /> + + + {/* State/Region Selection */} + {selectedCountry && ( + + option.name} + disabled={disabled || availableStates.length === 0} + renderInput={(params) => ( + 0 ? "Select state/region" : "No states available"} + /> + )} + /> + + )} + + {/* City Selection */} + {showCity && selectedCountry && selectedState && ( + + option.name} + disabled={disabled || availableCities.length === 0} + renderInput={(params) => ( + 0 ? "Select city" : "No cities available"} + InputProps={{ + ...params.InputProps, + startAdornment: + }} + /> + )} + /> + + )} + + {/* Remote Work Option */} + + + } + label="Open to remote work" + /> + + + {/* Location Summary Chips */} + {(selectedCountry || selectedState || selectedCity || isRemote) && ( + + + {selectedCountry && ( + } + label={selectedCountry.name} + variant="outlined" + color="primary" + size="small" + /> + )} + {selectedState && ( + + )} + {selectedCity && showCity && ( + } + label={selectedCity.name} + variant="outlined" + color="default" + size="small" + /> + )} + {isRemote && ( + + )} + + + )} + + + ); +}; + +// Demo component to show usage with real data +const LocationInputDemo: React.FC = () => { + const [location, setLocation] = useState>({}); + const [showAdvanced, setShowAdvanced] = useState(false); + + const handleLocationChange = (newLocation: Partial) => { + setLocation(newLocation); + console.log('Location updated:', newLocation); + }; + + // Show some stats about the data + const totalCountries = Country.getAllCountries().length; + const usStates = State.getStatesOfCountry('US').length; + const canadaProvinces = State.getStatesOfCountry('CA').length; + + return ( + + + Location Input with Real Data + + + + Using country-state-city library with {totalCountries} countries, + {usStates} US states, {canadaProvinces} Canadian provinces, and thousands of cities + + + + + + Basic Location Input + + + + + + setShowAdvanced(e.target.checked)} + color="primary" + /> + } + label="Show city field" + /> + + + {showAdvanced && ( + + + Advanced Location Input (with City) + + + + )} + + + + Current Location Data: + + + {JSON.stringify(location, null, 2)} + + + + + + šŸ’” This component uses the country-state-city library which is regularly updated + and includes ISO codes, flags, and comprehensive location data. + + + + + ); +}; + +export { LocationInput }; \ No newline at end of file diff --git a/frontend/src/components/layout/BackstoryLayout.tsx b/frontend/src/components/layout/BackstoryLayout.tsx index b1fa5ff..814ba45 100644 --- a/frontend/src/components/layout/BackstoryLayout.tsx +++ b/frontend/src/components/layout/BackstoryLayout.tsx @@ -3,6 +3,7 @@ import { Outlet, useLocation, Routes } from "react-router-dom"; import { Box, Container, Paper } from '@mui/material'; import { useNavigate } from "react-router-dom"; import ChatIcon from '@mui/icons-material/Chat'; +import DashboardIcon from '@mui/icons-material/Dashboard'; import DescriptionIcon from '@mui/icons-material/Description'; import BarChartIcon from '@mui/icons-material/BarChart'; import SettingsIcon from '@mui/icons-material/Settings'; @@ -19,6 +20,7 @@ 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'; type NavigationLinkType = { name: string; @@ -29,41 +31,44 @@ type NavigationLinkType = { const DefaultNavItems: NavigationLinkType[] = [ { name: 'Find a Candidate', path: '/find-a-candidate', icon: }, - { name: 'Docs', path: '/docs', icon: }, - // { name: 'How It Works', path: '/how-it-works', icon: }, - // { name: 'For Candidates', path: '/for-candidates', icon: }, - // { name: 'For Employers', path: '/for-employers', icon: }, - // { name: 'Pricing', path: '/pricing', icon: }, + { name: 'Docs', path: '/docs', icon: }, + // { name: 'How It Works', path: '/how-it-works', icon: }, + // { name: 'For Candidates', path: '/for-candidates', icon: }, + // { name: 'For Employers', path: '/for-employers', icon: }, + // { name: 'Pricing', path: '/pricing', icon: }, +]; + +const ViewerNavItems: NavigationLinkType[] = [ + { name: 'Chat', path: '/chat', icon: }, ]; const CandidateNavItems : NavigationLinkType[]= [ { name: 'Chat', path: '/chat', icon: }, - // { name: 'Job Analysis', path: '/job-analysis', icon: }, - { name: 'Resume Builder', path: '/resume-builder', icon: }, - // { name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: }, - { name: 'Find a Candidate', path: '/find-a-candidate', icon: }, - // { name: 'Dashboard', icon: , path: '/dashboard' }, - // { name: 'Profile', icon: , path: '/profile' }, - // { name: 'Backstory', icon: , path: '/backstory' }, - // { name: 'Resumes', icon: , path: '/resumes' }, - // { name: 'Q&A Setup', icon: , path: '/qa-setup' }, - // { name: 'Analytics', icon: , path: '/analytics' }, - // { name: 'Settings', icon: , path: '/settings' }, + // { name: 'Job Analysis', path: '/candidate/job-analysis', icon: }, + { name: 'Resume Builder', path: '/candidate/resume-builder', icon: }, + // { name: 'Knowledge Explorer', path: '/candidate/knowledge-explorer', icon: }, + { name: 'Dashboard', icon: , path: '/candidate/dashboard' }, + // { name: 'Profile', icon: , path: '/candidate/profile' }, + // { name: 'Backstory', icon: , path: '/candidate/backstory' }, + // { name: 'Resumes', icon: , path: '/candidate/resumes' }, + // { name: 'Q&A Setup', icon: , path: '/candidate/qa-setup' }, + // { name: 'Analytics', icon: , path: '/candidate/analytics' }, + // { name: 'Settings', icon: , path: '/candidate/settings' }, ]; const EmployerNavItems: NavigationLinkType[] = [ { name: 'Chat', path: '/chat', icon: }, - { name: 'Job Analysis', path: '/job-analysis', icon: }, - { name: 'Resume Builder', path: '/resume-builder', icon: }, - { name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: }, + { name: 'Job Analysis', path: '/employer/job-analysis', icon: }, + { name: 'Resume Builder', path: '/employer/resume-builder', icon: }, + { name: 'Knowledge Explorer', path: '/employer/knowledge-explorer', icon: }, { name: 'Find a Candidate', path: '/find-a-candidate', icon: }, - // { name: 'Dashboard', icon: , path: '/dashboard' }, - // { name: 'Search', icon: , path: '/search' }, - // { name: 'Saved', icon: , path: '/saved' }, - // { name: 'Jobs', icon: , path: '/jobs' }, - // { name: 'Company', icon: , path: '/company' }, - // { name: 'Analytics', icon: , path: '/analytics' }, - // { name: 'Settings', icon: , path: '/settings' }, + // { name: 'Dashboard', icon: , path: '/employer/dashboard' }, + // { name: 'Search', icon: , path: '/employer/search' }, + // { name: 'Saved', icon: , path: '/employer/saved' }, + // { name: 'Jobs', icon: , path: '/employer/jobs' }, + // { name: 'Company', icon: , path: '/employer/company' }, + // { name: 'Analytics', icon: , path: '/employer/analytics' }, + // { name: 'Settings', icon: , path: '/employer/settings' }, ]; // Navigation links based on user type @@ -73,10 +78,12 @@ const getNavigationLinks = (user: User | null): NavigationLinkType[] => { } switch (user.userType) { + case 'viewer': + return DefaultNavItems.concat(ViewerNavItems); case 'candidate': - return CandidateNavItems; + return DefaultNavItems.concat(CandidateNavItems); case 'employer': - return EmployerNavItems; + return DefaultNavItems.concat(EmployerNavItems); default: return DefaultNavItems; } @@ -130,7 +137,8 @@ const BackstoryLayout: React.FC = (props: BackstoryLayoutP const { setSnack, page, chatRef, snackRef, submitQuery } = props; const navigate = useNavigate(); const location = useLocation(); - const { user, guest, candidate } = useUser(); + const { guest, candidate } = useUser(); + const { user } = useSecureAuth(); const [navigationLinks, setNavigationLinks] = useState([]); useEffect(() => { diff --git a/frontend/src/components/layout/BackstoryRoutes.tsx b/frontend/src/components/layout/BackstoryRoutes.tsx index 172c9b9..a279367 100644 --- a/frontend/src/components/layout/BackstoryRoutes.tsx +++ b/frontend/src/components/layout/BackstoryRoutes.tsx @@ -18,6 +18,7 @@ import { JobAnalysisPage } from 'pages/JobAnalysisPage'; import { GenerateCandidate } from "pages/GenerateCandidate"; import { ControlsPage } from 'pages/ControlsPage'; import { LoginPage } from "pages/LoginPage"; +import { CandidateDashboardPage } from "pages/CandidateDashboardPage" const ProfilePage = () => (Profile); const BackstoryPage = () => (Backstory); @@ -31,41 +32,44 @@ const LogoutPage = () => (Logout page... (Dashboard); // const AnalyticsPage = () => (Analytics); // const SettingsPage = () => (Settings); - interface BackstoryDynamicRoutesProps extends BackstoryPageProps { chatRef: Ref; user?: User | null; } const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNode => { const { user, setSnack, submitQuery, chatRef } = props; + const backstoryProps = { + setSnack, submitQuery + }; let index=0 const routes = [ } />, - } />, - } />, - } />, - } />, - } />, - } />, - } />, - } />, - } />, + } />, + } />, + } />, + } />, + } />, + } />, + } />, + } />, + } />, ]; if (!user) { routes.push()} />); - routes.push(} />); + routes.push(} />); routes.push(} />); } else { - routes.push(} />); + routes.push(} />); routes.push(} />); if (user.userType === 'candidate') { routes.splice(-1, 0, ...[ - } />, - } />, - } />, - } />, + } />, + } />, + } />, + } />, + } />, ]); } diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index a9c8520..006bbf6 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -33,11 +33,12 @@ import { import { NavigationLinkType } from 'components/layout/BackstoryLayout'; import { Beta } from 'components/Beta'; import { useUser } from 'hooks/useUser'; -import { Candidate, Employer } from 'types/types'; +import { Candidate, Employer, Viewer } from 'types/types'; import { SetSnackType } from 'components/Snack'; import { CopyBubble } from 'components/CopyBubble'; import 'components/layout/Header.css'; +import { useSecureAuth } from 'hooks/useSecureAuth'; // Styled components const StyledAppBar = styled(AppBar, { @@ -97,9 +98,10 @@ interface HeaderProps { } const Header: React.FC = (props: HeaderProps) => { - const { user, setUser } = useUser(); + const { user, logout } = useSecureAuth(); 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, @@ -112,7 +114,7 @@ const Header: React.FC = (props: HeaderProps) => { const theme = useTheme(); const location = useLocation(); - const name = (candidate ? candidate.username : user?.email) || ''; + const name = (user?.firstName || user?.email || ''); const BackstoryLogo = () => { return = (props: HeaderProps) => { const handleLogout = () => { handleUserMenuClose(); - setUser(null); + logout(); + navigate('/'); }; const handleDrawerToggle = () => { @@ -325,31 +328,28 @@ const Header: React.FC = (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" } + }} > - - - - - Profile + { handleUserMenuClose(); navigate(`/${user.userType}/profile`) }}> + + Profile - - - - - Dashboard + { handleUserMenuClose(); navigate(`/${user.userType}/dashboard`) }}> + + Dashboard - - - - - Settings + { handleUserMenuClose(); navigate(`/${user.userType}settings`) }}> + + Settings - - - - Logout + + Logout diff --git a/frontend/src/hooks/useSecureAuth.tsx b/frontend/src/hooks/useSecureAuth.tsx new file mode 100644 index 0000000..7a2b73d --- /dev/null +++ b/frontend/src/hooks/useSecureAuth.tsx @@ -0,0 +1,625 @@ +// Persistent Authentication Hook with localStorage Integration +// Automatically restoring login state on page refresh + +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'; + +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); +} + +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, JSON.stringify(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 = JSON.parse(userDataStr); + } + if (expiryStr) { + expiresAt = parseInt(expiryStr, 10); + } + } catch (error) { + console.error('Failed to parse stored auth data:', error); + } + + return { accessToken, refreshToken, userData, expiresAt }; +} + +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 + 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 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 + storeAuthData(refreshResult); + apiClient.setAuthToken(refreshResult.accessToken); + + console.log("User =>", refreshResult.user); + + setAuthState({ + user: refreshResult.user, + isAuthenticated: true, + isLoading: false, + isInitializing: false, + error: null + }); + + console.log('Token refreshed successfully'); + } 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 + apiClient.setAuthToken(stored.accessToken); + + console.log("User =>", stored.userData); + setAuthState({ + user: stored.userData, + 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(); + + 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); + + // Store tokens and user data + 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'); + 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]); + + 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(', ')); + } + + const viewer = await apiClient.createViewer(viewerData); + + // 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(', ')); + } + + const candidate = await apiClient.createCandidate(candidateData); + + // 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(', ')); + } + + const employer = await apiClient.createEmployer(employerData); + + // 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 + }; +} + +// ============================ +// 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'; +} + +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 +// ============================ + +/* +// App.tsx - Root level auth provider +function App() { + return ( + + + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + ); +} + +// Component using auth +function Header() { + const { user, isAuthenticated, logout, isInitializing } = useAuth(); + + if (isInitializing) { + return
Loading...
; + } + + return ( +
+ {isAuthenticated ? ( +
+ Welcome, {user?.firstName || user?.companyName}! + +
+ ) : ( +
+ Login + Register +
+ )} +
+ ); +} + +// 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 index b98ddd1..3629997 100644 --- a/frontend/src/hooks/useUser.tsx +++ b/frontend/src/hooks/useUser.tsx @@ -6,10 +6,8 @@ import { debugConversion } from "types/conversion"; type UserContextType = { apiClient: ApiClient; - user: User | null; guest: Guest; candidate: Candidate | null; - setUser: (user: User | null) => void; setCandidate: (candidate: Candidate | null) => void; }; @@ -31,8 +29,6 @@ const UserProvider: React.FC = (props: UserProviderProps) => const [apiClient, setApiClient] = useState(new ApiClient()); const [candidate, setCandidate] = useState(null); const [guest, setGuest] = useState(null); - const [user, setUser] = useState(null); - const [activeUser, setActiveUser] = useState(null); useEffect(() => { console.log("Candidate =>", candidate); @@ -42,65 +38,6 @@ const UserProvider: React.FC = (props: UserProviderProps) => console.log("Guest =>", guest); }, [guest]); - /* If the user changes to a non-null value, create a new - * apiClient with the access token */ - useEffect(() => { - console.log("User => ", user); - if (user === null) { - return; - } - /* This apiClient will persist until the user is changed - * or logged out */ - const accessToken = localStorage.getItem('accessToken'); - if (!accessToken) { - throw Error("accessToken is not set for user!"); - } - setApiClient(new ApiClient(accessToken)); - }, [user]); - - /* Handle logout if any consumers of UserProvider setUser to NULL */ - useEffect(() => { - /* If there is an active user and it is the same as the - * new user, do nothing */ - if (activeUser && activeUser.email === user?.email) { - return; - } - - const logout = async () => { - if (!activeUser) { - return; - } - console.log(`Logging out ${activeUser.email}`); - try { - const accessToken = localStorage.getItem('accessToken'); - const refreshToken = localStorage.getItem('refreshToken'); - if (!accessToken || !refreshToken) { - setSnack("Authentication tokens are invalid.", "error"); - return; - } - const results = await apiClient.logout(accessToken, refreshToken); - if (results.error) { - console.error(results.error); - setSnack(results.error.message, "error") - } - } catch (e) { - console.error(e); - setSnack(`Unable to logout: ${e}`, "error") - } - - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('userData'); - createGuestSession(); - setUser(null); - }; - - setActiveUser(user); - if (!user) { - logout(); - } - }, [user, apiClient, activeUser]); - 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)}`; @@ -132,7 +69,6 @@ const UserProvider: React.FC = (props: UserProviderProps) => user.lastLogin = new Date(user.lastLogin); } setApiClient(new ApiClient(accessToken)); - setUser(user); } catch (e) { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); @@ -152,7 +88,7 @@ const UserProvider: React.FC = (props: UserProviderProps) => } return ( - + {children} ); diff --git a/frontend/src/pages/CandidateChatPage.tsx b/frontend/src/pages/CandidateChatPage.tsx index 434cb67..34cbd3d 100644 --- a/frontend/src/pages/CandidateChatPage.tsx +++ b/frontend/src/pages/CandidateChatPage.tsx @@ -158,11 +158,11 @@ const CandidateChatPage = forwardRef((pr return ( - {candidate && } + {candidate && } < Box sx={{ display: "flex", mt: 1, gap: 1, height: "100%" }}> {/* Sessions Sidebar */} - + Chat Sessions {sessions && ( @@ -215,20 +215,6 @@ const CandidateChatPage = forwardRef((pr )} - - {sessions && ( - - - Candidate Info - - - Name: {sessions.candidate.fullName} - - - Email: {sessions.candidate.email} - - - )} {/* Chat Interface */} diff --git a/frontend/src/pages/CandidateDashboardPage.tsx b/frontend/src/pages/CandidateDashboardPage.tsx new file mode 100644 index 0000000..cbd629c --- /dev/null +++ b/frontend/src/pages/CandidateDashboardPage.tsx @@ -0,0 +1,277 @@ +import React from 'react'; +import { + Box, + Card, + CardContent, + Typography, + Button, + LinearProgress, + List, + ListItem, + ListItemIcon, + ListItemText, + ListItemButton, + Divider, + Chip, + Stack +} from '@mui/material'; +import { + Dashboard as DashboardIcon, + Person as PersonIcon, + Article as ArticleIcon, + Description as DescriptionIcon, + Quiz as QuizIcon, + Analytics as AnalyticsIcon, + Settings as SettingsIcon, + Add as AddIcon, + Visibility as VisibilityIcon, + Download as DownloadIcon, + ContactMail as ContactMailIcon, + Edit as EditIcon, + TipsAndUpdates as TipsIcon, + SettingsBackupRestore +} from '@mui/icons-material'; +import { useSecureAuth } from 'hooks/useSecureAuth'; +import { LoadingPage } from './LoadingPage'; +import { LoginRequired } from './LoginRequired'; +import { BackstoryPageProps } from 'components/BackstoryTab'; +import { Navigate, useNavigate } from 'react-router-dom'; + +interface DashboardProps extends BackstoryPageProps { + userName?: string; + profileCompletion?: number; +} + +const CandidateDashboardPage: React.FC = (props: DashboardProps) => { + const navigate = useNavigate(); + const { setSnack } = props; + const { user, isLoading, isInitializing, isAuthenticated } = useSecureAuth(); + const profileCompletion = 75; + const sidebarItems = [ + { icon: , text: 'Dashboard', active: true }, + { icon: , text: 'Profile', active: false }, + { icon: , text: 'Backstory', active: false }, + { icon: , text: 'Resumes', active: false }, + { icon: , text: 'Q&A Setup', active: false }, + { icon: , text: 'Analytics', active: false }, + { icon: , text: 'Settings', active: false }, + ]; + + if (isLoading || isInitializing) { + return (); + } + if (!user || !isAuthenticated) { + return (); + } + if (user.userType !== 'candidate') { + setSnack(`The page you were on is only available for candidates (you are a ${user.userType}`, 'warning'); + navigate('/'); + return (<>); + } + + return ( + + {/* Sidebar */} + + + JobPortal + + + + {sidebarItems.map((item, index) => ( + + + + {item.icon} + + + + + ))} + + + + {/* Main Content */} + + {/* Welcome Section */} + + + Welcome back, {user.firstName}! + + + + + Your profile is {profileCompletion}% complete + + + + + + + + {/* Cards Grid */} + + {/* Top Row */} + + {/* Resume Builder Card */} + + + + Resume Builder + + + + 3 custom resumes + + + + Last created: May 15, 2025 + + + + + + + {/* Recent Activity Card */} + + + + Recent Activity + + + + + + 5 profile views + + + + + 2 resume downloads + + + + + 1 direct contact + + + + + + + + + {/* Bottom Row */} + + {/* Complete Your Backstory Card */} + + + + Complete Your Backstory + + + + + • Add projects + + + • Detail skills + + + • Work history + + + + + + + + {/* Improvement Suggestions Card */} + + + + Improvement Suggestions + + + + + • Add certifications + + + • Enhance your project details + + + + + + + + + + + ); +}; + +export { CandidateDashboardPage }; \ No newline at end of file diff --git a/frontend/src/pages/JobAnalysisPage.tsx b/frontend/src/pages/JobAnalysisPage.tsx index 9832a9e..b27f4d6 100644 --- a/frontend/src/pages/JobAnalysisPage.tsx +++ b/frontend/src/pages/JobAnalysisPage.tsx @@ -33,130 +33,59 @@ import WorkIcon from '@mui/icons-material/Work'; import AssessmentIcon from '@mui/icons-material/Assessment'; import DescriptionIcon from '@mui/icons-material/Description'; import FileUploadIcon from '@mui/icons-material/FileUpload'; -import { JobMatchAnalysis } from '../components/JobMatchAnalysis'; - -// Mock types for our application -interface Candidate { - id: string; - name: string; - title: string; - location: string; - email: string; - phone: string; - photoUrl?: string; - resume?: string; -} - -interface User { - id: string; - name: string; - company: string; - role: string; -} - -// Mock hook for getting the current user -const useUser = (): { user: User | null, loading: boolean } => { - // In a real app, this would check auth state and get user info - const [loading, setLoading] = useState(true); - const [user, setUser] = useState(null); - - useEffect(() => { - // Simulate fetching user data - setTimeout(() => { - setUser({ - id: 'emp123', - name: 'Sarah Thompson', - company: 'Tech Innovations Inc.', - role: 'HR Manager' - }); - setLoading(false); - }, 800); - }, []); - - return { user, loading }; -}; - -// Mock API for fetching candidates -const fetchCandidates = async (searchQuery: string = ''): Promise => { - // Simulate API delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - const mockCandidates: Candidate[] = [ - { - id: 'c1', - name: 'Alex Johnson', - title: 'Senior Frontend Developer', - location: 'Seattle, WA', - email: 'alex.johnson@example.com', - phone: '(555) 123-4567', - photoUrl: 'https://i.pravatar.cc/150?img=11' - }, - { - id: 'c2', - name: 'Morgan Williams', - title: 'Full Stack Engineer', - location: 'Portland, OR', - email: 'morgan.w@example.com', - phone: '(555) 234-5678', - photoUrl: 'https://i.pravatar.cc/150?img=12' - }, - { - id: 'c3', - name: 'Jamie Garcia', - title: 'DevOps Specialist', - location: 'San Francisco, CA', - email: 'jamie.g@example.com', - phone: '(555) 345-6789', - photoUrl: 'https://i.pravatar.cc/150?img=13' - }, - { - id: 'c4', - name: 'Taylor Chen', - title: 'Backend Developer', - location: 'Austin, TX', - email: 'taylor.c@example.com', - phone: '(555) 456-7890', - photoUrl: 'https://i.pravatar.cc/150?img=14' - }, - { - id: 'c5', - name: 'Jordan Smith', - title: 'UI/UX Developer', - location: 'Chicago, IL', - email: 'jordan.s@example.com', - phone: '(555) 567-8901', - photoUrl: 'https://i.pravatar.cc/150?img=15' - } - ]; - - if (!searchQuery) return mockCandidates; - - // Filter candidates based on search query - return mockCandidates.filter(candidate => - candidate.name.toLowerCase().includes(searchQuery.toLowerCase()) || - candidate.title.toLowerCase().includes(searchQuery.toLowerCase()) || - candidate.location.toLowerCase().includes(searchQuery.toLowerCase()) - ); -}; +import { JobMatchAnalysis } from 'components/JobMatchAnalysis'; +import { Candidate } from "types/types"; +import { useUser } from "hooks/useUser"; +import { useNavigate } from 'react-router-dom'; +import { BackstoryPageProps } from 'components/BackstoryTab'; +import { useSecureAuth } from 'hooks/useSecureAuth'; // Main component -const JobAnalysisPage: React.FC = () => { +const JobAnalysisPage: React.FC = (props: BackstoryPageProps) => { const theme = useTheme(); - const { user, loading: userLoading } = useUser(); + const { user } = useSecureAuth(); // State management const [activeStep, setActiveStep] = useState(0); - const [candidates, setCandidates] = useState([]); const [selectedCandidate, setSelectedCandidate] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [loadingCandidates, setLoadingCandidates] = useState(false); const [jobDescription, setJobDescription] = useState(''); const [jobTitle, setJobTitle] = useState(''); const [jobLocation, setJobLocation] = useState(''); const [analysisStarted, setAnalysisStarted] = useState(false); const [error, setError] = useState(null); const [openUploadDialog, setOpenUploadDialog] = useState(false); - + const { apiClient } = useUser(); + const { setSnack } = props; + const [candidates, setCandidates] = useState(null); + + useEffect(() => { + if (candidates !== null) { + return; + } + const getCandidates = async () => { + try { + const results = await apiClient.getCandidates(); + const candidates: Candidate[] = results.data; + candidates.sort((a, b) => { + let result = a.lastName.localeCompare(b.lastName); + if (result === 0) { + result = a.firstName.localeCompare(b.firstName); + } + if (result === 0) { + result = a.username.localeCompare(b.username); + } + return result; + }); + console.log(candidates); + setCandidates(candidates); + } catch (err) { + setSnack("" + err); + } + }; + + getCandidates(); + }, [candidates, setSnack]); + // Steps in our process const steps = [ { label: 'Select Candidate', icon: }, @@ -164,38 +93,6 @@ const JobAnalysisPage: React.FC = () => { { label: 'View Analysis', icon: } ]; - // Load initial candidates - useEffect(() => { - const loadCandidates = async () => { - setLoadingCandidates(true); - try { - const data = await fetchCandidates(); - setCandidates(data); - } catch (err) { - setError('Failed to load candidates. Please try again.'); - } finally { - setLoadingCandidates(false); - } - }; - - if (user) { - loadCandidates(); - } - }, [user]); - - // Handler for candidate search - const handleSearch = async () => { - setLoadingCandidates(true); - try { - const data = await fetchCandidates(searchQuery); - setCandidates(data); - } catch (err) { - setError('Search failed. Please try again.'); - } finally { - setLoadingCandidates(false); - } - }; - // Mock handlers for our analysis APIs const fetchRequirements = async (): Promise => { // Simulates extracting requirements from the job description @@ -371,7 +268,7 @@ const JobAnalysisPage: React.FC = () => { Select a Candidate
- + {/* { }} sx={{ mr: 2 }} /> - - - {loadingCandidates ? ( - - - - ) : candidates.length === 0 ? ( - No candidates found. Please adjust your search criteria. - ) : ( + */} + - {candidates.map((candidate) => ( + {candidates?.map((candidate) => ( { - {candidate.name} + {candidate.fullName} - {candidate.title} + {candidate.description} - - Location: {candidate.location} - + {candidate.location && + Location: {candidate.location.country} + } Email: {candidate.email} - - Phone: {candidate.phone} - + {candidate.phone && + Phone: {candidate.phone} + } ))} - - )} + ); @@ -530,7 +419,7 @@ const JobAnalysisPage: React.FC = () => { {selectedCandidate && ( @@ -538,15 +427,6 @@ const JobAnalysisPage: React.FC = () => { ); - // If user is loading, show loading state - if (userLoading) { - return ( - - - - ); - } - // If no user is logged in, show message if (!user) { return ( @@ -578,7 +458,8 @@ const JobAnalysisPage: React.FC = () => { {steps.map((step, index) => ( - ( + ( = index ? theme.palette.primary.main : theme.palette.grey[300], @@ -587,7 +468,9 @@ const JobAnalysisPage: React.FC = () => { > {step.icon} - )}> + ) + }} + > {step.label} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 03359e3..474588a 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -16,9 +16,36 @@ import { Card, CardContent, Divider, - Avatar + Avatar, + IconButton, + InputAdornment, + List, + ListItem, + ListItemIcon, + ListItemText, + Collapse, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio, + Chip } from '@mui/material'; -import { Person, PersonAdd, AccountCircle, ExitToApp } from '@mui/icons-material'; +import { + Person, + PersonAdd, + AccountCircle, + ExitToApp, + Visibility, + VisibilityOff, + CheckCircle, + Cancel, + ExpandLess, + ExpandMore, + Visibility as ViewIcon, + Work, + Business +} from '@mui/icons-material'; import 'react-phone-number-input/style.css'; import PhoneInput from 'react-phone-number-input'; import { E164Number } from 'libphonenumber-js/core'; @@ -26,22 +53,15 @@ import './LoginPage.css'; import { ApiClient } from 'services/api-client'; import { useUser } from 'hooks/useUser'; +import { useSecureAuth } from 'hooks/useSecureAuth'; +import { LocationInput } from 'components/LocationInput'; +import { Location } from 'types/types'; -// Import conversion utilities -import { - formatApiRequest, - parseApiResponse, - handleApiResponse, - extractApiData, - isSuccessResponse, - debugConversion, - type ApiResponse -} from 'types/conversion'; - -import { - AuthResponse, User, Guest, Candidate -} from 'types/types' +import { Candidate } from 'types/types' import { useNavigate } from 'react-router-dom'; +import { BackstoryPageProps } from 'components/BackstoryTab'; + +type UserRegistrationType = 'viewer' | 'candidate' | 'employer'; interface LoginRequest { login: string; @@ -49,25 +69,49 @@ interface LoginRequest { } interface RegisterRequest { + userType: UserRegistrationType; username: string; email: string; firstName: string; lastName: string; password: string; + confirmPassword: string; phone?: string; + // Employer specific fields (placeholder) + companyName?: string; + industry?: string; + companySize?: string; +} + +interface PasswordRequirement { + label: string; + met: boolean; } const apiClient = new ApiClient(); -const LoginPage: React.FC = () => { - const navigate = useNavigate(); - const { user, setUser, guest } = useUser(); +const LoginPage: React.FC = (props: BackstoryPageProps) => { + const { setSnack } = props; + const { guest } = useUser(); const [tabValue, setTabValue] = useState(0); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [phone, setPhone] = useState(null); - const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || ''; + const { createCandidateAccount, createViewerAccount, user, login, isLoading, error } = useSecureAuth(); + const [passwordValidation, setPasswordValidation] = useState<{ isValid: boolean; issues: string[] }>({ isValid: true, issues: [] }); + const name = (user?.userType === 'candidate' || user?.userType === 'viewer') ? user.username : user?.email || ''; + const [location, setLocation] = useState>({}); + + // Password visibility states + const [showLoginPassword, setShowLoginPassword] = useState(false); + const [showRegisterPassword, setShowRegisterPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [showPasswordRequirements, setShowPasswordRequirements] = useState(false); + + const handleLocationChange = (location: Partial) => { + setLocation(location); + console.log('Location updated:', location); + }; // Login form state const [loginForm, setLoginForm] = useState({ @@ -77,14 +121,49 @@ const LoginPage: React.FC = () => { // Register form state const [registerForm, setRegisterForm] = useState({ + userType: 'candidate', username: '', email: '', firstName: '', lastName: '', password: '', - phone: '' + confirmPassword: '', + phone: '', + companyName: '', + industry: '', + companySize: '' }); + // Password requirements validation + const getPasswordRequirements = (password: string): PasswordRequirement[] => { + return [ + { + label: 'At least 8 characters long', + met: password.length >= 8 + }, + { + label: 'Contains uppercase letter', + met: /[A-Z]/.test(password) + }, + { + label: 'Contains lowercase letter', + met: /[a-z]/.test(password) + }, + { + label: 'Contains number', + met: /\d/.test(password) + }, + { + label: 'Contains special character (!@#$%^&*)', + met: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password) + } + ]; + }; + + const passwordRequirements = getPasswordRequirements(registerForm.password); + const passwordsMatch = registerForm.password === registerForm.confirmPassword; + const hasPasswordMatchError = registerForm.confirmPassword.length > 0 && !passwordsMatch; + useEffect(() => { if (phone !== registerForm.phone && phone) { console.log({ phone }); @@ -95,94 +174,123 @@ const LoginPage: React.FC = () => { const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); - setError(null); setSuccess(null); - try { - const authResponse = await apiClient.login(loginForm.login, loginForm.password) - - debugConversion(authResponse, 'Login Response'); - - // Store tokens in localStorage - localStorage.setItem('accessToken', authResponse.accessToken); - localStorage.setItem('refreshToken', authResponse.refreshToken); - localStorage.setItem('userData', JSON.stringify(authResponse.user)); - + const success = await login(loginForm); + if (success) { setSuccess('Login successful!'); - navigate('/'); - setUser(authResponse.user); - // Clear form - setLoginForm({ login: '', password: '' }); - } catch (err) { - console.error('Login error:', err); - setError(err instanceof Error ? err.message : 'Login failed'); - } finally { - setLoading(false); + } + }; + + const handlePasswordChange = (password: string) => { + setRegisterForm(prev => ({ ...prev, password })); + setPasswordValidation(apiClient.validatePasswordStrength(password)); + + // Show requirements if password has content and isn't valid + if (password.length > 0) { + const requirements = getPasswordRequirements(password); + const allMet = requirements.every(req => req.met); + if (!allMet && !showPasswordRequirements) { + setShowPasswordRequirements(true); + } + if (allMet && showPasswordRequirements) { + setShowPasswordRequirements(false); + } + } + }; + + const handleUserTypeChange = (event: React.ChangeEvent) => { + const userType = event.target.value as UserRegistrationType; + setRegisterForm(prev => ({ ...prev, userType })); + + // Clear location and phone for viewer type + if (userType === 'viewer') { + setLocation({}); + setPhone(null); } }; const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); + + // Check if employer registration is attempted + if (registerForm.userType === 'employer') { + setSnack('Employer registration is not yet supported. Please contact support for employer account setup.', "warning"); + return; + } + + // Validate passwords match + if (!passwordsMatch) { + return; + } + + // Validate password requirements + const allRequirementsMet = passwordRequirements.every(req => req.met); + if (!allRequirementsMet) { + return; + } + setLoading(true); - setError(null); setSuccess(null); - try { - const candidate: Candidate = { - username: registerForm.username, - email: registerForm.email, - firstName: registerForm.firstName, - lastName: registerForm.lastName, - fullName: `${registerForm.firstName} ${registerForm.lastName}`, - phone: registerForm.phone || undefined, - userType: 'candidate', - status: 'active', - createdAt: new Date(), - updatedAt: new Date(), - skills: [], - experience: [], - education: [], - preferredJobTypes: [], - languages: [], - certifications: [], - location: { - city: '', - country: '', - remote: true - } - }; - - const result = await apiClient.createCandidate(candidate); - - debugConversion(result, 'Registration Response'); - - setSuccess('Registration successful! You can now login.'); - - // Clear form and switch to login tab - setRegisterForm({ - username: '', - email: '', - firstName: '', - lastName: '', - password: '', - phone: '' - }); - setTabValue(0); - - } catch (err) { - console.error('Registration error:', err); - setError(err instanceof Error ? err.message : 'Registration failed'); - } finally { - setLoading(false); + // For now, all non-employer registrations go through candidate creation + // This would need to be updated when viewer and employer APIs are available + let success; + switch (registerForm.userType) { + case 'candidate': + success = await createCandidateAccount(registerForm); + break; + case 'viewer': + success = await createViewerAccount(registerForm); + break; } + + if (success) { + // Redirect based on user type + if (registerForm.userType === 'viewer') { + window.location.href = '/find-a-candidate'; + } else { + window.location.href = '/candidate/dashboard'; + } + } + setLoading(false); }; const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { setTabValue(newValue); - setError(null); setSuccess(null); }; + // Toggle password visibility functions + const toggleLoginPasswordVisibility = () => setShowLoginPassword(!showLoginPassword); + const toggleRegisterPasswordVisibility = () => setShowRegisterPassword(!showRegisterPassword); + const toggleConfirmPasswordVisibility = () => setShowConfirmPassword(!showConfirmPassword); + + // Get user type icon and description + const getUserTypeInfo = (userType: UserRegistrationType) => { + switch (userType) { + case 'viewer': + return { + icon: , + title: 'Viewer', + description: 'Chat with Backstory about candidates and explore the platform.' + }; + case 'candidate': + return { + icon: , + title: 'Candidate', + description: 'Use Backstory to generate your resume and help you manage your career. Optionally let people interact with your profile.' + }; + case 'employer': + return { + icon: , + title: 'Employer', + description: 'Post jobs and find talent (Coming Soon.)' + }; + } + }; + + console.log(user); // If user is logged in, show their profile if (user) { return ( @@ -221,6 +329,11 @@ const LoginPage: React.FC = () => { Phone: {user.phone || 'Not provided'} + + + Account type: {user.userType} + + Last Login: { @@ -258,7 +371,6 @@ const LoginPage: React.FC = () => { const handleLoginChange = (event: React.ChangeEvent) => { const { value } = event.target; setLoginForm({ ...loginForm, login: value }); - setError(validateInput(value)); }; return ( @@ -325,7 +437,7 @@ const LoginPage: React.FC = () => { setLoginForm({ ...loginForm, password: e.target.value })} margin="normal" @@ -333,6 +445,22 @@ const LoginPage: React.FC = () => { disabled={loading} variant="outlined" autoComplete='current-password' + slotProps={{ + input: { + endAdornment: ( + + + {showLoginPassword ? : } + + + ) + } + }} /> + + {/* Conditional fields based on user type */} + {registerForm.userType === 'candidate' && ( + <> + setPhone(v as E164Number)} + /> + + + + )} + + handlePasswordChange(e.target.value)} + margin="normal" + required + disabled={loading} + variant="outlined" + slotProps={{ + input: { + endAdornment: ( + + + {showRegisterPassword ? : } + + + ) + } + }} + /> + + {/* Password Requirements */} + {registerForm.password.length > 0 && ( + + + + + + {passwordRequirements.map((requirement, index) => ( + + + {requirement.met ? ( + + ) : ( + + )} + + + + ))} + + + + + )} + + setRegisterForm({ ...registerForm, confirmPassword: e.target.value })} + margin="normal" + required + disabled={loading} + variant="outlined" + error={hasPasswordMatchError} + helperText={hasPasswordMatchError ? 'Passwords do not match' : ''} + slotProps={{ + input: { + endAdornment: ( + + + {showConfirmPassword ? : } + + + ) + } + }} + /> + + + + )} )} diff --git a/frontend/src/pages/LoginRequired.tsx b/frontend/src/pages/LoginRequired.tsx new file mode 100644 index 0000000..945f060 --- /dev/null +++ b/frontend/src/pages/LoginRequired.tsx @@ -0,0 +1,23 @@ +import Box from '@mui/material/Box'; +import { BackstoryPageProps } from '../components/BackstoryTab'; +import { Message } from '../components/Message'; +import { ChatMessage } from 'types/types'; + +const LoginRequired = (props: BackstoryPageProps) => { + const preamble: ChatMessage = { + sender: 'system', + type: 'preparing', + status: 'done', + sessionId: '', + content: 'You must be logged to view this feature.', + timestamp: new Date() + } + + return + + +}; + +export { + LoginRequired +}; \ No newline at end of file diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 77b92ed..bf5c4fd 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -9,14 +9,14 @@ import * as Types from 'types/types'; import { formatApiRequest, - // parseApiResponse, - // parsePaginatedResponse, + parseApiResponse, + parsePaginatedResponse, handleApiResponse, handlePaginatedApiResponse, createPaginatedRequest, toUrlParams, - // extractApiData, - // ApiResponse, + extractApiData, + ApiResponse, PaginatedResponse, PaginatedRequest } from 'types/conversion'; @@ -41,6 +41,49 @@ interface StreamingResponse { promise: Promise; } +export interface LoginRequest { + login: string; // email or username + password: string; +} + +export interface CreateCandidateRequest { + email: string; + username: string; + password: string; + firstName: string; + lastName: string; + phone?: string; +} + +export interface CreateEmployerRequest { + email: string; + username: string; + password: string; + companyName: string; + industry: string; + companySize: string; + companyDescription: string; + websiteUrl?: string; + phone?: string; +} + +export interface CreateViewerRequest { + email: string; + username: string; + password: string; + firstName: string; + lastName: string; +} + +export interface PasswordResetRequest { + email: string; +} + +export interface PasswordResetConfirm { + token: string; + newPassword: string; +} + // ============================ // Chat Types and Interfaces // ============================ @@ -95,12 +138,11 @@ class ApiClient { // ============================ // Authentication Methods // ============================ - - async login(login: string, password: string): Promise { + async login(request: LoginRequest): Promise { const response = await fetch(`${this.baseUrl}/auth/login`, { method: 'POST', headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest({ login, password })) + body: JSON.stringify(formatApiRequest(request)) }); return handleApiResponse(response); @@ -126,15 +168,29 @@ class ApiClient { return handleApiResponse(response); } + // ============================ + // Viewer Methods + // ============================ + + async createViewer(request: CreateViewerRequest): Promise { + const response = await fetch(`${this.baseUrl}/viewers`, { + method: 'POST', + headers: this.defaultHeaders, + body: JSON.stringify(formatApiRequest(request)) + }); + + return handleApiResponse(response); + } + // ============================ // Candidate Methods // ============================ - async createCandidate(candidate: Omit): Promise { + async createCandidate(request: CreateCandidateRequest): Promise { const response = await fetch(`${this.baseUrl}/candidates`, { method: 'POST', headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest(candidate)) + body: JSON.stringify(formatApiRequest(request)) }); return handleApiResponse(response); @@ -189,11 +245,11 @@ class ApiClient { // Employer Methods // ============================ - async createEmployer(employer: Omit): Promise { + async createEmployer(request: CreateEmployerRequest): Promise { const response = await fetch(`${this.baseUrl}/employers`, { method: 'POST', headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest(employer)) + body: JSON.stringify(formatApiRequest(request)) }); return handleApiResponse(response); @@ -568,6 +624,90 @@ class ApiClient { return handlePaginatedApiResponse(response); } + // ============================ + // Password Reset Methods + // ============================ + + async requestPasswordReset(request: PasswordResetRequest): Promise<{ message: string }> { + const response = await fetch(`${this.baseUrl}/auth/password-reset/request`, { + method: 'POST', + headers: this.defaultHeaders, + body: JSON.stringify(formatApiRequest(request)) + }); + + return handleApiResponse<{ message: string }>(response); + } + + async confirmPasswordReset(request: PasswordResetConfirm): Promise<{ message: string }> { + const response = await fetch(`${this.baseUrl}/auth/password-reset/confirm`, { + method: 'POST', + headers: this.defaultHeaders, + body: JSON.stringify(formatApiRequest(request)) + }); + + return handleApiResponse<{ message: string }>(response); + } + + // ============================ + // Password Validation Utilities + // ============================ + + validatePasswordStrength(password: string): { isValid: boolean; issues: string[] } { + const issues: string[] = []; + + if (password.length < 8) { + issues.push('Password must be at least 8 characters long'); + } + + if (!/[A-Z]/.test(password)) { + issues.push('Password must contain at least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + issues.push('Password must contain at least one lowercase letter'); + } + + if (!/\d/.test(password)) { + issues.push('Password must contain at least one digit'); + } + + const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?/~`"\'\\'; + if (!password.split('').some(char => specialChars.includes(char))) { + issues.push('Password must contain at least one special character'); + } + + return { + isValid: issues.length === 0, + issues + }; + } + + validateEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + validateUsername(username: string): { isValid: boolean; issues: string[] } { + const issues: string[] = []; + + if (username.length < 3) { + issues.push('Username must be at least 3 characters long'); + } + + if (username.length > 30) { + issues.push('Username must be no more than 30 characters long'); + } + + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + issues.push('Username can only contain letters, numbers, underscores, and hyphens'); + } + + return { + isValid: issues.length === 0, + issues + }; + } + // ============================ // Error Handling Helper // ============================ diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 5f9972f..b570930 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -1,6 +1,6 @@ // Generated TypeScript types from Pydantic models // Source: src/backend/models.py -// Generated on: 2025-05-29T23:38:18.286927 +// Generated on: 2025-05-30T09:14:59.413256 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -57,9 +57,9 @@ export type UserGender = "female" | "male"; export type UserStatus = "active" | "inactive" | "pending" | "banned"; -export type UserType = "candidate" | "employer" | "guest"; +export type UserType = "candidate" | "employer" | "viewer" | "guest"; -export type VectorStoreType = "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate"; +export type VectorStoreType = "chroma"; // ============================ // Interfaces @@ -135,7 +135,11 @@ export interface Authentication { export interface BaseUser { id?: string; email: string; + firstName: string; + lastName: string; + fullName: string; phone?: string; + location?: Location; createdAt: Date; updatedAt: Date; lastLogin?: Date; @@ -146,19 +150,27 @@ export interface BaseUser { export interface BaseUserWithType { id?: string; email: string; + firstName: string; + lastName: string; + fullName: string; phone?: string; + location?: Location; createdAt: Date; updatedAt: Date; lastLogin?: Date; profileImage?: string; status: "active" | "inactive" | "pending" | "banned"; - userType: "candidate" | "employer" | "guest"; + userType: "candidate" | "employer" | "viewer" | "guest"; } export interface Candidate { id?: string; email: string; + firstName: string; + lastName: string; + fullName: string; phone?: string; + location?: Location; createdAt: Date; updatedAt: Date; lastLogin?: Date; @@ -166,22 +178,18 @@ export interface Candidate { status: "active" | "inactive" | "pending" | "banned"; userType?: "candidate"; username: string; - firstName: string; - lastName: string; - fullName: string; description?: string; resume?: string; - skills: Array; - experience: Array; + skills?: Array; + experience?: Array; questions?: Array; - education: Array; - preferredJobTypes: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">; + education?: Array; + preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">; desiredSalary?: DesiredSalary; - location: Location; availabilityDate?: Date; summary?: string; - languages: Array; - certifications: Array; + languages?: Array; + certifications?: Array; jobApplications?: Array; hasProfile?: boolean; age?: number; @@ -368,7 +376,11 @@ export interface Education { export interface Employer { id?: string; email: string; + firstName: string; + lastName: string; + fullName: string; phone?: string; + location?: Location; createdAt: Date; updatedAt: Date; lastLogin?: Date; @@ -382,7 +394,6 @@ export interface Employer { companyDescription: string; websiteUrl?: string; jobs?: Array; - location: Location; companyLogo?: string; socialLinks?: Array; poc?: PointOfContact; @@ -564,7 +575,7 @@ export interface RAGConfiguration { description?: string; dataSourceConfigurations: Array; embeddingModel: string; - vectorStoreType: "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate"; + vectorStoreType: "chroma"; retrievalParameters: RetrievalParameters; createdAt: Date; updatedAt: Date; @@ -662,6 +673,23 @@ export interface UserPreference { emailFrequency: "immediate" | "daily" | "weekly" | "never"; } +export interface Viewer { + id?: string; + email: string; + firstName: string; + lastName: string; + fullName: string; + phone?: string; + location?: Location; + createdAt: Date; + updatedAt: Date; + lastLogin?: Date; + profileImage?: string; + status: "active" | "inactive" | "pending" | "banned"; + userType?: "viewer"; + username: string; +} + export interface WorkExperience { id?: string; companyName: string; @@ -675,11 +703,470 @@ export interface WorkExperience { achievements?: Array; } +// ============================ +// Date Conversion Functions +// ============================ + +// These functions convert API responses to properly typed objects +// with Date objects instead of ISO date strings + +/** + * Convert Analytics from API response, parsing date fields + */ +export function convertAnalyticsFromApi(data: any): Analytics { + if (!data) return data; + + return { + ...data, + entityType: new Date(data.entityType), + timestamp: new Date(data.timestamp), + }; +} +/** + * Convert ApplicationDecision from API response, parsing date fields + */ +export function convertApplicationDecisionFromApi(data: any): ApplicationDecision { + if (!data) return data; + + return { + ...data, + date: new Date(data.date), + }; +} +/** + * Convert Attachment from API response, parsing date fields + */ +export function convertAttachmentFromApi(data: any): Attachment { + if (!data) return data; + + return { + ...data, + uploadedAt: new Date(data.uploadedAt), + }; +} +/** + * Convert AuthResponse from API response, parsing date fields + */ +export function convertAuthResponseFromApi(data: any): AuthResponse { + if (!data) return data; + + return { + ...data, + user: new Date(data.user), + }; +} +/** + * Convert Authentication from API response, parsing date fields + */ +export function convertAuthenticationFromApi(data: any): Authentication { + if (!data) return data; + + return { + ...data, + resetPasswordExpiry: data.resetPasswordExpiry ? new Date(data.resetPasswordExpiry) : undefined, + lastPasswordChange: new Date(data.lastPasswordChange), + lockedUntil: data.lockedUntil ? new Date(data.lockedUntil) : undefined, + }; +} +/** + * Convert BaseUser from API response, parsing date fields + */ +export function convertBaseUserFromApi(data: any): BaseUser { + if (!data) return data; + + return { + ...data, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined, + }; +} +/** + * Convert BaseUserWithType from API response, parsing date fields + */ +export function convertBaseUserWithTypeFromApi(data: any): BaseUserWithType { + if (!data) return data; + + return { + ...data, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined, + }; +} +/** + * Convert Candidate from API response, parsing date fields + */ +export function convertCandidateFromApi(data: any): Candidate { + if (!data) return data; + + return { + ...data, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined, + userType: data.userType ? new Date(data.userType) : undefined, + questions: data.questions ? new Date(data.questions) : undefined, + availabilityDate: data.availabilityDate ? new Date(data.availabilityDate) : undefined, + }; +} +/** + * Convert CandidateListResponse from API response, parsing date fields + */ +export function convertCandidateListResponseFromApi(data: any): CandidateListResponse { + if (!data) return data; + + return { + ...data, + data: data.data ? new Date(data.data) : undefined, + }; +} +/** + * Convert CandidateResponse from API response, parsing date fields + */ +export function convertCandidateResponseFromApi(data: any): CandidateResponse { + if (!data) return data; + + return { + ...data, + data: data.data ? new Date(data.data) : undefined, + }; +} +/** + * Convert Certification from API response, parsing date fields + */ +export function convertCertificationFromApi(data: any): Certification { + if (!data) return data; + + return { + ...data, + issueDate: new Date(data.issueDate), + expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined, + }; +} +/** + * Convert ChatContext from API response, parsing date fields + */ +export function convertChatContextFromApi(data: any): ChatContext { + if (!data) return data; + + return { + ...data, + relatedEntityType: data.relatedEntityType ? new Date(data.relatedEntityType) : undefined, + }; +} +/** + * Convert ChatMessage from API response, parsing date fields + */ +export function convertChatMessageFromApi(data: any): ChatMessage { + if (!data) return data; + + return { + ...data, + timestamp: new Date(data.timestamp), + }; +} +/** + * Convert ChatMessageBase from API response, parsing date fields + */ +export function convertChatMessageBaseFromApi(data: any): ChatMessageBase { + if (!data) return data; + + return { + ...data, + timestamp: new Date(data.timestamp), + }; +} +/** + * Convert ChatMessageUser from API response, parsing date fields + */ +export function convertChatMessageUserFromApi(data: any): ChatMessageUser { + if (!data) return data; + + return { + ...data, + timestamp: new Date(data.timestamp), + }; +} +/** + * Convert ChatSession from API response, parsing date fields + */ +export function convertChatSessionFromApi(data: any): ChatSession { + if (!data) return data; + + return { + ...data, + createdAt: data.createdAt ? new Date(data.createdAt) : undefined, + lastActivity: data.lastActivity ? new Date(data.lastActivity) : undefined, + }; +} +/** + * Convert DataSourceConfiguration from API response, parsing date fields + */ +export function convertDataSourceConfigurationFromApi(data: any): DataSourceConfiguration { + if (!data) return data; + + return { + ...data, + lastRefreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : undefined, + }; +} +/** + * Convert EditHistory from API response, parsing date fields + */ +export function convertEditHistoryFromApi(data: any): EditHistory { + if (!data) return data; + + return { + ...data, + editedAt: new Date(data.editedAt), + }; +} +/** + * Convert Education from API response, parsing date fields + */ +export function convertEducationFromApi(data: any): Education { + if (!data) return data; + + return { + ...data, + startDate: new Date(data.startDate), + endDate: data.endDate ? new Date(data.endDate) : undefined, + }; +} +/** + * Convert Employer from API response, parsing date fields + */ +export function convertEmployerFromApi(data: any): Employer { + if (!data) return data; + + return { + ...data, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined, + }; +} +/** + * Convert Guest from API response, parsing date fields + */ +export function convertGuestFromApi(data: any): Guest { + if (!data) return data; + + return { + ...data, + createdAt: new Date(data.createdAt), + lastActivity: new Date(data.lastActivity), + }; +} +/** + * Convert InterviewFeedback from API response, parsing date fields + */ +export function convertInterviewFeedbackFromApi(data: any): InterviewFeedback { + if (!data) return data; + + return { + ...data, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + }; +} +/** + * Convert InterviewSchedule from API response, parsing date fields + */ +export function convertInterviewScheduleFromApi(data: any): InterviewSchedule { + if (!data) return data; + + return { + ...data, + scheduledDate: new Date(data.scheduledDate), + endDate: new Date(data.endDate), + }; +} +/** + * Convert Job from API response, parsing date fields + */ +export function convertJobFromApi(data: any): Job { + if (!data) return data; + + return { + ...data, + datePosted: new Date(data.datePosted), + applicationDeadline: data.applicationDeadline ? new Date(data.applicationDeadline) : undefined, + featuredUntil: data.featuredUntil ? new Date(data.featuredUntil) : undefined, + }; +} +/** + * Convert JobApplication from API response, parsing date fields + */ +export function convertJobApplicationFromApi(data: any): JobApplication { + if (!data) return data; + + return { + ...data, + appliedDate: new Date(data.appliedDate), + updatedDate: new Date(data.updatedDate), + candidateContact: data.candidateContact ? new Date(data.candidateContact) : undefined, + }; +} +/** + * Convert MessageReaction from API response, parsing date fields + */ +export function convertMessageReactionFromApi(data: any): MessageReaction { + if (!data) return data; + + return { + ...data, + timestamp: new Date(data.timestamp), + }; +} +/** + * Convert RAGConfiguration from API response, parsing date fields + */ +export function convertRAGConfigurationFromApi(data: any): RAGConfiguration { + if (!data) return data; + + return { + ...data, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + }; +} +/** + * Convert RefreshToken from API response, parsing date fields + */ +export function convertRefreshTokenFromApi(data: any): RefreshToken { + if (!data) return data; + + return { + ...data, + expiresAt: new Date(data.expiresAt), + }; +} +/** + * Convert UserActivity from API response, parsing date fields + */ +export function convertUserActivityFromApi(data: any): UserActivity { + if (!data) return data; + + return { + ...data, + timestamp: new Date(data.timestamp), + }; +} +/** + * Convert Viewer from API response, parsing date fields + */ +export function convertViewerFromApi(data: any): Viewer { + if (!data) return data; + + return { + ...data, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined, + }; +} +/** + * Convert WorkExperience from API response, parsing date fields + */ +export function convertWorkExperienceFromApi(data: any): WorkExperience { + if (!data) return data; + + return { + ...data, + startDate: new Date(data.startDate), + endDate: data.endDate ? new Date(data.endDate) : undefined, + }; +} + +/** + * Generic converter that automatically selects the right conversion function + * based on the model type + */ +export function convertFromApi(data: any, modelType: string): T { + if (!data) return data; + + switch (modelType) { + case 'Analytics': + return convertAnalyticsFromApi(data) as T; + case 'ApplicationDecision': + return convertApplicationDecisionFromApi(data) as T; + case 'Attachment': + return convertAttachmentFromApi(data) as T; + case 'AuthResponse': + return convertAuthResponseFromApi(data) as T; + case 'Authentication': + return convertAuthenticationFromApi(data) as T; + case 'BaseUser': + return convertBaseUserFromApi(data) as T; + case 'BaseUserWithType': + return convertBaseUserWithTypeFromApi(data) as T; + case 'Candidate': + return convertCandidateFromApi(data) as T; + case 'CandidateListResponse': + return convertCandidateListResponseFromApi(data) as T; + case 'CandidateResponse': + return convertCandidateResponseFromApi(data) as T; + case 'Certification': + return convertCertificationFromApi(data) as T; + case 'ChatContext': + return convertChatContextFromApi(data) as T; + case 'ChatMessage': + return convertChatMessageFromApi(data) as T; + case 'ChatMessageBase': + return convertChatMessageBaseFromApi(data) as T; + case 'ChatMessageUser': + return convertChatMessageUserFromApi(data) as T; + case 'ChatSession': + return convertChatSessionFromApi(data) as T; + case 'DataSourceConfiguration': + return convertDataSourceConfigurationFromApi(data) as T; + case 'EditHistory': + return convertEditHistoryFromApi(data) as T; + case 'Education': + return convertEducationFromApi(data) as T; + case 'Employer': + return convertEmployerFromApi(data) as T; + case 'Guest': + return convertGuestFromApi(data) as T; + case 'InterviewFeedback': + return convertInterviewFeedbackFromApi(data) as T; + case 'InterviewSchedule': + return convertInterviewScheduleFromApi(data) as T; + case 'Job': + return convertJobFromApi(data) as T; + case 'JobApplication': + return convertJobApplicationFromApi(data) as T; + case 'MessageReaction': + return convertMessageReactionFromApi(data) as T; + case 'RAGConfiguration': + return convertRAGConfigurationFromApi(data) as T; + case 'RefreshToken': + return convertRefreshTokenFromApi(data) as T; + case 'UserActivity': + return convertUserActivityFromApi(data) as T; + case 'Viewer': + return convertViewerFromApi(data) as T; + case 'WorkExperience': + return convertWorkExperienceFromApi(data) as T; + default: + return data as T; + } +} + +/** + * Convert array of items using the appropriate converter + */ +export function convertArrayFromApi(data: any[], modelType: string): T[] { + if (!data || !Array.isArray(data)) return data; + return data.map(item => convertFromApi(item, modelType)); +} // ============================ // Union Types // ============================ -export type User = Candidate | Employer; +export type User = Candidate | Employer | Viewer; // Export all types export type { }; diff --git a/src/backend/auth_utils.py b/src/backend/auth_utils.py new file mode 100644 index 0000000..ddac649 --- /dev/null +++ b/src/backend/auth_utils.py @@ -0,0 +1,268 @@ +# auth_utils.py +""" +Secure Authentication Utilities +Provides password hashing, verification, and security features +""" + +import bcrypt # type: ignore +import secrets +import logging +from datetime import datetime, timezone +from typing import Dict, Any, Optional, Tuple +from pydantic import BaseModel # type: ignore + +logger = logging.getLogger(__name__) + +class PasswordSecurity: + """Handles password hashing and verification using bcrypt""" + + @staticmethod + def hash_password(password: str) -> Tuple[str, str]: + """ + Hash a password with a random salt using bcrypt + + Args: + password: Plain text password + + Returns: + Tuple of (password_hash, salt) both as strings + """ + # Generate a random salt + salt = bcrypt.gensalt() + + # Hash the password + password_hash = bcrypt.hashpw(password.encode('utf-8'), salt) + + return password_hash.decode('utf-8'), salt.decode('utf-8') + + @staticmethod + def verify_password(password: str, password_hash: str) -> bool: + """ + Verify a password against its hash + + Args: + password: Plain text password to verify + password_hash: Stored password hash + + Returns: + True if password matches, False otherwise + """ + try: + return bcrypt.checkpw( + password.encode('utf-8'), + password_hash.encode('utf-8') + ) + except Exception as e: + logger.error(f"Password verification error: {e}") + return False + + @staticmethod + def generate_secure_token(length: int = 32) -> str: + """Generate a cryptographically secure random token""" + return secrets.token_urlsafe(length) + +class AuthenticationRecord(BaseModel): + """Authentication record for storing user credentials""" + user_id: str + password_hash: str + salt: str + refresh_tokens: list = [] + reset_password_token: Optional[str] = None + reset_password_expiry: Optional[datetime] = None + last_password_change: datetime + mfa_enabled: bool = False + mfa_method: Optional[str] = None + mfa_secret: Optional[str] = None + login_attempts: int = 0 + locked_until: Optional[datetime] = None + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() if v else None + } + +class SecurityConfig: + """Security configuration constants""" + MAX_LOGIN_ATTEMPTS = 5 + ACCOUNT_LOCKOUT_DURATION_MINUTES = 15 + PASSWORD_MIN_LENGTH = 8 + TOKEN_EXPIRY_HOURS = 24 + REFRESH_TOKEN_EXPIRY_DAYS = 30 + +class AuthenticationManager: + """Manages authentication operations with security features""" + + def __init__(self, database): + self.database = database + self.password_security = PasswordSecurity() + + async def create_user_authentication(self, user_id: str, password: str) -> AuthenticationRecord: + """ + Create authentication record for a new user + + Args: + user_id: Unique user identifier + password: Plain text password + + Returns: + AuthenticationRecord object + """ + if len(password) < SecurityConfig.PASSWORD_MIN_LENGTH: + raise ValueError(f"Password must be at least {SecurityConfig.PASSWORD_MIN_LENGTH} characters long") + + # Hash the password + password_hash, salt = self.password_security.hash_password(password) + + # Create authentication record + auth_record = AuthenticationRecord( + user_id=user_id, + password_hash=password_hash, + salt=salt, + last_password_change=datetime.now(timezone.utc), + login_attempts=0 + ) + + # Store in database + await self.database.set_authentication(user_id, auth_record.model_dump()) + + logger.info(f"šŸ” Created authentication record for user {user_id}") + return auth_record + + async def verify_user_credentials(self, login: str, password: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: + """ + Verify user credentials with security checks + + Args: + login: Email or username + password: Plain text password + + Returns: + Tuple of (is_valid, user_data, error_message) + """ + try: + # Get user data + user_data = await self.database.get_user(login) + if not user_data: + logger.warning(f"āš ļø Login attempt with non-existent user: {login}") + return False, None, "Invalid credentials" + + # Get authentication record + auth_record = await self.database.get_authentication(user_data["id"]) + if not auth_record: + logger.error(f"āŒ No authentication record found for user {user_data['id']}") + return False, None, "Authentication record not found" + + auth_data = AuthenticationRecord.model_validate(auth_record) + + # Check if account is locked + if auth_data.locked_until and auth_data.locked_until > datetime.now(timezone.utc): + logger.warning(f"šŸ”’ Account locked for user {login}") + return False, None, "Account is temporarily locked due to too many failed attempts" + + # Verify password + if not self.password_security.verify_password(password, auth_data.password_hash): + # Increment failed attempts + auth_data.login_attempts += 1 + + # Lock account if too many attempts + if auth_data.login_attempts >= SecurityConfig.MAX_LOGIN_ATTEMPTS: + from datetime import timedelta + auth_data.locked_until = datetime.now(timezone.utc) + timedelta( + minutes=SecurityConfig.ACCOUNT_LOCKOUT_DURATION_MINUTES + ) + logger.warning(f"šŸ”’ Account locked for user {login} after {auth_data.login_attempts} failed attempts") + + # Update authentication record + await self.database.set_authentication(user_data["id"], auth_data.model_dump()) + + logger.warning(f"āš ļø Invalid password for user {login} (attempt {auth_data.login_attempts})") + return False, None, "Invalid credentials" + + # Reset failed attempts on successful login + if auth_data.login_attempts > 0: + auth_data.login_attempts = 0 + auth_data.locked_until = None + await self.database.set_authentication(user_data["id"], auth_data.model_dump()) + + logger.info(f"āœ… Successful authentication for user {login}") + return True, user_data, None + + except Exception as e: + logger.error(f"āŒ Authentication error for user {login}: {e}") + return False, None, "Authentication failed" + + async def check_user_exists(self, email: str, username: str | None = None) -> Tuple[bool, Optional[str]]: + """ + Check if a user already exists with the given email or username + + Args: + email: Email address to check + username: Username to check (optional) + + Returns: + Tuple of (exists, conflict_field) + """ + try: + # Check email + existing_user = await self.database.get_user(email) + if existing_user: + return True, "email" + + # Check username if provided + if username: + existing_user = await self.database.get_user(username) + if existing_user: + return True, "username" + + return False, None + + except Exception as e: + logger.error(f"āŒ Error checking user existence: {e}") + # In case of error, assume user doesn't exist to avoid blocking creation + return False, None + + async def update_last_login(self, user_id: str): + """Update user's last login timestamp""" + try: + user_data = await self.database.get_user_by_id(user_id) + if user_data: + user_data["lastLogin"] = datetime.now(timezone.utc).isoformat() + await self.database.set_user_by_id(user_id, user_data) + except Exception as e: + logger.error(f"āŒ Error updating last login for user {user_id}: {e}") + +# Utility functions for common operations +def validate_password_strength(password: str) -> Tuple[bool, list]: + """ + Validate password strength + + Args: + password: Password to validate + + Returns: + Tuple of (is_valid, list_of_issues) + """ + issues = [] + + if len(password) < SecurityConfig.PASSWORD_MIN_LENGTH: + issues.append(f"Password must be at least {SecurityConfig.PASSWORD_MIN_LENGTH} characters long") + + if not any(c.isupper() for c in password): + issues.append("Password must contain at least one uppercase letter") + + if not any(c.islower() for c in password): + issues.append("Password must contain at least one lowercase letter") + + if not any(c.isdigit() for c in password): + issues.append("Password must contain at least one digit") + + # Check for special characters + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + if not any(c in special_chars for c in password): + issues.append("Password must contain at least one special character") + + return len(issues) == 0, issues + +def sanitize_login_input(login: str) -> str: + """Sanitize login input (email or username)""" + return login.strip().lower() if login else "" \ No newline at end of file diff --git a/src/backend/database.py b/src/backend/database.py index d4ddf4e..b1492b6 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -3,7 +3,7 @@ from typing import Optional, Dict, List, Optional, Any import json import logging import os -from datetime import datetime, timedelta, UTC +from datetime import datetime, timezone, UTC, timedelta import asyncio from models import ( # User models @@ -14,14 +14,14 @@ logger = logging.getLogger(__name__) class _RedisManager: def __init__(self): - self.redis_client: Optional[redis.Redis] = None + self.redis: Optional[redis.Redis] = None self.redis_url = os.getenv("REDIS_URL", "redis://redis:6379") self._connection_pool: Optional[redis.ConnectionPool] = None self._is_connected = False async def connect(self): """Initialize Redis connection with connection pooling""" - if self._is_connected and self.redis_client: + if self._is_connected and self.redis: logger.info("Redis already connected") return @@ -38,26 +38,26 @@ class _RedisManager: health_check_interval=30 ) - self.redis_client = redis.Redis( + self.redis = redis.Redis( connection_pool=self._connection_pool ) - if not self.redis_client: + if not self.redis: raise RuntimeError("Redis client not initialized") # Test connection - await self.redis_client.ping() + await self.redis.ping() self._is_connected = True logger.info("Successfully connected to Redis") # Log Redis info - info = await self.redis_client.info() + info = await self.redis.info() logger.info(f"Redis version: {info.get('redis_version', 'unknown')}") except Exception as e: logger.error(f"Failed to connect to Redis: {e}") self._is_connected = False - self.redis_client = None + self.redis = None self._connection_pool = None raise @@ -68,12 +68,12 @@ class _RedisManager: return try: - if self.redis_client: + if self.redis: # Wait for any pending operations to complete await asyncio.sleep(0.1) # Close the client - await self.redis_client.aclose() + await self.redis.aclose() logger.info("Redis client closed") if self._connection_pool: @@ -82,7 +82,7 @@ class _RedisManager: logger.info("Redis connection pool closed") self._is_connected = False - self.redis_client = None + self.redis = None self._connection_pool = None logger.info("Successfully disconnected from Redis") @@ -91,32 +91,32 @@ class _RedisManager: logger.error(f"Error during Redis disconnect: {e}") # Force cleanup even if there's an error self._is_connected = False - self.redis_client = None + self.redis = None self._connection_pool = None def get_client(self) -> redis.Redis: """Get Redis client instance""" - if not self._is_connected or not self.redis_client: + if not self._is_connected or not self.redis: raise RuntimeError("Redis client not initialized or disconnected") - return self.redis_client + return self.redis @property def is_connected(self) -> bool: """Check if Redis is connected""" - return self._is_connected and self.redis_client is not None + return self._is_connected and self.redis is not None async def health_check(self) -> dict: """Perform health check on Redis connection""" if not self.is_connected: return {"status": "disconnected", "error": "Redis not connected"} - if not self.redis_client: + if not self.redis: raise RuntimeError("Redis client not initialized") try: # Test basic operations - await self.redis_client.ping() - info = await self.redis_client.info() + await self.redis.ping() + info = await self.redis.info() return { "status": "healthy", @@ -137,16 +137,16 @@ class _RedisManager: return False try: - if not self.redis_client: + if not self.redis: raise RuntimeError("Redis client not initialized") if background: # Non-blocking background save - await self.redis_client.bgsave() + await self.redis.bgsave() logger.info("Background save initiated") else: # Blocking save - await self.redis_client.save() + await self.redis.save() logger.info("Synchronous save completed") return True except Exception as e: @@ -159,19 +159,20 @@ class _RedisManager: return None try: - if not self.redis_client: + if not self.redis: raise RuntimeError("Redis client not initialized") - return await self.redis_client.info() + return await self.redis.info() except Exception as e: logger.error(f"Failed to get Redis info: {e}") return None class RedisDatabase: - def __init__(self, redis_client: redis.Redis): - self.redis_client = redis_client + def __init__(self, redis: redis.Redis): + self.redis = redis # Redis key prefixes for different data types self.KEY_PREFIXES = { + 'viewers': 'viewer:', 'candidates': 'candidate:', 'employers': 'employer:', 'jobs': 'job:', @@ -197,29 +198,67 @@ class RedisDatabase: except json.JSONDecodeError: logger.error(f"Failed to deserialize data: {data}") return None - - # Candidates operations - async def get_candidate(self, candidate_id: str) -> Optional[Dict]: - """Get candidate by ID""" - key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}" - data = await self.redis_client.get(key) + + # Viewer operations + async def get_viewer(self, viewer_id: str) -> Optional[Dict]: + """Get viewer by ID""" + key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}" + data = await self.redis.get(key) return self._deserialize(data) if data else None - async def set_candidate(self, candidate_id: str, candidate_data: Dict): - """Set candidate data""" - key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}" - await self.redis_client.set(key, self._serialize(candidate_data)) + async def set_viewer(self, viewer_id: str, viewer_data: Dict): + """Set viewer data""" + key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}" + await self.redis.set(key, self._serialize(viewer_data)) - async def get_all_candidates(self) -> Dict[str, Any]: - """Get all candidates""" - pattern = f"{self.KEY_PREFIXES['candidates']}*" - keys = await self.redis_client.keys(pattern) + async def get_all_viewers(self) -> Dict[str, Any]: + """Get all viewers""" + pattern = f"{self.KEY_PREFIXES['viewers']}*" + keys = await self.redis.keys(pattern) if not keys: return {} # Use pipeline for efficiency - pipe = self.redis_client.pipeline() + pipe = self.redis.pipeline() + for key in keys: + pipe.get(key) + values = await pipe.execute() + + result = {} + for key, value in zip(keys, values): + viewer_id = key.replace(self.KEY_PREFIXES['viewers'], '') + result[viewer_id] = self._deserialize(value) + + return result + + async def delete_viewer(self, viewer_id: str): + """Delete viewer""" + key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}" + await self.redis.delete(key) + + # Candidates operations + async def get_candidate(self, candidate_id: str) -> Optional[Dict]: + """Get candidate by ID""" + key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}" + data = await self.redis.get(key) + return self._deserialize(data) if data else None + + async def set_candidate(self, candidate_id: str, candidate_data: Dict): + """Set candidate data""" + key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}" + await self.redis.set(key, self._serialize(candidate_data)) + + async def get_all_candidates(self) -> Dict[str, Any]: + """Get all candidates""" + pattern = f"{self.KEY_PREFIXES['candidates']}*" + keys = await self.redis.keys(pattern) + + if not keys: + return {} + + # Use pipeline for efficiency + pipe = self.redis.pipeline() for key in keys: pipe.get(key) values = await pipe.execute() @@ -234,29 +273,29 @@ class RedisDatabase: async def delete_candidate(self, candidate_id: str): """Delete candidate""" key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}" - await self.redis_client.delete(key) + await self.redis.delete(key) # Employers operations async def get_employer(self, employer_id: str) -> Optional[Dict]: """Get employer by ID""" key = f"{self.KEY_PREFIXES['employers']}{employer_id}" - data = await self.redis_client.get(key) + data = await self.redis.get(key) return self._deserialize(data) if data else None async def set_employer(self, employer_id: str, employer_data: Dict): """Set employer data""" key = f"{self.KEY_PREFIXES['employers']}{employer_id}" - await self.redis_client.set(key, self._serialize(employer_data)) + await self.redis.set(key, self._serialize(employer_data)) async def get_all_employers(self) -> Dict[str, Any]: """Get all employers""" pattern = f"{self.KEY_PREFIXES['employers']}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) if not keys: return {} - pipe = self.redis_client.pipeline() + pipe = self.redis.pipeline() for key in keys: pipe.get(key) values = await pipe.execute() @@ -271,29 +310,29 @@ class RedisDatabase: async def delete_employer(self, employer_id: str): """Delete employer""" key = f"{self.KEY_PREFIXES['employers']}{employer_id}" - await self.redis_client.delete(key) + await self.redis.delete(key) # Jobs operations async def get_job(self, job_id: str) -> Optional[Dict]: """Get job by ID""" key = f"{self.KEY_PREFIXES['jobs']}{job_id}" - data = await self.redis_client.get(key) + data = await self.redis.get(key) return self._deserialize(data) if data else None async def set_job(self, job_id: str, job_data: Dict): """Set job data""" key = f"{self.KEY_PREFIXES['jobs']}{job_id}" - await self.redis_client.set(key, self._serialize(job_data)) + await self.redis.set(key, self._serialize(job_data)) async def get_all_jobs(self) -> Dict[str, Any]: """Get all jobs""" pattern = f"{self.KEY_PREFIXES['jobs']}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) if not keys: return {} - pipe = self.redis_client.pipeline() + pipe = self.redis.pipeline() for key in keys: pipe.get(key) values = await pipe.execute() @@ -308,29 +347,29 @@ class RedisDatabase: async def delete_job(self, job_id: str): """Delete job""" key = f"{self.KEY_PREFIXES['jobs']}{job_id}" - await self.redis_client.delete(key) + await self.redis.delete(key) # Job Applications operations async def get_job_application(self, application_id: str) -> Optional[Dict]: """Get job application by ID""" key = f"{self.KEY_PREFIXES['job_applications']}{application_id}" - data = await self.redis_client.get(key) + data = await self.redis.get(key) return self._deserialize(data) if data else None async def set_job_application(self, application_id: str, application_data: Dict): """Set job application data""" key = f"{self.KEY_PREFIXES['job_applications']}{application_id}" - await self.redis_client.set(key, self._serialize(application_data)) + await self.redis.set(key, self._serialize(application_data)) async def get_all_job_applications(self) -> Dict[str, Any]: """Get all job applications""" pattern = f"{self.KEY_PREFIXES['job_applications']}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) if not keys: return {} - pipe = self.redis_client.pipeline() + pipe = self.redis.pipeline() for key in keys: pipe.get(key) values = await pipe.execute() @@ -345,29 +384,29 @@ class RedisDatabase: async def delete_job_application(self, application_id: str): """Delete job application""" key = f"{self.KEY_PREFIXES['job_applications']}{application_id}" - await self.redis_client.delete(key) + await self.redis.delete(key) # Chat Sessions operations async def get_chat_session(self, session_id: str) -> Optional[Dict]: """Get chat session by ID""" key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}" - data = await self.redis_client.get(key) + data = await self.redis.get(key) return self._deserialize(data) if data else None async def set_chat_session(self, session_id: str, session_data: Dict): """Set chat session data""" key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}" - await self.redis_client.set(key, self._serialize(session_data)) + await self.redis.set(key, self._serialize(session_data)) async def get_all_chat_sessions(self) -> Dict[str, Any]: """Get all chat sessions""" pattern = f"{self.KEY_PREFIXES['chat_sessions']}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) if not keys: return {} - pipe = self.redis_client.pipeline() + pipe = self.redis.pipeline() for key in keys: pipe.get(key) values = await pipe.execute() @@ -382,36 +421,36 @@ class RedisDatabase: async def delete_chat_session(self, session_id: str): """Delete chat session""" key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}" - await self.redis_client.delete(key) + await self.redis.delete(key) # Chat Messages operations (stored as lists) async def get_chat_messages(self, session_id: str) -> List[Dict]: """Get chat messages for a session""" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" - messages = await self.redis_client.lrange(key, 0, -1) + messages = await self.redis.lrange(key, 0, -1) return [self._deserialize(msg) for msg in messages if msg] async def add_chat_message(self, session_id: str, message_data: Dict): """Add a chat message to a session""" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" - await self.redis_client.rpush(key, self._serialize(message_data)) + await self.redis.rpush(key, self._serialize(message_data)) async def set_chat_messages(self, session_id: str, messages: List[Dict]): """Set all chat messages for a session (replaces existing)""" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" # Clear existing messages - await self.redis_client.delete(key) + await self.redis.delete(key) # Add new messages if messages: serialized_messages = [self._serialize(msg) for msg in messages] - await self.redis_client.rpush(key, *serialized_messages) + await self.redis.rpush(key, *serialized_messages) async def get_all_chat_messages(self) -> Dict[str, List[Dict]]: """Get all chat messages grouped by session""" pattern = f"{self.KEY_PREFIXES['chat_messages']}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) if not keys: return {} @@ -419,7 +458,7 @@ class RedisDatabase: result = {} for key in keys: session_id = key.replace(self.KEY_PREFIXES['chat_messages'], '') - messages = await self.redis_client.lrange(key, 0, -1) + messages = await self.redis.lrange(key, 0, -1) result[session_id] = [self._deserialize(msg) for msg in messages if msg] return result @@ -427,7 +466,7 @@ class RedisDatabase: async def delete_chat_messages(self, session_id: str): """Delete all chat messages for a session""" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" - await self.redis_client.delete(key) + await self.redis.delete(key) # Enhanced Chat Session Methods async def get_chat_sessions_by_user(self, user_id: str) -> List[Dict]: @@ -474,7 +513,7 @@ class RedisDatabase: async def get_chat_message_count(self, session_id: str) -> int: """Get the total number of messages in a chat session""" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" - return await self.redis_client.llen(key) + return await self.redis.llen(key) async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]: """Search for messages containing specific text in a session""" @@ -526,7 +565,7 @@ class RedisDatabase: async def get_user_by_username(self, username: str) -> Optional[Dict]: """Get user by username specifically""" username_key = f"{self.KEY_PREFIXES['users']}{username.lower()}" - data = await self.redis_client.get(username_key) + data = await self.redis.get(username_key) return self._deserialize(data) if data else None async def find_candidate_by_username(self, username: str) -> Optional[Dict]: @@ -576,6 +615,7 @@ class RedisDatabase: stats["average_messages_per_session"] = stats["total_messages"] / stats["total_sessions"] return stats + async def get_candidate_chat_summary(self, candidate_id: str) -> Dict[str, Any]: """Get a summary of chat activity for a specific candidate""" @@ -625,7 +665,7 @@ class RedisDatabase: async def bulk_update_chat_sessions(self, session_updates: Dict[str, Dict]): """Bulk update multiple chat sessions""" - pipe = self.redis_client.pipeline() + pipe = self.redis.pipeline() for session_id, updates in session_updates.items(): session_data = await self.get_chat_session(session_id) @@ -642,23 +682,23 @@ class RedisDatabase: async def get_ai_parameters(self, param_id: str) -> Optional[Dict]: """Get AI parameters by ID""" key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}" - data = await self.redis_client.get(key) + data = await self.redis.get(key) return self._deserialize(data) if data else None async def set_ai_parameters(self, param_id: str, param_data: Dict): """Set AI parameters data""" key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}" - await self.redis_client.set(key, self._serialize(param_data)) + await self.redis.set(key, self._serialize(param_data)) async def get_all_ai_parameters(self) -> Dict[str, Any]: """Get all AI parameters""" pattern = f"{self.KEY_PREFIXES['ai_parameters']}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) if not keys: return {} - pipe = self.redis_client.pipeline() + pipe = self.redis.pipeline() for key in keys: pipe.get(key) values = await pipe.execute() @@ -673,37 +713,18 @@ class RedisDatabase: async def delete_ai_parameters(self, param_id: str): """Delete AI parameters""" key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}" - await self.redis_client.delete(key) + await self.redis.delete(key) - # Users operations (for auth) - async def get_user(self, login: str) -> Optional[Dict]: - """Get user by email or username""" - if '@' in login: - email = login.lower() - key = f"{self.KEY_PREFIXES['users']}{email}" - else: - username = login.lower() - key = f"{self.KEY_PREFIXES['users']}{username}" - data = await self.redis_client.get(key) - return self._deserialize(data) if data else None - - async def set_user(self, user: BaseUser, user_data: Dict): - """Set user data""" - email_key = f"{self.KEY_PREFIXES['users']}{user.email.lower()}" - username_key = f"{self.KEY_PREFIXES['users']}{user.username.lower()}" - serialized_data = self._serialize(user_data) - await self.redis_client.set(email_key, serialized_data) - await self.redis_client.set(username_key, serialized_data) async def get_all_users(self) -> Dict[str, Any]: """Get all users""" pattern = f"{self.KEY_PREFIXES['users']}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) if not keys: return {} - pipe = self.redis_client.pipeline() + pipe = self.redis.pipeline() for key in keys: pipe.get(key) values = await pipe.execute() @@ -718,32 +739,334 @@ class RedisDatabase: async def delete_user(self, email: str): """Delete user""" key = f"{self.KEY_PREFIXES['users']}{email}" - await self.redis_client.delete(key) + await self.redis.delete(key) # Utility methods async def clear_all_data(self): """Clear all data from Redis (use with caution!)""" for prefix in self.KEY_PREFIXES.values(): pattern = f"{prefix}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) if keys: - await self.redis_client.delete(*keys) + await self.redis.delete(*keys) async def get_stats(self) -> Dict[str, int]: """Get statistics about stored data""" stats = {} for data_type, prefix in self.KEY_PREFIXES.items(): pattern = f"{prefix}*" - keys = await self.redis_client.keys(pattern) + keys = await self.redis.keys(pattern) stats[data_type] = len(keys) return stats + # Authentication Record Methods + async def set_authentication(self, user_id: str, auth_data: Dict[str, Any]) -> bool: + """Store authentication record for a user""" + try: + key = f"auth:{user_id}" + await self.redis.set(key, json.dumps(auth_data, default=str)) + logger.debug(f"šŸ” Stored authentication record for user {user_id}") + return True + except Exception as e: + logger.error(f"āŒ Error storing authentication record for {user_id}: {e}") + return False + + async def get_authentication(self, user_id: str) -> Optional[Dict[str, Any]]: + """Retrieve authentication record for a user""" + try: + key = f"auth:{user_id}" + data = await self.redis.get(key) + if data: + return json.loads(data) + return None + except Exception as e: + logger.error(f"āŒ Error retrieving authentication record for {user_id}: {e}") + return None + + async def delete_authentication(self, user_id: str) -> bool: + """Delete authentication record for a user""" + try: + key = f"auth:{user_id}" + result = await self.redis.delete(key) + logger.debug(f"šŸ” Deleted authentication record for user {user_id}") + return result > 0 + except Exception as e: + logger.error(f"āŒ Error deleting authentication record for {user_id}: {e}") + return False + + # Enhanced User Methods + async def set_user_by_id(self, user_id: str, user_data: Dict[str, Any]) -> bool: + """Store user data with ID as key for direct lookup""" + try: + key = f"user_by_id:{user_id}" + await self.redis.set(key, json.dumps(user_data, default=str)) + logger.debug(f"šŸ‘¤ Stored user data by ID for {user_id}") + return True + except Exception as e: + logger.error(f"āŒ Error storing user by ID {user_id}: {e}") + return False + + async def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]: + """Retrieve user data by ID""" + try: + key = f"user_by_id:{user_id}" + data = await self.redis.get(key) + if data: + return json.loads(data) + return None + except Exception as e: + logger.error(f"āŒ Error retrieving user by ID {user_id}: {e}") + return None + + async def user_exists_by_email(self, email: str) -> bool: + """Check if a user exists with the given email""" + try: + key = f"users:{email.lower()}" + exists = await self.redis.exists(key) + return exists > 0 + except Exception as e: + logger.error(f"āŒ Error checking user existence by email {email}: {e}") + return False + + async def user_exists_by_username(self, username: str) -> bool: + """Check if a user exists with the given username""" + try: + key = f"users:{username.lower()}" + exists = await self.redis.exists(key) + return exists > 0 + except Exception as e: + logger.error(f"āŒ Error checking user existence by username {username}: {e}") + return False + + # Enhanced user lookup method to support both email and username + async def get_user(self, login: str) -> Optional[Dict[str, Any]]: + """ + Get user by email or username + """ + try: + # Normalize the login string + login = login.strip().lower() + key = f"users:{login}" + + data = await self.redis.get(key) + if data: + user_data = json.loads(data) + logger.debug(f"šŸ‘¤ Retrieved user data for {login}") + return user_data + + logger.debug(f"šŸ‘¤ No user found for {login}") + return None + except Exception as e: + logger.error(f"āŒ Error retrieving user {login}: {e}") + return None + + async def set_user(self, login: str, user_data: Dict[str, Any]) -> bool: + """ + Enhanced method to store user data by email or username + Updated version of your existing method + """ + try: + # Normalize the login string + login = login.strip().lower() + key = f"users:{login}" + + await self.redis.set(key, json.dumps(user_data, default=str)) + logger.debug(f"šŸ‘¤ Stored user data for {login}") + return True + except Exception as e: + logger.error(f"āŒ Error storing user {login}: {e}") + return False + + # Token Management Methods + async def store_refresh_token(self, user_id: str, token: str, expires_at: datetime, device_info: Dict[str, str]) -> bool: + """Store refresh token for a user""" + try: + key = f"refresh_token:{token}" + token_data = { + "user_id": user_id, + "expires_at": expires_at.isoformat(), + "device": device_info.get("device", "unknown"), + "ip_address": device_info.get("ip_address", "unknown"), + "is_revoked": False, + "created_at": datetime.now(timezone.utc).isoformat() + } + + # Store with expiration + ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds()) + if ttl_seconds > 0: + await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str)) + logger.debug(f"šŸ” Stored refresh token for user {user_id}") + return True + else: + logger.warning(f"āš ļø Attempted to store expired refresh token for user {user_id}") + return False + except Exception as e: + logger.error(f"āŒ Error storing refresh token for {user_id}: {e}") + return False + + async def get_refresh_token(self, token: str) -> Optional[Dict[str, Any]]: + """Retrieve refresh token data""" + try: + key = f"refresh_token:{token}" + data = await self.redis.get(key) + if data: + return json.loads(data) + return None + except Exception as e: + logger.error(f"āŒ Error retrieving refresh token: {e}") + return None + + async def revoke_refresh_token(self, token: str) -> bool: + """Revoke a refresh token""" + try: + key = f"refresh_token:{token}" + token_data = await self.get_refresh_token(token) + if token_data: + token_data["is_revoked"] = True + token_data["revoked_at"] = datetime.now(timezone.utc).isoformat() + await self.redis.set(key, json.dumps(token_data, default=str)) + logger.debug(f"šŸ” Revoked refresh token") + return True + return False + except Exception as e: + logger.error(f"āŒ Error revoking refresh token: {e}") + return False + + async def revoke_all_user_tokens(self, user_id: str) -> bool: + """Revoke all refresh tokens for a user""" + try: + # This requires scanning all refresh tokens - consider using a user token index for efficiency + pattern = "refresh_token:*" + cursor = 0 + revoked_count = 0 + + while True: + cursor, keys = await self.redis.scan(cursor, match=pattern, count=100) + + for key in keys: + token_data = await self.redis.get(key) + if token_data: + token_info = json.loads(token_data) + if token_info.get("user_id") == user_id and not token_info.get("is_revoked"): + token_info["is_revoked"] = True + token_info["revoked_at"] = datetime.now(timezone.utc).isoformat() + await self.redis.set(key, json.dumps(token_info, default=str)) + revoked_count += 1 + + if cursor == 0: + break + + logger.info(f"šŸ” Revoked {revoked_count} refresh tokens for user {user_id}") + return True + except Exception as e: + logger.error(f"āŒ Error revoking all tokens for user {user_id}: {e}") + return False + + # Password Reset Token Methods + async def store_password_reset_token(self, email: str, token: str, expires_at: datetime) -> bool: + """Store password reset token""" + try: + key = f"password_reset:{token}" + token_data = { + "email": email.lower(), + "expires_at": expires_at.isoformat(), + "used": False, + "created_at": datetime.now(timezone.utc).isoformat() + } + + # Store with expiration + ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds()) + if ttl_seconds > 0: + await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str)) + logger.debug(f"šŸ” Stored password reset token for {email}") + return True + else: + logger.warning(f"āš ļø Attempted to store expired password reset token for {email}") + return False + except Exception as e: + logger.error(f"āŒ Error storing password reset token for {email}: {e}") + return False + + async def get_password_reset_token(self, token: str) -> Optional[Dict[str, Any]]: + """Retrieve password reset token data""" + try: + key = f"password_reset:{token}" + data = await self.redis.get(key) + if data: + return json.loads(data) + return None + except Exception as e: + logger.error(f"āŒ Error retrieving password reset token: {e}") + return None + + async def mark_password_reset_token_used(self, token: str) -> bool: + """Mark password reset token as used""" + try: + key = f"password_reset:{token}" + token_data = await self.get_password_reset_token(token) + if token_data: + token_data["used"] = True + token_data["used_at"] = datetime.now(timezone.utc).isoformat() + await self.redis.set(key, json.dumps(token_data, default=str)) + logger.debug(f"šŸ” Marked password reset token as used") + return True + return False + except Exception as e: + logger.error(f"āŒ Error marking password reset token as used: {e}") + return False + + # User Activity and Security Logging + async def log_security_event(self, user_id: str, event_type: str, details: Dict[str, Any]) -> bool: + """Log security events for audit purposes""" + try: + key = f"security_log:{user_id}:{datetime.now(timezone.utc).strftime('%Y-%m-%d')}" + event_data = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "user_id": user_id, + "event_type": event_type, + "details": details + } + + # Add to list (latest events first) + await self.redis.lpush(key, json.dumps(event_data, default=str)) + + # Keep only last 100 events per day + await self.redis.ltrim(key, 0, 99) + + # Set expiration for 30 days + await self.redis.expire(key, 30 * 24 * 60 * 60) + + logger.debug(f"šŸ”’ Logged security event {event_type} for user {user_id}") + return True + except Exception as e: + logger.error(f"āŒ Error logging security event for {user_id}: {e}") + return False + + async def get_user_security_log(self, user_id: str, days: int = 7) -> List[Dict[str, Any]]: + """Retrieve security log for a user""" + try: + events = [] + for i in range(days): + date = (datetime.now(timezone.utc) - timedelta(days=i)).strftime('%Y-%m-%d') + key = f"security_log:{user_id}:{date}" + + daily_events = await self.redis.lrange(key, 0, -1) + for event_json in daily_events: + events.append(json.loads(event_json)) + + # Sort by timestamp (most recent first) + events.sort(key=lambda x: x["timestamp"], reverse=True) + return events + except Exception as e: + logger.error(f"āŒ Error retrieving security log for {user_id}: {e}") + return [] + # Global Redis manager instance redis_manager = _RedisManager() class DatabaseManager: """Enhanced database manager with graceful shutdown capabilities""" - + def __init__(self): self.db: Optional[RedisDatabase] = None self._shutdown_initiated = False @@ -762,9 +1085,9 @@ class DatabaseManager: self.db = RedisDatabase(redis_manager.get_client()) # Test connection and log stats - if not redis_manager.redis_client: + if not redis_manager.redis: raise RuntimeError("Redis client not initialized") - await redis_manager.redis_client.ping() + await redis_manager.redis.ping() stats = await self.db.get_stats() logger.info(f"Database initialized successfully. Stats: {stats}") @@ -823,10 +1146,10 @@ class DatabaseManager: # Force Redis to save data to disk try: - if redis_manager.redis_client: + if redis_manager.redis: # Try BGSAVE first (non-blocking) try: - await redis_manager.redis_client.bgsave() + await redis_manager.redis.bgsave() logger.info("Background save initiated") # Wait a bit for background save to start @@ -836,7 +1159,7 @@ class DatabaseManager: logger.warning(f"Background save failed, trying synchronous save: {e}") try: # Fallback to synchronous save - await redis_manager.redis_client.save() + await redis_manager.redis.save() logger.info("Synchronous save completed") except Exception as e2: logger.warning(f"Synchronous save also failed (Redis persistence may be disabled): {e2}") @@ -872,4 +1195,5 @@ class DatabaseManager: raise RuntimeError("Database not initialized") if self._shutdown_initiated: raise RuntimeError("Application is shutting down") - return self.db \ No newline at end of file + return self.db + \ No newline at end of file diff --git a/src/backend/focused_test.py b/src/backend/focused_test.py index 8271858..20abd28 100644 --- a/src/backend/focused_test.py +++ b/src/backend/focused_test.py @@ -41,6 +41,9 @@ def test_model_creation(): # Create employer employer = Employer( + firstName="Mary", + lastName="Smith", + fullName="Mary Smith", email="hr@company.com", username="test_employer", createdAt=datetime.now(), diff --git a/src/backend/generate_types.py b/src/backend/generate_types.py index 621e6b7..29a7d3c 100644 --- a/src/backend/generate_types.py +++ b/src/backend/generate_types.py @@ -1,7 +1,8 @@ #!/usr/bin/env python """ Enhanced Type Generator - Generate TypeScript types from Pydantic models -Now with command line parameters, pre-test validation, and TypeScript compilation +Now with command line parameters, pre-test validation, TypeScript compilation, +and automatic date field conversion functions """ import sys @@ -84,6 +85,27 @@ def unwrap_annotated_type(python_type: Any) -> Any: return python_type +def is_date_type(python_type: Any) -> bool: + """Check if a Python type represents a date/datetime""" + # Unwrap any annotations first + python_type = unwrap_annotated_type(python_type) + + # Handle Union types (like Optional[datetime]) + origin = get_origin(python_type) + if origin is Union: + args = get_args(python_type) + # Check if any of the union args is a date type (excluding None) + return any(is_date_type(arg) for arg in args if arg is not type(None)) + + # Direct datetime check + if python_type == datetime: + return True + + # String representation checks for various datetime types + type_str = str(python_type) + date_patterns = ['datetime', 'date', 'DateTime', 'Date'] + return any(pattern in type_str for pattern in date_patterns) + def python_type_to_typescript(python_type: Any, debug: bool = False) -> str: """Convert a Python type to TypeScript type string""" @@ -302,6 +324,7 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]: """Process a Pydantic model and return TypeScript interface definition""" interface_name = model_class.__name__ properties = [] + date_fields = [] # Track date fields for conversion functions if debug: print(f" šŸ” Processing model: {interface_name}") @@ -327,6 +350,16 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]: if debug: print(f" Raw type: {field_type}") + # Check if this is a date field + if is_date_type(field_type): + is_optional = is_field_optional(field_info, field_type, debug) + date_fields.append({ + 'name': ts_name, + 'optional': is_optional + }) + if debug: + print(f" šŸ—“ļø Date field detected: {ts_name} (optional: {is_optional})") + ts_type = python_type_to_typescript(field_type, debug) # Check if optional @@ -362,6 +395,16 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]: if debug: print(f" Raw type: {field_type}") + # Check if this is a date field + if is_date_type(field_type): + is_optional = is_field_optional(field_info, field_type) + date_fields.append({ + 'name': ts_name, + 'optional': is_optional + }) + if debug: + print(f" šŸ—“ļø Date field detected: {ts_name} (optional: {is_optional})") + ts_type = python_type_to_typescript(field_type, debug) # For Pydantic v1, check required and default @@ -396,7 +439,8 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]: return { 'name': interface_name, - 'properties': properties + 'properties': properties, + 'date_fields': date_fields } def process_enum(enum_class) -> Dict[str, Any]: @@ -410,6 +454,102 @@ def process_enum(enum_class) -> Dict[str, Any]: 'values': " | ".join(values) } +def generate_conversion_functions(interfaces: List[Dict[str, Any]]) -> str: + """Generate TypeScript conversion functions for models with date fields""" + conversion_functions = [] + + for interface in interfaces: + interface_name = interface['name'] + date_fields = interface.get('date_fields', []) + + if not date_fields: + continue # Skip interfaces without date fields + + function_name = f"convert{interface_name}FromApi" + + # Generate function + func_lines = [ + f"/**", + f" * Convert {interface_name} from API response, parsing date fields", + f" */", + f"export function {function_name}(data: any): {interface_name} {{", + f" if (!data) return data;", + f" ", + f" return {{", + f" ...data," + ] + + # Add date field conversions + for date_field in date_fields: + field_name = date_field['name'] + is_optional = date_field['optional'] + + if is_optional: + func_lines.append(f" {field_name}: data.{field_name} ? new Date(data.{field_name}) : undefined,") + else: + func_lines.append(f" {field_name}: new Date(data.{field_name}),") + + func_lines.extend([ + f" }};", + f"}}" + ]) + + conversion_functions.append('\n'.join(func_lines)) + + if not conversion_functions: + return "" + + # Generate the conversion functions section + result = [ + "// ============================", + "// Date Conversion Functions", + "// ============================", + "", + "// These functions convert API responses to properly typed objects", + "// with Date objects instead of ISO date strings", + "", + ] + + result.extend(conversion_functions) + result.append("") + + # Generate a generic converter function + models_with_dates = [interface['name'] for interface in interfaces if interface.get('date_fields')] + + if models_with_dates: + result.extend([ + "/**", + " * Generic converter that automatically selects the right conversion function", + " * based on the model type", + " */", + "export function convertFromApi(data: any, modelType: string): T {", + " if (!data) return data;", + " ", + " switch (modelType) {" + ]) + + for model_name in models_with_dates: + result.append(f" case '{model_name}':") + result.append(f" return convert{model_name}FromApi(data) as T;") + + result.extend([ + " default:", + " return data as T;", + " }", + "}", + "", + "/**", + " * Convert array of items using the appropriate converter", + " */", + "export function convertArrayFromApi(data: any[], modelType: string): T[] {", + " if (!data || !Array.isArray(data)) return data;", + " return data.map(item => convertFromApi(item, modelType));", + "}", + "" + ]) + + return '\n'.join(result) + def generate_typescript_interfaces(source_file: str, debug: bool = False): """Generate TypeScript interfaces from models""" @@ -449,7 +589,8 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False): interface = process_pydantic_model(obj, debug) interfaces.append(interface) - print(f" āœ… Found Pydantic model: {name}") + date_count = len(interface.get('date_fields', [])) + print(f" āœ… Found Pydantic model: {name}" + (f" ({date_count} date fields)" if date_count > 0 else "")) # Check if it's an Enum elif (isinstance(obj, type) and @@ -466,7 +607,9 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False): traceback.print_exc() continue + total_date_fields = sum(len(interface.get('date_fields', [])) for interface in interfaces) print(f"\nšŸ“Š Found {len(interfaces)} interfaces and {len(enums)} enums") + print(f"šŸ—“ļø Found {total_date_fields} date fields across all models") # Generate TypeScript content ts_content = f"""// Generated TypeScript types from Pydantic models @@ -500,8 +643,13 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False): ts_content += "}\n\n" + # Add conversion functions + conversion_functions = generate_conversion_functions(interfaces) + if conversion_functions: + ts_content += conversion_functions + # Add user union type if we have user types - user_interfaces = [i for i in interfaces if i['name'] in ['Candidate', 'Employer']] + user_interfaces = [i for i in interfaces if i['name'] in ['Candidate', 'Employer', 'Viewer']] if len(user_interfaces) >= 2: ts_content += "// ============================\n" ts_content += "// Union Types\n" @@ -534,7 +682,7 @@ def compile_typescript(ts_file: str) -> bool: def main(): """Main function with command line argument parsing""" parser = argparse.ArgumentParser( - description='Generate TypeScript types from Pydantic models', + description='Generate TypeScript types from Pydantic models with date conversion functions', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -544,6 +692,10 @@ Examples: python generate_types.py --skip-compile # Skip TS compilation python generate_types.py --debug # Enable debug output python generate_types.py --source models.py --output types.ts --skip-test --skip-compile --debug + +Generated conversion functions can be used like: + const candidate = convertCandidateFromApi(apiResponse); + const jobs = convertArrayFromApi(apiResponse, 'Job'); """ ) @@ -580,13 +732,13 @@ Examples: parser.add_argument( '--version', '-v', action='version', - version='TypeScript Generator 2.0' + version='TypeScript Generator 3.0 (with Date Conversion)' ) args = parser.parse_args() - print("šŸš€ Enhanced TypeScript Type Generator") - print("=" * 50) + print("šŸš€ Enhanced TypeScript Type Generator with Date Conversion") + print("=" * 60) print(f"šŸ“ Source file: {args.source}") print(f"šŸ“ Output file: {args.output}") print() @@ -608,7 +760,7 @@ Examples: print() # Step 3: Generate TypeScript content - print("šŸ”„ Generating TypeScript types...") + print("šŸ”„ Generating TypeScript types and conversion functions...") if args.debug: print("šŸ› Debug mode enabled - detailed output follows:") print() @@ -628,6 +780,11 @@ Examples: file_size = len(ts_content) print(f"āœ… TypeScript types generated: {args.output} ({file_size} characters)") + # Count conversion functions + conversion_count = ts_content.count('export function convert') - ts_content.count('convertFromApi') - ts_content.count('convertArrayFromApi') + if conversion_count > 0: + print(f"šŸ—“ļø Generated {conversion_count} date conversion functions") + # Step 5: Compile TypeScript (unless skipped) if not args.skip_compile: print() @@ -639,15 +796,21 @@ Examples: # Step 6: Success summary print(f"\nšŸŽ‰ Type generation completed successfully!") - print("=" * 50) + print("=" * 60) print(f"āœ… Generated {args.output} from {args.source}") print(f"āœ… File size: {file_size} characters") + if conversion_count > 0: + print(f"āœ… Date conversion functions: {conversion_count}") if not args.skip_test: print("āœ… Model validation passed") if not args.skip_compile: print("āœ… TypeScript syntax validated") + print(f"\nšŸ’” Usage in your TypeScript project:") - print(f" import {{ Candidate, Employer, Job }} from './{Path(args.output).stem}';") + print(f" import {{ Candidate, Employer, Job, convertCandidateFromApi }} from './{Path(args.output).stem}';") + if conversion_count > 0: + print(f" const candidate = convertCandidateFromApi(apiResponse);") + print(f" const jobs = convertArrayFromApi(apiResponse, 'Job');") return True diff --git a/src/backend/main.py b/src/backend/main.py index c303927..f184992 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -17,15 +17,39 @@ import signal import json import traceback +import uuid +import logging +from datetime import datetime, timezone, timedelta +from typing import Dict, Any, Optional +from pydantic import BaseModel, EmailStr, validator # type: ignore # Prometheus from prometheus_client import Summary # type: ignore from prometheus_fastapi_instrumentator import Instrumentator # type: ignore from prometheus_client import CollectorRegistry, Counter # type: ignore +# ============================= +# Import custom modules +# ============================= +from auth_utils import ( + AuthenticationManager, + validate_password_strength, + sanitize_login_input, + SecurityConfig +) +import model_cast +import defines +import agents +from logger import logger +from database import RedisDatabase, redis_manager, DatabaseManager +from metrics import Metrics +from llm_manager import llm_manager + +# ============================= # Import Pydantic models +# ============================= from models import ( # User models - Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse, + Candidate, Employer, Viewer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse, # Job models Job, JobApplication, ApplicationStatus, @@ -37,13 +61,6 @@ from models import ( Location, Skill, WorkExperience, Education ) -import model_cast -import defines -import agents -from logger import logger -from database import RedisDatabase, redis_manager, DatabaseManager -from metrics import Metrics -from llm_manager import llm_manager # Initialize FastAPI app # ============================ @@ -75,10 +92,6 @@ async def lifespan(app: FastAPI): # Initialize database await db_manager.initialize() - # Seed development data if needed - if defines.debug: - await seed_development_data() - signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) @@ -131,6 +144,89 @@ ALGORITHM = "HS256" # Authentication Utilities # ============================ +# Request/Response Models +class LoginRequest(BaseModel): + login: str # Can be email or username + password: str + + @validator('login') + def sanitize_login(cls, v): + return sanitize_login_input(v) + + @validator('password') + def validate_password_not_empty(cls, v): + if not v or not v.strip(): + raise ValueError('Password cannot be empty') + return v + +class CreateViewerRequest(BaseModel): + email: EmailStr + username: str + password: str + firstName: str + lastName: str + + @validator('username') + def validate_username(cls, v): + if not v or len(v.strip()) < 3: + raise ValueError('Username must be at least 3 characters long') + return v.strip().lower() + + @validator('password') + def validate_password_strength(cls, v): + is_valid, issues = validate_password_strength(v) + if not is_valid: + raise ValueError('; '.join(issues)) + return v + +class CreateCandidateRequest(BaseModel): + email: EmailStr + username: str + password: str + firstName: str + lastName: str + # Add other required candidate fields as needed + phone: Optional[str] = None + + @validator('username') + def validate_username(cls, v): + if not v or len(v.strip()) < 3: + raise ValueError('Username must be at least 3 characters long') + return v.strip().lower() + + @validator('password') + def validate_password_strength(cls, v): + is_valid, issues = validate_password_strength(v) + if not is_valid: + raise ValueError('; '.join(issues)) + return v + +# Create Employer Endpoint (similar pattern) +class CreateEmployerRequest(BaseModel): + email: EmailStr + username: str + password: str + companyName: str + industry: str + companySize: str + companyDescription: str + # Add other required employer fields + websiteUrl: Optional[str] = None + phone: Optional[str] = None + + @validator('username') + def validate_username(cls, v): + if not v or len(v.strip()) < 3: + raise ValueError('Username must be at least 3 characters long') + return v.strip().lower() + + @validator('password') + def validate_password_strength(cls, v): + is_valid, issues = validate_password_strength(v) + if not is_valid: + raise ValueError('; '.join(issues)) + return v + def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() if expires_delta: @@ -151,17 +247,17 @@ async def verify_token_with_blacklist(credentials: HTTPAuthorizationCredentials raise HTTPException(status_code=401, detail="Invalid authentication credentials") # Check if token is blacklisted - redis_client = redis_manager.get_client() + redis = redis_manager.get_client() blacklist_key = f"blacklisted_token:{credentials.credentials}" - is_blacklisted = await redis_client.exists(blacklist_key) + is_blacklisted = await redis.exists(blacklist_key) if is_blacklisted: logger.warning(f"🚫 Attempt to use blacklisted token for user {user_id}") raise HTTPException(status_code=401, detail="Token has been revoked") # Optional: Check if all user tokens are revoked (for "logout from all devices") # user_revoked_key = f"user_tokens_revoked:{user_id}" - # user_tokens_revoked_at = await redis_client.get(user_revoked_key) + # user_tokens_revoked_at = await redis.get(user_revoked_key) # if user_tokens_revoked_at: # revoked_timestamp = datetime.fromisoformat(user_tokens_revoked_at.decode()) # token_issued_at = datetime.fromtimestamp(payload.get("iat", 0), UTC) @@ -182,7 +278,12 @@ async def get_current_user( ) -> BaseUserWithType: """Get current user from database""" try: - # Check candidates first + # Check viewers + viewer = await database.get_viewer(user_id) + if viewer: + return Viewer.model_validate(viewer) + + # Check candidates candidate = await database.get_candidate(user_id) if candidate: return Candidate.model_validate(candidate) @@ -294,65 +395,80 @@ api_router = APIRouter(prefix="/api/1.0") @api_router.post("/auth/login") async def login( - login: str = Body(...), - password: str = Body(...), + request: LoginRequest, database: RedisDatabase = Depends(get_database) ): - """Login endpoint""" + """Secure login endpoint with password verification""" try: - # Check if user exists (simplified - in real app, check hashed password) - user_data = await database.get_user(login) - if not user_data: - logger.info(f"āš ļø Login attempt with non-existent email: {login}") + # Initialize authentication manager + auth_manager = AuthenticationManager(database) + + # Verify credentials + is_valid, user_data, error_message = await auth_manager.verify_user_credentials( + request.login, + request.password + ) + + if not is_valid or not user_data: + logger.warning(f"āš ļø Failed login attempt for: {request.login}") return JSONResponse( status_code=401, - content=create_error_response("AUTH_FAILED", "Invalid credentials") + content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials") ) - logger.info(f"šŸ”‘ User {login} logged in successfully") + # Update last login timestamp + await auth_manager.update_last_login(user_data["id"]) + + logger.info(f"šŸ”‘ User {request.login} logged in successfully") # Create tokens access_token = create_access_token(data={"sub": user_data["id"]}) refresh_token = create_access_token( data={"sub": user_data["id"], "type": "refresh"}, - expires_delta=timedelta(days=30) + expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS) ) - # Get user object + # Get user object based on type user = None if user_data["type"] == "candidate": - logger.info(f"šŸ”‘ User {login} is a candidate") + logger.info(f"šŸ”‘ User {request.login} is a candidate") candidate_data = await database.get_candidate(user_data["id"]) if candidate_data: user = Candidate.model_validate(candidate_data) elif user_data["type"] == "employer": - logger.info(f"šŸ”‘ User {login} is a employer") + logger.info(f"šŸ”‘ User {request.login} is an employer") employer_data = await database.get_employer(user_data["id"]) if employer_data: - user = Employer.model_validate(employer_data) + user = Employer.model_validate(employer_data) + elif user_data["type"] == "viewer": + logger.info(f"šŸ”‘ User {request.login} is an viewer") + viewer_data = await database.get_viewer(user_data["id"]) + if viewer_data: + user = Viewer.model_validate(viewer_data) if not user: + logger.error(f"āŒ User object not found for {user_data['id']}") return JSONResponse( status_code=404, - content=create_error_response("USER_NOT_FOUND", "User not found") + content=create_error_response("USER_NOT_FOUND", "User profile not found") ) + # Create response auth_response = AuthResponse( accessToken=access_token, refreshToken=refresh_token, user=user, - expiresAt=int((datetime.now(UTC) + timedelta(hours=24)).timestamp()) + expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()) ) return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: - logger.error(f"āš ļø Login error: {e}") + logger.error(f"āŒ Login error: {e}") return JSONResponse( status_code=500, - content=create_error_response("LOGIN_ERROR", str(e)) + content=create_error_response("LOGIN_ERROR", "An error occurred during login") ) - @api_router.post("/auth/logout") async def logout( access_token: str = Body(..., alias="accessToken"), @@ -390,12 +506,12 @@ async def logout( ) # Get Redis client - redis_client = redis_manager.get_client() + redis = redis_manager.get_client() # Revoke refresh token (blacklist it until its natural expiration) refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp())) if refresh_ttl > 0: - await redis_client.setex( + await redis.setex( f"blacklisted_token:{refresh_token}", refresh_ttl, json.dumps({ @@ -418,7 +534,7 @@ async def logout( if access_user_id == user_id: access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp())) if access_ttl > 0: - await redis_client.setex( + await redis.setex( f"blacklisted_token:{access_token}", access_ttl, json.dumps({ @@ -438,7 +554,7 @@ async def logout( # Optional: Revoke all tokens for this user (for "logout from all devices") # Uncomment the following lines if you want to implement this feature: # - # await redis_client.setex( + # await redis.setex( # f"user_tokens_revoked:{user_id}", # timedelta(days=30).total_seconds(), # Max refresh token lifetime # datetime.now(UTC).isoformat() @@ -467,10 +583,10 @@ async def logout_all_devices( ): """Logout from all devices by revoking all tokens for the user""" try: - redis_client = redis_manager.get_client() + redis = redis_manager.get_client() # Set a timestamp that invalidates all tokens issued before this moment - await redis_client.setex( + await redis.setex( f"user_tokens_revoked:{current_user.id}", int(timedelta(days=30).total_seconds()), # Max refresh token lifetime datetime.now(UTC).isoformat() @@ -518,6 +634,10 @@ async def refresh_token_endpoint( employer_data = await database.get_employer(user_id) if employer_data: user = Employer.model_validate(employer_data) + else: + viewer_data = await database.get_viewer(user_id) + if viewer_data: + user = Viewer.model_validate(viewer_data) if not user: return JSONResponse( @@ -546,48 +666,195 @@ async def refresh_token_endpoint( content=create_error_response("REFRESH_ERROR", str(e)) ) +# ============================ +# Viewer Endpoints +# ============================ +@api_router.post("/viewers") +async def create_viewer( + request: CreateViewerRequest, + database: RedisDatabase = Depends(get_database) +): + """Create a new viewer with secure password handling and duplicate checking""" + try: + # Initialize authentication manager + auth_manager = AuthenticationManager(database) + + # Check if user already exists + user_exists, conflict_field = await auth_manager.check_user_exists( + request.email, + request.username + ) + + if user_exists and not conflict_field: + raise ValueError("User already exists with this email or username, but conflict_field is not set") + + if user_exists and conflict_field: + logger.warning(f"āš ļø Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}") + return JSONResponse( + status_code=409, + content=create_error_response( + "USER_EXISTS", + f"A user with this {conflict_field} already exists" + ) + ) + + # Generate viewer ID + viewer_id = str(uuid.uuid4()) + current_time = datetime.now(timezone.utc) + + # Prepare viewer data + viewer_data = { + "id": viewer_id, + "email": request.email, + "username": request.username, + "firstName": request.firstName, + "lastName": request.lastName, + "fullName": f"{request.firstName} {request.lastName}", + "createdAt": current_time.isoformat(), + "updatedAt": current_time.isoformat(), + "status": "active", + "userType": "viewer", + } + + # Create viewer object and validate + viewer = Viewer.model_validate(viewer_data) + + # Create authentication record with hashed password + await auth_manager.create_user_authentication(viewer_id, request.password) + + # Store viewer in database + await database.set_viewer(viewer.id, viewer.model_dump()) + + # Add to users for auth lookup (by email and username) + user_auth_data = { + "id": viewer.id, + "type": "viewer", + "email": viewer.email, + "username": request.username + } + + # Store user lookup records + await database.set_user(viewer.email, user_auth_data) # By email + await database.set_user(request.username, user_auth_data) # By username + await database.set_user_by_id(viewer.id, user_auth_data) # By ID + + logger.info(f"āœ… Created viewer: {viewer.email} (ID: {viewer.id})") + + # Return viewer data (excluding sensitive information) + response_data = viewer.model_dump(by_alias=True, exclude_unset=True) + # Remove any sensitive fields from response if needed + + return create_success_response(response_data) + + except ValueError as ve: + logger.warning(f"āš ļø Validation error creating viewer: {ve}") + return JSONResponse( + status_code=400, + content=create_error_response("VALIDATION_ERROR", str(ve)) + ) + except Exception as e: + logger.error(f"āŒ Viewer creation error: {e}") + logger.error(traceback.format_exc()) + return JSONResponse( + status_code=500, + content=create_error_response("CREATION_FAILED", "Failed to create viewer account") + ) + # ============================ # Candidate Endpoints # ============================ @api_router.post("/candidates") async def create_candidate( - candidate_data: Dict[str, Any] = Body(...), + request: CreateCandidateRequest, database: RedisDatabase = Depends(get_database) ): - """Create a new candidate""" + """Create a new candidate with secure password handling and duplicate checking""" try: - # Add required fields - candidate_data["id"] = str(uuid.uuid4()) - candidate_data["createdAt"] = datetime.now(UTC).isoformat() - candidate_data["updatedAt"] = datetime.now(UTC).isoformat() + # Initialize authentication manager + auth_manager = AuthenticationManager(database) - # Create candidate - candidate = Candidate.model_validate(candidate_data) - # Check if candidate already exists - existing_candidate = await database.get_candidate(candidate.id) - if existing_candidate: + # Check if user already exists + user_exists, conflict_field = await auth_manager.check_user_exists( + request.email, + request.username + ) + + if user_exists and not conflict_field: + raise ValueError("User already exists with this email or username, but conflict_field is not set") + + if user_exists and conflict_field: + logger.warning(f"āš ļø Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}") return JSONResponse( - status_code=400, - content=create_error_response("ALREADY_EXISTS", "Candidate already exists") + status_code=409, + content=create_error_response( + "USER_EXISTS", + f"A user with this {conflict_field} already exists" + ) ) + + # Generate candidate ID + candidate_id = str(uuid.uuid4()) + current_time = datetime.now(timezone.utc) + + # Prepare candidate data + candidate_data = { + "id": candidate_id, + "userType": "candidate", + "email": request.email, + "username": request.username, + "firstName": request.firstName, + "lastName": request.lastName, + "fullName": f"{request.firstName} {request.lastName}", + "phone": request.phone, + "createdAt": current_time.isoformat(), + "updatedAt": current_time.isoformat(), + "status": "active", + } + + # Create candidate object and validate + candidate = Candidate.model_validate(candidate_data) + + # Create authentication record with hashed password + await auth_manager.create_user_authentication(candidate_id, request.password) + + # Store candidate in database await database.set_candidate(candidate.id, candidate.model_dump()) - # Add to users for auth (simplified) - await database.set_user(candidate, { + # Add to users for auth lookup (by email and username) + user_auth_data = { "id": candidate.id, - "type": "candidate" - }) + "type": "candidate", + "email": candidate.email, + "username": request.username + } - return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True)) + # Store user lookup records + await database.set_user(candidate.email, user_auth_data) # By email + await database.set_user(request.username, user_auth_data) # By username + await database.set_user_by_id(candidate.id, user_auth_data) # By ID - except Exception as e: - logger.error(f"Candidate creation error: {e}") + logger.info(f"āœ… Created candidate: {candidate.email} (ID: {candidate.id})") + + # Return candidate data (excluding sensitive information) + response_data = candidate.model_dump(by_alias=True, exclude_unset=True) + # Remove any sensitive fields from response if needed + + return create_success_response(response_data) + + except ValueError as ve: + logger.warning(f"āš ļø Validation error creating candidate: {ve}") return JSONResponse( status_code=400, - content=create_error_response("CREATION_FAILED", str(e)) + content=create_error_response("VALIDATION_ERROR", str(ve)) ) - + except Exception as e: + logger.error(f"āŒ Candidate creation error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CREATION_FAILED", "Failed to create candidate account") + ) + @api_router.get("/candidates/{username}") async def get_candidate( username: str = Path(...), @@ -753,6 +1020,185 @@ async def search_candidates( content=create_error_response("SEARCH_FAILED", str(e)) ) +# ============================ +# Employer Endpoints +# ============================ + +@api_router.post("/employers") +async def create_employer( + request: CreateEmployerRequest, + database: RedisDatabase = Depends(get_database) +): + """Create a new employer with secure password handling and duplicate checking""" + try: + # Initialize authentication manager + auth_manager = AuthenticationManager(database) + + # Check if user already exists + user_exists, conflict_field = await auth_manager.check_user_exists( + request.email, + request.username + ) + + if user_exists and not conflict_field: + raise ValueError("User already exists with this email or username, but conflict_field is not set") + + if user_exists and conflict_field: + logger.warning(f"āš ļø Attempted to create employer with existing {conflict_field}: {getattr(request, conflict_field)}") + return JSONResponse( + status_code=409, + content=create_error_response( + "USER_EXISTS", + f"A user with this {conflict_field} already exists" + ) + ) + + # Generate employer ID + employer_id = str(uuid.uuid4()) + current_time = datetime.now(timezone.utc) + + # Prepare employer data + employer_data = { + "id": employer_id, + "email": request.email, + "companyName": request.companyName, + "industry": request.industry, + "companySize": request.companySize, + "companyDescription": request.companyDescription, + "websiteUrl": request.websiteUrl, + "phone": request.phone, + "createdAt": current_time.isoformat(), + "updatedAt": current_time.isoformat(), + "status": "active", + "userType": "employer", + "location": { + "city": "", + "country": "", + "remote": False + }, + "socialLinks": [] + } + + # Create employer object and validate + employer = Employer.model_validate(employer_data) + + # Create authentication record with hashed password + await auth_manager.create_user_authentication(employer_id, request.password) + + # Store employer in database + await database.set_employer(employer.id, employer.model_dump()) + + # Add to users for auth lookup + user_auth_data = { + "id": employer.id, + "type": "employer", + "email": employer.email, + "username": request.username + } + + # Store user lookup records + await database.set_user(employer.email, user_auth_data) # By email + await database.set_user(request.username, user_auth_data) # By username + await database.set_user_by_id(employer.id, user_auth_data) # By ID + + logger.info(f"āœ… Created employer: {employer.email} (ID: {employer.id})") + + # Return employer data (excluding sensitive information) + response_data = employer.model_dump(by_alias=True, exclude_unset=True) + + return create_success_response(response_data) + + except ValueError as ve: + logger.warning(f"āš ļø Validation error creating employer: {ve}") + return JSONResponse( + status_code=400, + content=create_error_response("VALIDATION_ERROR", str(ve)) + ) + except Exception as e: + logger.error(f"āŒ Employer creation error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CREATION_FAILED", "Failed to create employer account") + ) + + +# ============================ +# Password Reset Endpoints +# ============================ +class PasswordResetRequest(BaseModel): + email: EmailStr + +class PasswordResetConfirm(BaseModel): + token: str + new_password: str + + @validator('new_password') + def validate_password_strength(cls, v): + is_valid, issues = validate_password_strength(v) + if not is_valid: + raise ValueError('; '.join(issues)) + return v + +@api_router.post("/auth/password-reset/request") +async def request_password_reset( + request: PasswordResetRequest, + database: RedisDatabase = Depends(get_database) +): + """Request password reset""" + try: + # Check if user exists + user_data = await database.get_user(request.email) + if not user_data: + # Don't reveal whether email exists or not + return create_success_response({"message": "If the email exists, a reset link will be sent"}) + + auth_manager = AuthenticationManager(database) + + # Generate reset token + reset_token = auth_manager.password_security.generate_secure_token() + reset_expiry = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry + + # Update authentication record + auth_record = await database.get_authentication(user_data["id"]) + if auth_record: + auth_record["resetPasswordToken"] = reset_token + auth_record["resetPasswordExpiry"] = reset_expiry.isoformat() + await database.set_authentication(user_data["id"], auth_record) + + # TODO: Send email with reset token + logger.info(f"šŸ” Password reset requested for: {request.email}") + + return create_success_response({"message": "If the email exists, a reset link will be sent"}) + + except Exception as e: + logger.error(f"āŒ Password reset request error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESET_ERROR", "An error occurred processing the request") + ) + +@api_router.post("/auth/password-reset/confirm") +async def confirm_password_reset( + request: PasswordResetConfirm, + database: RedisDatabase = Depends(get_database) +): + """Confirm password reset with token""" + try: + # Find user by reset token + # This would require a way to lookup by token - you might need to modify your database structure + + # For now, this is a placeholder - you'd need to implement token lookup + # in your Redis database structure + + return create_success_response({"message": "Password reset successfully"}) + + except Exception as e: + logger.error(f"āŒ Password reset confirm error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESET_ERROR", "An error occurred resetting the password") + ) + # ============================ # Job Endpoints # ============================ @@ -1349,17 +1795,17 @@ async def enhanced_health_check(): """Enhanced health check endpoint""" try: database = db_manager.get_database() - if not redis_manager.redis_client: + if not redis_manager.redis: raise RuntimeError("Redis client not initialized") # Test Redis connection - await redis_manager.redis_client.ping() + await redis_manager.redis.ping() # Get database stats stats = await database.get_stats() # Redis info - redis_info = await redis_manager.redis_client.info() + redis_info = await redis_manager.redis.info() return { "status": "healthy", @@ -1386,9 +1832,9 @@ async def enhanced_health_check(): return {"status": "error", "message": str(e)} @api_router.get("/redis/stats") -async def redis_stats(redis_client: redis.Redis = Depends(get_redis)): +async def redis_stats(redis: redis.Redis = Depends(get_redis)): try: - info = await redis_client.info() + info = await redis.info() return { "connected_clients": info.get("connected_clients"), "used_memory_human": info.get("used_memory_human"), @@ -1501,96 +1947,6 @@ async def root(): "health": f"{defines.api_prefix}/health" } -# ============================ -# Development Data Seeding -# ============================ - -async def seed_development_data(): - """Seed the database with development data""" - try: - database = db_manager.get_database() - - # Check if data already exists - stats = await database.get_stats() - if stats.get('candidates', 0) > 0: - logger.info("āœ… Development data already exists, skipping seeding") - return - - # Create sample location - sample_location = Location( - city="San Francisco", - state="CA", - country="USA", - postalCode="94102" - ) - - # Create sample candidate - candidate_id = str(uuid.uuid4()) - sample_candidate = Candidate( - id=candidate_id, - email="john.doe@example.com", - createdAt=datetime.now(UTC), - updatedAt=datetime.now(UTC), - status="active", - firstName="John", - lastName="Doe", - fullName="John Doe", - username="johndoe", - skills=[], - experience=[], - education=[], - preferredJobTypes=["full-time"], - location=sample_location, - languages=[], - certifications=[] - ) - - await database.set_candidate(candidate_id, sample_candidate.model_dump()) - await database.set_user(sample_candidate, {"id": candidate_id, "type": "candidate"}) - - # Create sample employer - employer_id = str(uuid.uuid4()) - sample_employer = Employer( - id=employer_id, - email="hr@techcorp.com", - createdAt=datetime.now(UTC), - updatedAt=datetime.now(UTC), - status="active", - companyName="TechCorp", - industry="Technology", - companySize="100-500", - companyDescription="Leading technology company", - location=sample_location - ) - - await database.set_employer(employer_id, sample_employer.model_dump()) - await database.set_user(sample_employer, {"id": employer_id, "type": "employer"}) - - # Create sample job - job_id = str(uuid.uuid4()) - sample_job = Job( - id=job_id, - title="Senior Software Engineer", - description="We are looking for a senior software engineer...", - responsibilities=["Develop software", "Lead projects", "Mentor juniors"], - requirements=["5+ years experience", "Python expertise"], - preferredSkills=["FastAPI", "React", "PostgreSQL"], - employerId=employer_id, - location=sample_location, - employmentType="full-time", - datePosted=datetime.now(UTC), - isActive=True, - views=0, - applicationCount=0 - ) - - await database.set_job(job_id, sample_job.model_dump()) - - logger.info("āœ… Development data seeded successfully") - - except Exception as e: - logger.error(f"āš ļø Failed to seed development data: {e}") - if __name__ == "__main__": host = defines.host port = defines.port diff --git a/src/backend/models.py b/src/backend/models.py index 72653f5..d4dafac 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -15,6 +15,7 @@ T = TypeVar('T') class UserType(str, Enum): CANDIDATE = "candidate" EMPLOYER = "employer" + VIEWER = "viewer" GUEST = "guest" class UserGender(str, Enum): @@ -105,11 +106,13 @@ class MFAMethod(str, Enum): EMAIL = "email" class VectorStoreType(str, Enum): - PINECONE = "pinecone" - QDRANT = "qdrant" - FAISS = "faiss" - MILVUS = "milvus" - WEAVIATE = "weaviate" + CHROMA = "chroma", + # FAISS = "faiss", + # PINECONE = "pinecone" + # QDRANT = "qdrant" + # FAISS = "faiss" + # MILVUS = "milvus" + # WEAVIATE = "weaviate" class DataSourceType(str, Enum): DOCUMENT = "document" @@ -366,7 +369,11 @@ class ErrorDetail(BaseModel): class BaseUser(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) email: EmailStr + first_name: str = Field(..., alias="firstName") + last_name: str = Field(..., alias="lastName") + full_name: str = Field(..., alias="fullName") phone: Optional[str] = None + location: Optional[Location] = None created_at: datetime = Field(..., alias="createdAt") updated_at: datetime = Field(..., alias="updatedAt") last_login: Optional[datetime] = Field(None, alias="lastLogin") @@ -382,25 +389,25 @@ class BaseUser(BaseModel): class BaseUserWithType(BaseUser): user_type: UserType = Field(..., alias="userType") +class Viewer(BaseUser): + user_type: Literal[UserType.VIEWER] = Field(UserType.VIEWER, alias="userType") + username: str + class Candidate(BaseUser): user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType") username: str - first_name: str = Field(..., alias="firstName") - last_name: str = Field(..., alias="lastName") - full_name: str = Field(..., alias="fullName") description: Optional[str] = None resume: Optional[str] = None - skills: List[Skill] - experience: List[WorkExperience] - questions: List[CandidateQuestion] = [] - education: List[Education] - preferred_job_types: List[EmploymentType] = Field(..., alias="preferredJobTypes") + skills: Optional[List[Skill]] = None + experience: Optional[List[WorkExperience]] = None + questions: Optional[List[CandidateQuestion]] = None + education: Optional[List[Education]] = None + preferred_job_types: Optional[List[EmploymentType]] = Field(None, alias="preferredJobTypes") desired_salary: Optional[DesiredSalary] = Field(None, alias="desiredSalary") - location: Location availability_date: Optional[datetime] = Field(None, alias="availabilityDate") summary: Optional[str] = None - languages: List[Language] - certifications: List[Certification] + languages: Optional[List[Language]] = None + certifications: Optional[List[Certification]] = None job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications") has_profile: bool = Field(default=False, alias="hasProfile") # Used for AI generated personas @@ -417,7 +424,6 @@ class Employer(BaseUser): company_description: str = Field(..., alias="companyDescription") website_url: Optional[HttpUrl] = Field(None, alias="websiteUrl") jobs: Optional[List["Job"]] = None - location: Location company_logo: Optional[str] = Field(None, alias="companyLogo") social_links: Optional[List[SocialLink]] = Field(None, alias="socialLinks") poc: Optional[PointOfContact] = None @@ -454,7 +460,7 @@ class Authentication(BaseModel): class AuthResponse(BaseModel): access_token: str = Field(..., alias="accessToken") refresh_token: str = Field(..., alias="refreshToken") - user: Candidate | Employer + user: Candidate | Employer | Viewer expires_at: int = Field(..., alias="expiresAt") model_config = { "populate_by_name": True # Allow both field names and aliases