Updated UI, auth flows, and refactored
This commit is contained in:
parent
8f6c39c3f7
commit
a03497a552
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,3 +14,4 @@ chromadb/**
|
||||
chromadb-prod/**
|
||||
dev-keys/**
|
||||
**/__pycache__/**
|
||||
.vscode
|
||||
|
@ -5,10 +5,11 @@ import { backstoryTheme } from './BackstoryTheme';
|
||||
|
||||
import { SeverityType } from 'components/Snack';
|
||||
import { ConversationHandle } from 'components/Conversation';
|
||||
import { UserProvider } from 'hooks/useUser';
|
||||
import { CandidateRoute } from 'routes/CandidateRoute';
|
||||
import { BackstoryLayout } from 'components/layout/BackstoryLayout';
|
||||
import { ChatQuery } from 'types/types';
|
||||
import { AuthProvider } from 'hooks/AuthContext';
|
||||
import { AppStateProvider } from 'hooks/GlobalContext';
|
||||
|
||||
import './BackstoryApp.css';
|
||||
import '@fontsource/roboto/300.css';
|
||||
@ -39,18 +40,20 @@ const BackstoryApp = () => {
|
||||
// Render appropriate routes based on user type
|
||||
return (
|
||||
<ThemeProvider theme={backstoryTheme}>
|
||||
<UserProvider {...{ setSnack }}>
|
||||
<Routes>
|
||||
<Route path="/u/:username" element={<CandidateRoute {...{ setSnack }} />} />
|
||||
{/* Static/shared routes */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<BackstoryLayout {...{ setSnack, page, chatRef, snackRef, submitQuery }} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</UserProvider>
|
||||
<AuthProvider>
|
||||
<AppStateProvider>
|
||||
<Routes>
|
||||
<Route path="/u/:username" element={<CandidateRoute {...{ setSnack }} />} />
|
||||
{/* Static/shared routes */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<BackstoryLayout {...{ setSnack, page, chatRef, snackRef, submitQuery }} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AppStateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
@ -7,9 +7,8 @@ import {
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { useMediaQuery } from '@mui/material';
|
||||
import { useUser } from "../hooks/useUser";
|
||||
import { Candidate } from '../types/types';
|
||||
import { CopyBubble } from "./CopyBubble";
|
||||
import { Candidate } from 'types/types';
|
||||
import { CopyBubble } from "components/CopyBubble";
|
||||
import { rest } from 'lodash';
|
||||
|
||||
interface CandidateInfoProps {
|
||||
|
@ -13,12 +13,13 @@ import { DeleteConfirmation } from 'components/DeleteConfirmation';
|
||||
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
|
||||
import { BackstoryElementProps } from './BackstoryTab';
|
||||
import { connectionBase } from 'utils/Global';
|
||||
import { useUser } from "hooks/useUser";
|
||||
import { useAuth } from "hooks/AuthContext";
|
||||
import { StreamingResponse } from 'services/api-client';
|
||||
import { ChatMessage, ChatMessageBase, ChatContext, ChatSession, ChatQuery } from 'types/types';
|
||||
import { PaginatedResponse } from 'types/conversion';
|
||||
|
||||
import './Conversation.css';
|
||||
import { useSelectedCandidate } from 'hooks/GlobalContext';
|
||||
|
||||
const defaultMessage: ChatMessage = {
|
||||
type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: ""
|
||||
@ -69,7 +70,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
|
||||
sx,
|
||||
type,
|
||||
} = props;
|
||||
const { candidate, apiClient } = useUser()
|
||||
const { apiClient } = useAuth()
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
const [countdown, setCountdown] = useState<number>(0);
|
||||
const [conversation, setConversation] = useState<ChatMessage[]>([]);
|
||||
@ -77,7 +78,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
|
||||
const [filteredConversation, setFilteredConversation] = useState<ChatMessage[]>([]);
|
||||
const [processingMessage, setProcessingMessage] = useState<ChatMessage | undefined>(undefined);
|
||||
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined);
|
||||
const timerRef = useRef<any>(null);
|
||||
const [noInteractions, setNoInteractions] = useState<boolean>(true);
|
||||
const viewableElementRef = useRef<HTMLDivElement>(null);
|
||||
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
|
||||
|
@ -3,9 +3,8 @@ import Box from '@mui/material/Box';
|
||||
import PropagateLoader from 'react-spinners/PropagateLoader';
|
||||
import { Quote } from 'components/Quote';
|
||||
import { BackstoryElementProps } from 'components/BackstoryTab';
|
||||
import { useUser } from 'hooks/useUser';
|
||||
import { Candidate, ChatSession } from 'types/types';
|
||||
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
|
||||
interface GenerateImageProps extends BackstoryElementProps {
|
||||
prompt: string;
|
||||
@ -13,7 +12,7 @@ interface GenerateImageProps extends BackstoryElementProps {
|
||||
};
|
||||
|
||||
const GenerateImage = (props: GenerateImageProps) => {
|
||||
const { user } = useSecureAuth();
|
||||
const { user } = useAuth();
|
||||
const { setSnack, chatSession, prompt } = props;
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
|
@ -16,11 +16,11 @@ import { Header } from 'components/layout/Header';
|
||||
import { Scrollable } from 'components/Scrollable';
|
||||
import { Footer } from 'components/layout/Footer';
|
||||
import { Snack, SetSnackType } from 'components/Snack';
|
||||
import { useUser } from 'hooks/useUser';
|
||||
import { User } from 'types/types';
|
||||
import { getBackstoryDynamicRoutes } from 'components/layout/BackstoryRoutes';
|
||||
import { LoadingComponent } from "components/LoadingComponent";
|
||||
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||
import { AuthProvider, useAuth, ProtectedRoute } from 'hooks/AuthContext';
|
||||
import { useSelectedCandidate } from 'hooks/GlobalContext';
|
||||
|
||||
type NavigationLinkType = {
|
||||
name: string;
|
||||
@ -78,8 +78,6 @@ const getNavigationLinks = (user: User | null): NavigationLinkType[] => {
|
||||
}
|
||||
|
||||
switch (user.userType) {
|
||||
case 'viewer':
|
||||
return DefaultNavItems.concat(ViewerNavItems);
|
||||
case 'candidate':
|
||||
return DefaultNavItems.concat(CandidateNavItems);
|
||||
case 'employer':
|
||||
@ -137,8 +135,8 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
|
||||
const { setSnack, page, chatRef, snackRef, submitQuery } = props;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { guest, candidate } = useUser();
|
||||
const { user } = useSecureAuth();
|
||||
const { guest, user } = useAuth();
|
||||
const { selectedCandidate } = useSelectedCandidate();
|
||||
const [navigationLinks, setNavigationLinks] = useState<NavigationLinkType[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -157,7 +155,7 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
|
||||
|
||||
return (
|
||||
<Box sx={{ height: "100%", maxHeight: "100%", minHeight: "100%", flexDirection: "column" }}>
|
||||
<Header {...{ setSnack, guest, user, candidate, currentPath: page, navigate, navigationLinks }} />
|
||||
<Header {...{ setSnack, currentPath: page, navigate, navigationLinks }} />
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
|
@ -8,10 +8,6 @@ import {
|
||||
Button,
|
||||
IconButton,
|
||||
Box,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Drawer,
|
||||
Divider,
|
||||
Avatar,
|
||||
@ -19,6 +15,8 @@ import {
|
||||
Tab,
|
||||
Container,
|
||||
Fade,
|
||||
Popover,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import { styled, useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
@ -32,13 +30,12 @@ import {
|
||||
|
||||
import { NavigationLinkType } from 'components/layout/BackstoryLayout';
|
||||
import { Beta } from 'components/Beta';
|
||||
import { useUser } from 'hooks/useUser';
|
||||
import { Candidate, Employer, Viewer } from 'types/types';
|
||||
import { Candidate, Employer } from 'types/types';
|
||||
import { SetSnackType } from 'components/Snack';
|
||||
import { CopyBubble } from 'components/CopyBubble';
|
||||
|
||||
import 'components/layout/Header.css';
|
||||
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
|
||||
// Styled components
|
||||
const StyledAppBar = styled(AppBar, {
|
||||
@ -86,28 +83,104 @@ const MobileDrawer = styled(Drawer)(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const MobileMenuTabs = styled(Tabs)(({ theme }) => ({
|
||||
'& .MuiTabs-flexContainer': {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 48,
|
||||
textTransform: 'uppercase',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(1, 2),
|
||||
gap: theme.spacing(1),
|
||||
color: theme.palette.text.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.contrastText,
|
||||
'& .MuiTypography-root': {
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
display: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
const UserMenuContainer = styled(Paper)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxShadow: theme.shadows[8],
|
||||
overflow: 'hidden',
|
||||
minWidth: 200,
|
||||
}));
|
||||
|
||||
const UserMenuTabs = styled(Tabs)(({ theme }) => ({
|
||||
'& .MuiTabs-flexContainer': {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 48,
|
||||
textTransform: 'uppercase',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(1, 2),
|
||||
gap: theme.spacing(1),
|
||||
color: theme.palette.text.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
display: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
const MenuItemBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
width: '100%',
|
||||
'& .MuiSvgIcon-root': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
const MenuDivider = styled(Divider)(({ theme }) => ({
|
||||
margin: theme.spacing(0.5, 0),
|
||||
backgroundColor: theme.palette.divider,
|
||||
}));
|
||||
|
||||
interface HeaderProps {
|
||||
transparent?: boolean;
|
||||
className?: string;
|
||||
navigate: NavigateFunction;
|
||||
navigationLinks: NavigationLinkType[];
|
||||
showLogin?: boolean;
|
||||
currentPath: string;
|
||||
sessionId?: string | null;
|
||||
setSnack: SetSnackType,
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
const { user, logout } = useSecureAuth();
|
||||
const { user, logout } = useAuth();
|
||||
const candidate: Candidate | null = (user && user.userType === "candidate") ? user as Candidate : null;
|
||||
const employer: Employer | null = (user && user.userType === "employer") ? user as Employer : null;
|
||||
const viewer: Viewer | null = (user && user.userType === "viewer") ? user as Viewer : null;
|
||||
const {
|
||||
transparent = false,
|
||||
className,
|
||||
navigate,
|
||||
navigationLinks,
|
||||
showLogin,
|
||||
sessionId,
|
||||
setSnack,
|
||||
} = props;
|
||||
@ -146,8 +219,10 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
{name: "Home", path: "/", label: <BackstoryLogo/>},
|
||||
...navigationLinks
|
||||
];
|
||||
|
||||
// State for page navigation
|
||||
const [ currentTab, setCurrentTab ] = useState<string>("/");
|
||||
const [userMenuTab, setUserMenuTab] = useState<string>("");
|
||||
|
||||
// State for mobile drawer
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
@ -156,6 +231,43 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const userMenuOpen = Boolean(userMenuAnchor);
|
||||
|
||||
// User menu items
|
||||
const userMenuItems = [
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
icon: <Person fontSize="small" />,
|
||||
action: () => navigate(`/${user?.userType}/profile`)
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: <Dashboard fontSize="small" />,
|
||||
action: () => navigate(`/${user?.userType}/dashboard`)
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: <Settings fontSize="small" />,
|
||||
action: () => navigate(`/${user?.userType}/settings`)
|
||||
},
|
||||
{
|
||||
id: 'divider',
|
||||
label: '',
|
||||
icon: null,
|
||||
action: () => { }
|
||||
},
|
||||
{
|
||||
id: 'logout',
|
||||
label: 'Logout',
|
||||
icon: <Logout fontSize="small" />,
|
||||
action: () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const parts = location.pathname.split('/');
|
||||
let tab = '/';
|
||||
@ -173,12 +285,14 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
|
||||
const handleUserMenuClose = () => {
|
||||
setUserMenuAnchor(null);
|
||||
setUserMenuTab("");
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
handleUserMenuClose();
|
||||
logout();
|
||||
navigate('/');
|
||||
const handleUserMenuAction = (item: typeof userMenuItems[0]) => {
|
||||
if (item.id !== 'divider') {
|
||||
item.action();
|
||||
handleUserMenuClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
@ -216,31 +330,48 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
const renderDrawerContent = () => {
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
<MobileMenuTabs
|
||||
orientation="vertical"
|
||||
value={currentTab} >
|
||||
{navLinks.map((link) => (
|
||||
value={currentTab}
|
||||
onChange={(e, newValue) => setCurrentTab(newValue)}
|
||||
>
|
||||
{navLinks.map((link) => (
|
||||
<Tab
|
||||
key={link.name}
|
||||
value={link.path}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{link.icon && <Box sx={{ mr: 1 }}>{link.icon}</Box>}
|
||||
{link.name}
|
||||
</Box>
|
||||
<MenuItemBox>
|
||||
{link.path === '/' ? (
|
||||
<Avatar
|
||||
sx={{ width: 20, height: 20 }}
|
||||
variant="rounded"
|
||||
alt="Backstory logo"
|
||||
src="/logo192.png"
|
||||
/>
|
||||
) : (
|
||||
link.icon && link.icon
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{link.path === '/' ? 'Backstory' : (link.label && typeof link.label === 'object' ? link.name : (link.label || link.name))}
|
||||
</Typography>
|
||||
</MenuItemBox>
|
||||
}
|
||||
onClick={(e) => { handleDrawerToggle() ; setCurrentTab(link.path); navigate(link.path);} }
|
||||
onClick={(e) => {
|
||||
handleDrawerToggle();
|
||||
setCurrentTab(link.path);
|
||||
navigate(link.path);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
<Divider />
|
||||
{!user && (showLogin === undefined || showLogin !== false) && (
|
||||
</MobileMenuTabs>
|
||||
<MenuDivider />
|
||||
{!user && (
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => { navigate("/login"); }}
|
||||
onClick={() => { handleDrawerToggle(); navigate("/login"); }}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
@ -250,12 +381,39 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Render user menu content
|
||||
const renderUserMenu = () => {
|
||||
return (
|
||||
<UserMenuContainer>
|
||||
<UserMenuTabs
|
||||
orientation="vertical"
|
||||
value={userMenuTab}
|
||||
onChange={(e, newValue) => setUserMenuTab(newValue)}
|
||||
>
|
||||
{userMenuItems.map((item, index) => (
|
||||
item.id === 'divider' ? (
|
||||
<MenuDivider key={`divider-${index}`} />
|
||||
) : (
|
||||
<Tab
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
label={
|
||||
<MenuItemBox>
|
||||
{item.icon}
|
||||
<Typography variant="body2">{item.label}</Typography>
|
||||
</MenuItemBox>
|
||||
}
|
||||
onClick={() => handleUserMenuAction(item)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</UserMenuTabs>
|
||||
</UserMenuContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// Render user account section
|
||||
const renderUserSection = () => {
|
||||
if (showLogin !== undefined && showLogin === false) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<>
|
||||
@ -295,31 +453,11 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
<ExpandMore fontSize="small" />
|
||||
</UserButton>
|
||||
|
||||
<Menu
|
||||
<Popover
|
||||
id="user-menu"
|
||||
anchorEl={userMenuAnchor}
|
||||
open={userMenuOpen}
|
||||
anchorEl={userMenuAnchor}
|
||||
onClose={handleUserMenuClose}
|
||||
slots={{
|
||||
transition: Fade,
|
||||
}}
|
||||
slotProps={{
|
||||
list: {
|
||||
'aria-labelledby': 'user-button',
|
||||
sx: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column', // Adjusted for menu items
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
textTransform: 'uppercase', // All caps as requested
|
||||
},
|
||||
},
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 200, // Optional: ensures reasonable menu width
|
||||
},
|
||||
},
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
@ -328,30 +466,10 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiList-root": { gap: 0 },
|
||||
"& .MuiMenuItem-root": { gap: 1, display: "flex", flexDirection: "row", width: "100%" },
|
||||
"& .MuiSvgIcon-root": { color: "#D4A017" }
|
||||
}}
|
||||
TransitionComponent={Fade}
|
||||
>
|
||||
<MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}/profile`) }}>
|
||||
<Person fontSize="small" />
|
||||
<Box>Profile</Box>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}/dashboard`) }}>
|
||||
<Dashboard fontSize="small" />
|
||||
<Box>Dashboard</Box>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}settings`) }}>
|
||||
<Settings fontSize="small" />
|
||||
<Box>Settings</Box>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<Logout fontSize="small" />
|
||||
<Box>Logout</Box>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
{renderUserMenu()}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
575
frontend/src/hooks/AuthContext.tsx
Normal file
575
frontend/src/hooks/AuthContext.tsx
Normal file
@ -0,0 +1,575 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import * as Types from '../types/types';
|
||||
import { ApiClient, CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
|
||||
import { formatApiRequest, toCamelCase } from '../types/conversion';
|
||||
|
||||
// ============================
|
||||
// Types and Interfaces
|
||||
// ============================
|
||||
|
||||
export interface AuthState {
|
||||
user: Types.User | null;
|
||||
guest: Types.Guest | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitializing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
login: string; // email or username
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Re-export API client types for convenience
|
||||
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
|
||||
|
||||
// ============================
|
||||
// Token Storage Constants
|
||||
// ============================
|
||||
|
||||
const TOKEN_STORAGE = {
|
||||
ACCESS_TOKEN: 'accessToken',
|
||||
REFRESH_TOKEN: 'refreshToken',
|
||||
USER_DATA: 'userData',
|
||||
TOKEN_EXPIRY: 'tokenExpiry',
|
||||
GUEST_DATA: 'guestData'
|
||||
} as const;
|
||||
|
||||
// ============================
|
||||
// JWT Utilities
|
||||
// ============================
|
||||
|
||||
function parseJwtPayload(token: string): any {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JWT token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenExpired(token: string): boolean {
|
||||
const payload = parseJwtPayload(token);
|
||||
if (!payload || !payload.exp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if token expires within the next 5 minutes (buffer time)
|
||||
const expiryTime = payload.exp * 1000; // Convert to milliseconds
|
||||
const bufferTime = 5 * 60 * 1000; // 5 minutes
|
||||
const currentTime = Date.now();
|
||||
|
||||
return currentTime >= (expiryTime - bufferTime);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Storage Utilities with Date Conversion
|
||||
// ============================
|
||||
|
||||
function clearStoredAuth(): void {
|
||||
localStorage.removeItem(TOKEN_STORAGE.ACCESS_TOKEN);
|
||||
localStorage.removeItem(TOKEN_STORAGE.REFRESH_TOKEN);
|
||||
localStorage.removeItem(TOKEN_STORAGE.USER_DATA);
|
||||
localStorage.removeItem(TOKEN_STORAGE.TOKEN_EXPIRY);
|
||||
}
|
||||
|
||||
function prepareUserDataForStorage(user: Types.User): string {
|
||||
try {
|
||||
// Convert dates to ISO strings for storage
|
||||
const userForStorage = formatApiRequest(user);
|
||||
return JSON.stringify(userForStorage);
|
||||
} catch (error) {
|
||||
console.error('Failed to prepare user data for storage:', error);
|
||||
return JSON.stringify(user); // Fallback to direct serialization
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredUserData(userDataStr: string): Types.User | null {
|
||||
try {
|
||||
const rawUserData = JSON.parse(userDataStr);
|
||||
|
||||
// Convert the data using toCamelCase which handles date conversion
|
||||
const convertedData = toCamelCase<Types.User>(rawUserData);
|
||||
|
||||
return convertedData;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored user data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeAuthData(authResponse: Types.AuthResponse): void {
|
||||
localStorage.setItem(TOKEN_STORAGE.ACCESS_TOKEN, authResponse.accessToken);
|
||||
localStorage.setItem(TOKEN_STORAGE.REFRESH_TOKEN, authResponse.refreshToken);
|
||||
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(authResponse.user));
|
||||
localStorage.setItem(TOKEN_STORAGE.TOKEN_EXPIRY, authResponse.expiresAt.toString());
|
||||
}
|
||||
|
||||
function getStoredAuthData(): {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
userData: Types.User | null;
|
||||
expiresAt: number | null;
|
||||
} {
|
||||
const accessToken = localStorage.getItem(TOKEN_STORAGE.ACCESS_TOKEN);
|
||||
const refreshToken = localStorage.getItem(TOKEN_STORAGE.REFRESH_TOKEN);
|
||||
const userDataStr = localStorage.getItem(TOKEN_STORAGE.USER_DATA);
|
||||
const expiryStr = localStorage.getItem(TOKEN_STORAGE.TOKEN_EXPIRY);
|
||||
|
||||
let userData: Types.User | null = null;
|
||||
let expiresAt: number | null = null;
|
||||
|
||||
try {
|
||||
if (userDataStr) {
|
||||
userData = parseStoredUserData(userDataStr);
|
||||
}
|
||||
if (expiryStr) {
|
||||
expiresAt = parseInt(expiryStr, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored auth data:', error);
|
||||
clearStoredAuth();
|
||||
}
|
||||
|
||||
return { accessToken, refreshToken, userData, expiresAt };
|
||||
}
|
||||
|
||||
function updateStoredUserData(user: Types.User): void {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(user));
|
||||
} catch (error) {
|
||||
console.error('Failed to update stored user data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Guest Session Utilities
|
||||
// ============================
|
||||
|
||||
function createGuestSession(): Types.Guest {
|
||||
const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const guest: Types.Guest = {
|
||||
sessionId,
|
||||
createdAt: new Date(),
|
||||
lastActivity: new Date(),
|
||||
ipAddress: 'unknown',
|
||||
userAgent: navigator.userAgent
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(TOKEN_STORAGE.GUEST_DATA, JSON.stringify(formatApiRequest(guest)));
|
||||
} catch (error) {
|
||||
console.error('Failed to store guest session:', error);
|
||||
}
|
||||
|
||||
return guest;
|
||||
}
|
||||
|
||||
function getStoredGuestData(): Types.Guest | null {
|
||||
try {
|
||||
const guestDataStr = localStorage.getItem(TOKEN_STORAGE.GUEST_DATA);
|
||||
if (guestDataStr) {
|
||||
return toCamelCase<Types.Guest>(JSON.parse(guestDataStr));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored guest data:', error);
|
||||
localStorage.removeItem(TOKEN_STORAGE.GUEST_DATA);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Main Authentication Hook
|
||||
// ============================
|
||||
|
||||
export function useAuthenticationLogic() {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
user: null,
|
||||
guest: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
const [apiClient] = useState(() => new ApiClient());
|
||||
const initializationCompleted = useRef(false);
|
||||
|
||||
// Token refresh function
|
||||
const refreshAccessToken = useCallback(async (refreshToken: string): Promise<Types.AuthResponse | null> => {
|
||||
try {
|
||||
const response = await apiClient.refreshToken(refreshToken);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
return null;
|
||||
}
|
||||
}, [apiClient]);
|
||||
|
||||
// Initialize authentication state
|
||||
const initializeAuth = useCallback(async () => {
|
||||
if (initializationCompleted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize guest session first
|
||||
let guest = getStoredGuestData();
|
||||
if (!guest) {
|
||||
guest = createGuestSession();
|
||||
}
|
||||
|
||||
const stored = getStoredAuthData();
|
||||
|
||||
// If no stored tokens, user is not authenticated but has guest session
|
||||
if (!stored.accessToken || !stored.refreshToken || !stored.userData) {
|
||||
setAuthState({
|
||||
user: null,
|
||||
guest,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if access token is expired
|
||||
if (isTokenExpired(stored.accessToken)) {
|
||||
console.log('Access token expired, attempting refresh...');
|
||||
|
||||
const refreshResult = await refreshAccessToken(stored.refreshToken);
|
||||
|
||||
if (refreshResult) {
|
||||
storeAuthData(refreshResult);
|
||||
apiClient.setAuthToken(refreshResult.accessToken);
|
||||
|
||||
setAuthState({
|
||||
user: refreshResult.user,
|
||||
guest,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
console.log('Token refreshed successfully');
|
||||
} else {
|
||||
console.log('Token refresh failed, clearing stored auth');
|
||||
clearStoredAuth();
|
||||
apiClient.clearAuthToken();
|
||||
|
||||
setAuthState({
|
||||
user: null,
|
||||
guest,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Access token is still valid
|
||||
apiClient.setAuthToken(stored.accessToken);
|
||||
|
||||
setAuthState({
|
||||
user: stored.userData,
|
||||
guest,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
console.log('Restored authentication from stored tokens');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth:', error);
|
||||
clearStoredAuth();
|
||||
apiClient.clearAuthToken();
|
||||
|
||||
const guest = createGuestSession();
|
||||
setAuthState({
|
||||
user: null,
|
||||
guest,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
} finally {
|
||||
initializationCompleted.current = true;
|
||||
}
|
||||
}, [apiClient, refreshAccessToken]);
|
||||
|
||||
// Run initialization on mount
|
||||
useEffect(() => {
|
||||
initializeAuth();
|
||||
}, [initializeAuth]);
|
||||
|
||||
// Set up automatic token refresh
|
||||
useEffect(() => {
|
||||
if (!authState.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = getStoredAuthData();
|
||||
if (!stored.expiresAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expiryTime = stored.expiresAt * 1000;
|
||||
const currentTime = Date.now();
|
||||
const timeUntilExpiry = expiryTime - currentTime - (5 * 60 * 1000); // 5 minute buffer
|
||||
|
||||
if (timeUntilExpiry <= 0) {
|
||||
initializeAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTimer = setTimeout(() => {
|
||||
console.log('Auto-refreshing token before expiry...');
|
||||
initializeAuth();
|
||||
}, timeUntilExpiry);
|
||||
|
||||
return () => clearTimeout(refreshTimer);
|
||||
}, [authState.isAuthenticated, initializeAuth]);
|
||||
|
||||
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const authResponse = await apiClient.login(loginData);
|
||||
|
||||
storeAuthData(authResponse);
|
||||
apiClient.setAuthToken(authResponse.accessToken);
|
||||
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
user: authResponse.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
}));
|
||||
|
||||
console.log('Login successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearStoredAuth();
|
||||
apiClient.clearAuthToken();
|
||||
|
||||
// Create new guest session after logout
|
||||
const guest = createGuestSession();
|
||||
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
user: null,
|
||||
guest,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null
|
||||
}));
|
||||
|
||||
console.log('User logged out');
|
||||
}, [apiClient]);
|
||||
|
||||
const updateUserData = useCallback((updatedUser: Types.User) => {
|
||||
updateStoredUserData(updatedUser);
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
user: updatedUser
|
||||
}));
|
||||
console.log('User data updated');
|
||||
}, []);
|
||||
|
||||
const createCandidateAccount = useCallback(async (candidateData: CreateCandidateRequest): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const candidate = await apiClient.createCandidate(candidateData);
|
||||
console.log('Candidate created:', candidate);
|
||||
|
||||
// Auto-login after successful registration
|
||||
const loginSuccess = await login({
|
||||
login: candidateData.email,
|
||||
password: candidateData.password
|
||||
});
|
||||
|
||||
return loginSuccess;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient, login]);
|
||||
|
||||
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const employer = await apiClient.createEmployer(employerData);
|
||||
console.log('Employer created:', employer);
|
||||
|
||||
const loginSuccess = await login({
|
||||
login: employerData.email,
|
||||
password: employerData.password
|
||||
});
|
||||
|
||||
return loginSuccess;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient, login]);
|
||||
|
||||
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
await apiClient.requestPasswordReset({ email });
|
||||
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient]);
|
||||
|
||||
const refreshAuth = useCallback(async (): Promise<boolean> => {
|
||||
const stored = getStoredAuthData();
|
||||
if (!stored.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
const refreshResult = await refreshAccessToken(stored.refreshToken);
|
||||
|
||||
if (refreshResult) {
|
||||
storeAuthData(refreshResult);
|
||||
apiClient.setAuthToken(refreshResult.accessToken);
|
||||
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
user: refreshResult.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
}));
|
||||
|
||||
return true;
|
||||
} else {
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
}, [refreshAccessToken, logout]);
|
||||
|
||||
return {
|
||||
...authState,
|
||||
apiClient,
|
||||
login,
|
||||
logout,
|
||||
createCandidateAccount,
|
||||
createEmployerAccount,
|
||||
requestPasswordReset,
|
||||
refreshAuth,
|
||||
updateUserData
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Context Provider
|
||||
// ============================
|
||||
|
||||
const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const auth = useAuthenticationLogic();
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={auth}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Protected Route Component
|
||||
// ============================
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
requiredUserType?: Types.UserType;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
fallback = <div>Please log in to access this page.</div>,
|
||||
requiredUserType
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isInitializing, user } = useAuth();
|
||||
|
||||
// Show loading while checking stored tokens
|
||||
if (isInitializing) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// Check user type if required
|
||||
if (requiredUserType && user?.userType !== requiredUserType) {
|
||||
return <div>Access denied. Required user type: {requiredUserType}</div>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
287
frontend/src/hooks/GlobalContext.tsx
Normal file
287
frontend/src/hooks/GlobalContext.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import * as Types from 'types/types';
|
||||
import { formatApiRequest, toCamelCase } from 'types/conversion';
|
||||
|
||||
// ============================
|
||||
// App State Interface
|
||||
// ============================
|
||||
|
||||
export interface AppState {
|
||||
selectedCandidate: Types.Candidate | null;
|
||||
selectedJob: Types.Job | null;
|
||||
selectedEmployer: Types.Employer | null;
|
||||
// Add more global state as needed:
|
||||
// currentView: string;
|
||||
// filters: Record<string, any>;
|
||||
// searchQuery: string;
|
||||
}
|
||||
|
||||
export interface AppStateActions {
|
||||
setSelectedCandidate: (candidate: Types.Candidate | null) => void;
|
||||
setSelectedJob: (job: Types.Job | null) => void;
|
||||
setSelectedEmployer: (employer: Types.Employer | null) => void;
|
||||
clearSelections: () => void;
|
||||
// Future actions can be added here
|
||||
}
|
||||
|
||||
export type AppStateContextType = AppState & AppStateActions;
|
||||
|
||||
// ============================
|
||||
// Storage Constants
|
||||
// ============================
|
||||
|
||||
const APP_STORAGE = {
|
||||
SELECTED_CANDIDATE: 'selectedCandidate',
|
||||
SELECTED_JOB: 'selectedJob',
|
||||
SELECTED_EMPLOYER: 'selectedEmployer'
|
||||
} as const;
|
||||
|
||||
// ============================
|
||||
// Storage Utilities with Date Conversion
|
||||
// ============================
|
||||
|
||||
function storeCandidate(candidate: Types.Candidate | null): void {
|
||||
try {
|
||||
if (candidate) {
|
||||
const candidateForStorage = formatApiRequest(candidate);
|
||||
localStorage.setItem(APP_STORAGE.SELECTED_CANDIDATE, JSON.stringify(candidateForStorage));
|
||||
} else {
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_CANDIDATE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store selected candidate:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredCandidate(): Types.Candidate | null {
|
||||
try {
|
||||
const candidateStr = localStorage.getItem(APP_STORAGE.SELECTED_CANDIDATE);
|
||||
if (candidateStr) {
|
||||
const rawData = JSON.parse(candidateStr);
|
||||
return toCamelCase<Types.Candidate>(rawData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored candidate:', error);
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_CANDIDATE);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function storeJob(job: Types.Job | null): void {
|
||||
try {
|
||||
if (job) {
|
||||
const jobForStorage = formatApiRequest(job);
|
||||
localStorage.setItem(APP_STORAGE.SELECTED_JOB, JSON.stringify(jobForStorage));
|
||||
} else {
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_JOB);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store selected job:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredJob(): Types.Job | null {
|
||||
try {
|
||||
const jobStr = localStorage.getItem(APP_STORAGE.SELECTED_JOB);
|
||||
if (jobStr) {
|
||||
const rawData = JSON.parse(jobStr);
|
||||
return toCamelCase<Types.Job>(rawData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored job:', error);
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_JOB);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function storeEmployer(employer: Types.Employer | null): void {
|
||||
try {
|
||||
if (employer) {
|
||||
const employerForStorage = formatApiRequest(employer);
|
||||
localStorage.setItem(APP_STORAGE.SELECTED_EMPLOYER, JSON.stringify(employerForStorage));
|
||||
} else {
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_EMPLOYER);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store selected employer:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredEmployer(): Types.Employer | null {
|
||||
try {
|
||||
const employerStr = localStorage.getItem(APP_STORAGE.SELECTED_EMPLOYER);
|
||||
if (employerStr) {
|
||||
const rawData = JSON.parse(employerStr);
|
||||
return toCamelCase<Types.Employer>(rawData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored employer:', error);
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_EMPLOYER);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearAllStoredSelections(): void {
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_CANDIDATE);
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_JOB);
|
||||
localStorage.removeItem(APP_STORAGE.SELECTED_EMPLOYER);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// App State Hook
|
||||
// ============================
|
||||
|
||||
export function useAppStateLogic(): AppStateContextType {
|
||||
const [selectedCandidate, setSelectedCandidateState] = useState<Types.Candidate | null>(null);
|
||||
const [selectedJob, setSelectedJobState] = useState<Types.Job | null>(null);
|
||||
const [selectedEmployer, setSelectedEmployerState] = useState<Types.Employer | null>(null);
|
||||
|
||||
// Initialize state from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedCandidate = getStoredCandidate();
|
||||
const storedJob = getStoredJob();
|
||||
const storedEmployer = getStoredEmployer();
|
||||
|
||||
if (storedCandidate) {
|
||||
setSelectedCandidateState(storedCandidate);
|
||||
console.log('Restored selected candidate from storage:', storedCandidate);
|
||||
}
|
||||
|
||||
if (storedJob) {
|
||||
setSelectedJobState(storedJob);
|
||||
console.log('Restored selected job from storage:', storedJob);
|
||||
}
|
||||
|
||||
if (storedEmployer) {
|
||||
setSelectedEmployerState(storedEmployer);
|
||||
console.log('Restored selected employer from storage:', storedEmployer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSelectedCandidate = useCallback((candidate: Types.Candidate | null) => {
|
||||
setSelectedCandidateState(candidate);
|
||||
storeCandidate(candidate);
|
||||
|
||||
if (candidate) {
|
||||
console.log('Selected candidate:', candidate);
|
||||
} else {
|
||||
console.log('Cleared selected candidate');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSelectedJob = useCallback((job: Types.Job | null) => {
|
||||
setSelectedJobState(job);
|
||||
storeJob(job);
|
||||
|
||||
if (job) {
|
||||
console.log('Selected job:', job);
|
||||
} else {
|
||||
console.log('Cleared selected job');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSelectedEmployer = useCallback((employer: Types.Employer | null) => {
|
||||
setSelectedEmployerState(employer);
|
||||
storeEmployer(employer);
|
||||
|
||||
if (employer) {
|
||||
console.log('Selected employer:', employer);
|
||||
} else {
|
||||
console.log('Cleared selected employer');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearSelections = useCallback(() => {
|
||||
setSelectedCandidateState(null);
|
||||
setSelectedJobState(null);
|
||||
setSelectedEmployerState(null);
|
||||
clearAllStoredSelections();
|
||||
console.log('Cleared all selections');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedCandidate,
|
||||
selectedJob,
|
||||
selectedEmployer,
|
||||
setSelectedCandidate,
|
||||
setSelectedJob,
|
||||
setSelectedEmployer,
|
||||
clearSelections
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Context Provider
|
||||
// ============================
|
||||
|
||||
const AppStateContext = createContext<AppStateContextType | null>(null);
|
||||
|
||||
export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
const appState = useAppStateLogic();
|
||||
|
||||
return (
|
||||
<AppStateContext.Provider value={appState}>
|
||||
{children}
|
||||
</AppStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppState() {
|
||||
const context = useContext(AppStateContext);
|
||||
if (!context) {
|
||||
throw new Error('useAppState must be used within an AppStateProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Convenience Hooks for Specific State
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Hook specifically for candidate selection
|
||||
* Useful when a component only cares about candidate state
|
||||
*/
|
||||
export function useSelectedCandidate() {
|
||||
const { selectedCandidate, setSelectedCandidate } = useAppState();
|
||||
return { selectedCandidate, setSelectedCandidate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook specifically for job selection
|
||||
*/
|
||||
export function useSelectedJob() {
|
||||
const { selectedJob, setSelectedJob } = useAppState();
|
||||
return { selectedJob, setSelectedJob };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook specifically for employer selection
|
||||
*/
|
||||
export function useSelectedEmployer() {
|
||||
const { selectedEmployer, setSelectedEmployer } = useAppState();
|
||||
return { selectedEmployer, setSelectedEmployer };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Development Utilities
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Debug utility to log current app state (development only)
|
||||
*/
|
||||
export function useAppStateDebug() {
|
||||
const appState = useAppState();
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.group('🔍 App State Debug');
|
||||
console.log('Selected Candidate:', appState.selectedCandidate);
|
||||
console.log('Selected Job:', appState.selectedJob);
|
||||
console.log('Selected Employer:', appState.selectedEmployer);
|
||||
console.groupEnd();
|
||||
}
|
||||
}, [appState.selectedCandidate, appState.selectedJob, appState.selectedEmployer]);
|
||||
|
||||
return appState;
|
||||
}
|
@ -1,786 +0,0 @@
|
||||
// Persistent Authentication Hook with localStorage Integration and Date Conversion
|
||||
// Automatically restoring login state on page refresh with proper date handling
|
||||
|
||||
import React, { createContext, useContext,useState, useCallback, useEffect, useRef } from 'react';
|
||||
import * as Types from 'types/types';
|
||||
import { useUser } from 'hooks/useUser';
|
||||
import { CreateCandidateRequest, CreateEmployerRequest, CreateViewerRequest, LoginRequest } from 'services/api-client';
|
||||
import { formatApiRequest } from 'types/conversion';
|
||||
// Import date conversion functions
|
||||
import {
|
||||
convertCandidateFromApi,
|
||||
convertEmployerFromApi,
|
||||
convertViewerFromApi,
|
||||
convertFromApi,
|
||||
} from 'types/types';
|
||||
|
||||
|
||||
export interface AuthState {
|
||||
user: Types.User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitializing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Token storage utilities
|
||||
const TOKEN_STORAGE = {
|
||||
ACCESS_TOKEN: 'accessToken',
|
||||
REFRESH_TOKEN: 'refreshToken',
|
||||
USER_DATA: 'userData',
|
||||
TOKEN_EXPIRY: 'tokenExpiry'
|
||||
} as const;
|
||||
|
||||
// JWT token utilities
|
||||
function parseJwtPayload(token: string): any {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JWT token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenExpired(token: string): boolean {
|
||||
const payload = parseJwtPayload(token);
|
||||
if (!payload || !payload.exp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if token expires within the next 5 minutes (buffer time)
|
||||
const expiryTime = payload.exp * 1000; // Convert to milliseconds
|
||||
const bufferTime = 5 * 60 * 1000; // 5 minutes
|
||||
const currentTime = Date.now();
|
||||
|
||||
return currentTime >= (expiryTime - bufferTime);
|
||||
}
|
||||
|
||||
function clearStoredAuth(): void {
|
||||
localStorage.removeItem(TOKEN_STORAGE.ACCESS_TOKEN);
|
||||
localStorage.removeItem(TOKEN_STORAGE.REFRESH_TOKEN);
|
||||
localStorage.removeItem(TOKEN_STORAGE.USER_DATA);
|
||||
localStorage.removeItem(TOKEN_STORAGE.TOKEN_EXPIRY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert user data to storage format (dates to ISO strings)
|
||||
*/
|
||||
function prepareUserDataForStorage(user: Types.User): string {
|
||||
try {
|
||||
// Convert dates to ISO strings for storage
|
||||
const userForStorage = formatApiRequest(user);
|
||||
return JSON.stringify(userForStorage);
|
||||
} catch (error) {
|
||||
console.error('Failed to prepare user data for storage:', error);
|
||||
return JSON.stringify(user); // Fallback to direct serialization
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert stored user data back to proper format (ISO strings to dates)
|
||||
*/
|
||||
function parseStoredUserData(userDataStr: string): Types.User | null {
|
||||
try {
|
||||
const rawUserData = JSON.parse(userDataStr);
|
||||
|
||||
// Determine user type and apply appropriate conversion
|
||||
const userType = rawUserData.userType ||
|
||||
(rawUserData.companyName ? 'employer' :
|
||||
rawUserData.firstName ? 'candidate' : 'viewer');
|
||||
|
||||
switch (userType) {
|
||||
case 'candidate':
|
||||
return convertCandidateFromApi(rawUserData) as Types.Candidate;
|
||||
case 'employer':
|
||||
return convertEmployerFromApi(rawUserData) as Types.Employer;
|
||||
case 'viewer':
|
||||
return convertViewerFromApi(rawUserData) as Types.Viewer;
|
||||
default:
|
||||
// Fallback: try to determine by fields present
|
||||
if (rawUserData.companyName) {
|
||||
return convertEmployerFromApi(rawUserData) as Types.Employer;
|
||||
} else if (rawUserData.skills || rawUserData.experience) {
|
||||
return convertCandidateFromApi(rawUserData) as Types.Candidate;
|
||||
} else {
|
||||
return convertViewerFromApi(rawUserData) as Types.Viewer;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored user data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeAuthData(authResponse: Types.AuthResponse): void {
|
||||
localStorage.setItem(TOKEN_STORAGE.ACCESS_TOKEN, authResponse.accessToken);
|
||||
localStorage.setItem(TOKEN_STORAGE.REFRESH_TOKEN, authResponse.refreshToken);
|
||||
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(authResponse.user));
|
||||
localStorage.setItem(TOKEN_STORAGE.TOKEN_EXPIRY, authResponse.expiresAt.toString());
|
||||
}
|
||||
|
||||
function getStoredAuthData(): {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
userData: Types.User | null;
|
||||
expiresAt: number | null;
|
||||
} {
|
||||
const accessToken = localStorage.getItem(TOKEN_STORAGE.ACCESS_TOKEN);
|
||||
const refreshToken = localStorage.getItem(TOKEN_STORAGE.REFRESH_TOKEN);
|
||||
const userDataStr = localStorage.getItem(TOKEN_STORAGE.USER_DATA);
|
||||
const expiryStr = localStorage.getItem(TOKEN_STORAGE.TOKEN_EXPIRY);
|
||||
|
||||
let userData: Types.User | null = null;
|
||||
let expiresAt: number | null = null;
|
||||
|
||||
try {
|
||||
if (userDataStr) {
|
||||
userData = parseStoredUserData(userDataStr);
|
||||
}
|
||||
if (expiryStr) {
|
||||
expiresAt = parseInt(expiryStr, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored auth data:', error);
|
||||
// Clear corrupted data
|
||||
clearStoredAuth();
|
||||
}
|
||||
|
||||
return { accessToken, refreshToken, userData, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stored user data (useful when user profile is updated)
|
||||
*/
|
||||
function updateStoredUserData(user: Types.User): void {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(user));
|
||||
} catch (error) {
|
||||
console.error('Failed to update stored user data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function useSecureAuth() {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: true, // Start as true, will be set to false after checking tokens
|
||||
error: null
|
||||
});
|
||||
|
||||
const {apiClient} = useUser();
|
||||
const initializationCompleted = useRef(false);
|
||||
|
||||
// Token refresh function with date conversion
|
||||
const refreshAccessToken = useCallback(async (refreshToken: string): Promise<Types.AuthResponse | null> => {
|
||||
try {
|
||||
const response = await apiClient.refreshToken(refreshToken);
|
||||
|
||||
// Ensure user data has proper date conversion
|
||||
if (response.user) {
|
||||
const userType = response.user.userType ||
|
||||
(response.user.companyName ? 'employer' :
|
||||
response.user.firstName ? 'candidate' : 'viewer');
|
||||
|
||||
response.user = convertFromApi<Types.User>(response.user, userType);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
return null;
|
||||
}
|
||||
}, [apiClient]);
|
||||
|
||||
// Initialize authentication state from stored tokens
|
||||
const initializeAuth = useCallback(async () => {
|
||||
if (initializationCompleted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = getStoredAuthData();
|
||||
|
||||
// If no stored tokens, user is not authenticated
|
||||
if (!stored.accessToken || !stored.refreshToken || !stored.userData) {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isInitializing: false,
|
||||
isAuthenticated: false,
|
||||
user: null
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if access token is expired
|
||||
if (isTokenExpired(stored.accessToken)) {
|
||||
console.log('Access token expired, attempting refresh...');
|
||||
|
||||
// Try to refresh the token
|
||||
const refreshResult = await refreshAccessToken(stored.refreshToken);
|
||||
|
||||
if (refreshResult) {
|
||||
// Successfully refreshed - store with proper date conversion
|
||||
storeAuthData(refreshResult);
|
||||
apiClient.setAuthToken(refreshResult.accessToken);
|
||||
|
||||
console.log("User (refreshed) =>", refreshResult.user);
|
||||
|
||||
setAuthState({
|
||||
user: refreshResult.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
console.log('Token refreshed successfully with date conversion');
|
||||
} else {
|
||||
// Refresh failed, clear stored data
|
||||
console.log('Token refresh failed, clearing stored auth');
|
||||
clearStoredAuth();
|
||||
apiClient.clearAuthToken();
|
||||
|
||||
setAuthState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Access token is still valid - user data already has date conversion applied
|
||||
apiClient.setAuthToken(stored.accessToken);
|
||||
|
||||
console.log("User (from storage) =>", stored.userData);
|
||||
setAuthState({
|
||||
user: stored.userData, // Already converted by parseStoredUserData
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
console.log('Restored authentication from stored tokens with date conversion');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth:', error);
|
||||
clearStoredAuth();
|
||||
apiClient.clearAuthToken();
|
||||
|
||||
setAuthState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
} finally {
|
||||
initializationCompleted.current = true;
|
||||
}
|
||||
}, [apiClient, refreshAccessToken]);
|
||||
|
||||
// Run initialization on mount
|
||||
useEffect(() => {
|
||||
initializeAuth();
|
||||
}, [initializeAuth]);
|
||||
|
||||
// Set up automatic token refresh
|
||||
useEffect(() => {
|
||||
if (!authState.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = getStoredAuthData();
|
||||
if (!stored.expiresAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate time until token expires (with 5 minute buffer)
|
||||
const expiryTime = stored.expiresAt * 1000;
|
||||
const currentTime = Date.now();
|
||||
const timeUntilExpiry = expiryTime - currentTime - (5 * 60 * 1000); // 5 minute buffer
|
||||
|
||||
if (timeUntilExpiry <= 0) {
|
||||
// Token is already expired or will expire soon, refresh immediately
|
||||
initializeAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up automatic refresh before token expires
|
||||
const refreshTimer = setTimeout(() => {
|
||||
console.log('Auto-refreshing token before expiry...');
|
||||
initializeAuth();
|
||||
}, timeUntilExpiry);
|
||||
|
||||
return () => clearTimeout(refreshTimer);
|
||||
}, [authState.isAuthenticated, initializeAuth]);
|
||||
|
||||
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const authResponse = await apiClient.login(loginData);
|
||||
|
||||
// Ensure user data has proper date conversion before storing
|
||||
if (authResponse.user) {
|
||||
const userType = authResponse.user.userType ||
|
||||
(authResponse.user.companyName ? 'employer' :
|
||||
authResponse.user.firstName ? 'candidate' : 'viewer');
|
||||
|
||||
authResponse.user = convertFromApi<Types.User>(authResponse.user, userType);
|
||||
}
|
||||
|
||||
// Store tokens and user data with date conversion
|
||||
storeAuthData(authResponse);
|
||||
|
||||
// Update API client with new token
|
||||
apiClient.setAuthToken(authResponse.accessToken);
|
||||
|
||||
setAuthState({
|
||||
user: authResponse.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
console.log('Login successful with date conversion applied');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
// Clear stored authentication data
|
||||
clearStoredAuth();
|
||||
|
||||
// Clear API client token
|
||||
apiClient.clearAuthToken();
|
||||
|
||||
setAuthState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
console.log('User logged out');
|
||||
}, [apiClient]);
|
||||
|
||||
// Update user data in both state and localStorage (with date conversion)
|
||||
const updateUserData = useCallback((updatedUser: Types.User) => {
|
||||
// Update localStorage with proper date formatting
|
||||
updateStoredUserData(updatedUser);
|
||||
|
||||
// Update state
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
user: updatedUser
|
||||
}));
|
||||
|
||||
console.log('User data updated with date conversion');
|
||||
}, []);
|
||||
|
||||
const createViewerAccount = useCallback(async (viewerData: CreateViewerRequest): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Validate password strength
|
||||
const passwordValidation = apiClient.validatePasswordStrength(viewerData.password);
|
||||
if (!passwordValidation.isValid) {
|
||||
throw new Error(passwordValidation.issues.join(', '));
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if (!apiClient.validateEmail(viewerData.email)) {
|
||||
throw new Error('Please enter a valid email address');
|
||||
}
|
||||
|
||||
// Validate username
|
||||
const usernameValidation = apiClient.validateUsername(viewerData.username);
|
||||
if (!usernameValidation.isValid) {
|
||||
throw new Error(usernameValidation.issues.join(', '));
|
||||
}
|
||||
|
||||
// Create viewer - API client automatically applies date conversion
|
||||
const viewer = await apiClient.createViewer(viewerData);
|
||||
console.log('Viewer created with date conversion:', viewer);
|
||||
|
||||
// Auto-login after successful registration
|
||||
const loginSuccess = await login({
|
||||
login: viewerData.email,
|
||||
password: viewerData.password
|
||||
});
|
||||
|
||||
return loginSuccess;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient, login]);
|
||||
|
||||
const createCandidateAccount = useCallback(async (candidateData: CreateCandidateRequest): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Validate password strength
|
||||
const passwordValidation = apiClient.validatePasswordStrength(candidateData.password);
|
||||
if (!passwordValidation.isValid) {
|
||||
throw new Error(passwordValidation.issues.join(', '));
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if (!apiClient.validateEmail(candidateData.email)) {
|
||||
throw new Error('Please enter a valid email address');
|
||||
}
|
||||
|
||||
// Validate username
|
||||
const usernameValidation = apiClient.validateUsername(candidateData.username);
|
||||
if (!usernameValidation.isValid) {
|
||||
throw new Error(usernameValidation.issues.join(', '));
|
||||
}
|
||||
|
||||
// Create candidate - API client automatically applies date conversion
|
||||
const candidate = await apiClient.createCandidate(candidateData);
|
||||
console.log('Candidate created with date conversion:', candidate);
|
||||
|
||||
// Auto-login after successful registration
|
||||
const loginSuccess = await login({
|
||||
login: candidateData.email,
|
||||
password: candidateData.password
|
||||
});
|
||||
|
||||
return loginSuccess;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient, login]);
|
||||
|
||||
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Validate password strength
|
||||
const passwordValidation = apiClient.validatePasswordStrength(employerData.password);
|
||||
if (!passwordValidation.isValid) {
|
||||
throw new Error(passwordValidation.issues.join(', '));
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if (!apiClient.validateEmail(employerData.email)) {
|
||||
throw new Error('Please enter a valid email address');
|
||||
}
|
||||
|
||||
// Validate username
|
||||
const usernameValidation = apiClient.validateUsername(employerData.username);
|
||||
if (!usernameValidation.isValid) {
|
||||
throw new Error(usernameValidation.issues.join(', '));
|
||||
}
|
||||
|
||||
// Create employer - API client automatically applies date conversion
|
||||
const employer = await apiClient.createEmployer(employerData);
|
||||
console.log('Employer created with date conversion:', employer);
|
||||
|
||||
// Auto-login after successful registration
|
||||
const loginSuccess = await login({
|
||||
login: employerData.email,
|
||||
password: employerData.password
|
||||
});
|
||||
|
||||
return loginSuccess;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient, login]);
|
||||
|
||||
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
await apiClient.requestPasswordReset({ email });
|
||||
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed';
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [apiClient]);
|
||||
|
||||
// Force a token refresh (useful for manual refresh)
|
||||
const refreshAuth = useCallback(async (): Promise<boolean> => {
|
||||
const stored = getStoredAuthData();
|
||||
if (!stored.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
const refreshResult = await refreshAccessToken(stored.refreshToken);
|
||||
|
||||
if (refreshResult) {
|
||||
storeAuthData(refreshResult);
|
||||
apiClient.setAuthToken(refreshResult.accessToken);
|
||||
|
||||
setAuthState({
|
||||
user: refreshResult.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
}, [apiClient, refreshAccessToken, logout]);
|
||||
|
||||
return {
|
||||
...authState,
|
||||
login,
|
||||
logout,
|
||||
createViewerAccount,
|
||||
createCandidateAccount,
|
||||
createEmployerAccount,
|
||||
requestPasswordReset,
|
||||
refreshAuth,
|
||||
updateUserData // New function to update user data with proper storage
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Auth Context Provider (Optional)
|
||||
// ============================
|
||||
|
||||
const AuthContext = createContext<ReturnType<typeof useSecureAuth> | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const auth = useSecureAuth();
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={auth}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Protected Route Component
|
||||
// ============================
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
requiredUserType?: 'candidate' | 'employer' | 'viewer';
|
||||
}
|
||||
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
fallback = <div>Please log in to access this page.</div>,
|
||||
requiredUserType
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isInitializing, user } = useAuth();
|
||||
|
||||
// Show loading while checking stored tokens
|
||||
if (isInitializing) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// Check user type if required
|
||||
if (requiredUserType && user?.userType !== requiredUserType) {
|
||||
return <div>Access denied. Required user type: {requiredUserType}</div>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Usage Examples with Date Conversion
|
||||
// ============================
|
||||
|
||||
/*
|
||||
// App.tsx - Root level auth provider
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/candidate/*"
|
||||
element={
|
||||
<ProtectedRoute requiredUserType="candidate">
|
||||
<CandidateRoutes />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/employer/*"
|
||||
element={
|
||||
<ProtectedRoute requiredUserType="employer">
|
||||
<EmployerRoutes />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Component using auth with proper date handling
|
||||
function Header() {
|
||||
const { user, isAuthenticated, logout, isInitializing } = useAuth();
|
||||
|
||||
if (isInitializing) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<header>
|
||||
{isAuthenticated ? (
|
||||
<div>
|
||||
<div>
|
||||
Welcome, {user?.firstName || user?.companyName}!
|
||||
{user?.createdAt && (
|
||||
<small>Member since {user.createdAt.toLocaleDateString()}</small>
|
||||
)}
|
||||
{user?.lastLogin && (
|
||||
<small>Last login: {user.lastLogin.toLocaleString()}</small>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Link to="/login">Login</Link>
|
||||
<Link to="/register">Register</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// Profile component with date operations
|
||||
function UserProfile() {
|
||||
const { user, updateUserData } = useAuth();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// All date operations work properly because dates are Date objects
|
||||
const accountAge = user.createdAt ?
|
||||
Math.floor((Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24)) : 0;
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<Types.User>) => {
|
||||
// When updating user data, it will be properly stored with date conversion
|
||||
const updatedUser = { ...user, ...updates, updatedAt: new Date() };
|
||||
updateUserData(updatedUser);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Profile</h2>
|
||||
<p>Account created: {user.createdAt?.toLocaleDateString()}</p>
|
||||
<p>Account age: {accountAge} days</p>
|
||||
{user.lastLogin && (
|
||||
<p>Last login: {user.lastLogin.toLocaleString()}</p>
|
||||
)}
|
||||
|
||||
{'availabilityDate' in user && user.availabilityDate && (
|
||||
<p>Available from: {user.availabilityDate.toLocaleDateString()}</p>
|
||||
)}
|
||||
|
||||
{'experience' in user && user.experience?.map((exp, index) => (
|
||||
<div key={index}>
|
||||
<h4>{exp.position} at {exp.companyName}</h4>
|
||||
<p>
|
||||
{exp.startDate.toLocaleDateString()} -
|
||||
{exp.endDate ? exp.endDate.toLocaleDateString() : 'Present'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 <div>Checking authentication...</div>;
|
||||
}
|
||||
|
||||
return <LoginForm />;
|
||||
}
|
||||
*/
|
@ -1,100 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import { SetSnackType } from '../components/Snack';
|
||||
import { User, Guest, Candidate } from 'types/types';
|
||||
import { ApiClient } from "services/api-client";
|
||||
import { debugConversion } from "types/conversion";
|
||||
|
||||
type UserContextType = {
|
||||
apiClient: ApiClient;
|
||||
guest: Guest;
|
||||
candidate: Candidate | null;
|
||||
setCandidate: (candidate: Candidate | null) => void;
|
||||
};
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
|
||||
const useUser = () => {
|
||||
const ctx = useContext(UserContext);
|
||||
if (!ctx) throw new Error("useUser must be used within a UserProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
interface UserProviderProps {
|
||||
children: React.ReactNode;
|
||||
setSnack: SetSnackType;
|
||||
};
|
||||
|
||||
const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) => {
|
||||
const { children, setSnack } = props;
|
||||
const [apiClient, setApiClient] = useState<ApiClient>(new ApiClient());
|
||||
const [candidate, setCandidate] = useState<Candidate | null>(null);
|
||||
const [guest, setGuest] = useState<Guest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Candidate =>", candidate);
|
||||
}, [candidate]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Guest =>", guest);
|
||||
}, [guest]);
|
||||
|
||||
const createGuestSession = () => {
|
||||
console.log("TODO: Convert this to query the server for the session instead of generating it.");
|
||||
const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const guest: Guest = {
|
||||
sessionId,
|
||||
createdAt: new Date(),
|
||||
lastActivity: new Date(),
|
||||
ipAddress: 'unknown',
|
||||
userAgent: navigator.userAgent
|
||||
};
|
||||
setGuest(guest);
|
||||
debugConversion(guest, 'Guest Session');
|
||||
};
|
||||
|
||||
const checkExistingAuth = () => {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const userData = localStorage.getItem('userData');
|
||||
if (accessToken && userData) {
|
||||
try {
|
||||
const user = JSON.parse(userData);
|
||||
// Convert dates back to Date objects if they're stored as strings
|
||||
if (user.createdAt && typeof user.createdAt === 'string') {
|
||||
user.createdAt = new Date(user.createdAt);
|
||||
}
|
||||
if (user.updatedAt && typeof user.updatedAt === 'string') {
|
||||
user.updatedAt = new Date(user.updatedAt);
|
||||
}
|
||||
if (user.lastLogin && typeof user.lastLogin === 'string') {
|
||||
user.lastLogin = new Date(user.lastLogin);
|
||||
}
|
||||
setApiClient(new ApiClient(accessToken));
|
||||
} catch (e) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('userData');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create guest session on component mount
|
||||
useEffect(() => {
|
||||
createGuestSession();
|
||||
checkExistingAuth();
|
||||
}, []);
|
||||
|
||||
if (guest === null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ apiClient, candidate, setCandidate, guest }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
UserProvider,
|
||||
useUser
|
||||
};
|
@ -25,8 +25,8 @@ import {
|
||||
ChevronRight,
|
||||
Chat as ChatIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useUser } from 'hooks/useUser';
|
||||
import { ChatMessageBase, ChatMessage, ChatSession } from 'types/types';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { ChatMessageBase, ChatMessage, ChatSession, ChatStatusType } from 'types/types';
|
||||
import { ConversationHandle } from 'components/Conversation';
|
||||
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||
import { Message } from 'components/Message';
|
||||
@ -34,12 +34,14 @@ import { DeleteConfirmation } from 'components/DeleteConfirmation';
|
||||
import { CandidateSessionsResponse } from 'services/api-client';
|
||||
import { CandidateInfo } from 'components/CandidateInfo';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelectedCandidate } from 'hooks/GlobalContext';
|
||||
|
||||
const DRAWER_WIDTH = 300;
|
||||
const HANDLE_WIDTH = 48;
|
||||
|
||||
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
|
||||
const { apiClient, candidate } = useUser();
|
||||
const { apiClient } = useAuth();
|
||||
const { selectedCandidate } = useSelectedCandidate()
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMdUp = useMediaQuery(theme.breakpoints.up('md'));
|
||||
@ -65,13 +67,13 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
setDrawerOpen(isMdUp || !chatSession);
|
||||
}, [isMdUp, chatSession]);
|
||||
|
||||
// Load sessions for the candidate
|
||||
// Load sessions for the selectedCandidate
|
||||
const loadSessions = async () => {
|
||||
if (!candidate) return;
|
||||
if (!selectedCandidate) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await apiClient.getCandidateChatSessions(candidate.username);
|
||||
const result = await apiClient.getCandidateChatSessions(selectedCandidate.username);
|
||||
setSessions(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
@ -94,13 +96,13 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
|
||||
// Create new session
|
||||
const createNewSession = async () => {
|
||||
if (!candidate) { return }
|
||||
if (!selectedCandidate) { return }
|
||||
try {
|
||||
setLoading(true);
|
||||
const newSession = await apiClient.createCandidateChatSession(
|
||||
candidate.username,
|
||||
selectedCandidate.username,
|
||||
'candidate_chat',
|
||||
`Interview Discussion - ${candidate.username}`
|
||||
`Interview Discussion - ${selectedCandidate.username}`
|
||||
);
|
||||
setChatSession(newSession);
|
||||
setMessages([]);
|
||||
@ -124,7 +126,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
await apiClient.sendMessageStream(
|
||||
chatSession.id,
|
||||
{ prompt: messageContent }, {
|
||||
onMessage: (msg) => {
|
||||
onMessage: (msg: ChatMessage) => {
|
||||
console.log("onMessage:", msg);
|
||||
if (msg.type === "response") {
|
||||
setMessages(prev => {
|
||||
@ -141,10 +143,10 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
console.log("onError:", error);
|
||||
setStreaming(false);
|
||||
},
|
||||
onStreaming: (chunk) => {
|
||||
onStreaming: (chunk: ChatMessageBase) => {
|
||||
console.log("onStreaming:", chunk);
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
onStatusChange: (status: ChatStatusType) => {
|
||||
console.log("onStatusChange:", status);
|
||||
},
|
||||
onComplete: () => {
|
||||
@ -166,7 +168,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
// Load sessions when username changes
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [candidate]);
|
||||
}, [selectedCandidate]);
|
||||
|
||||
// Load messages when session changes
|
||||
useEffect(() => {
|
||||
@ -175,7 +177,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
}
|
||||
}, [chatSession]);
|
||||
|
||||
if (!candidate) {
|
||||
if (!selectedCandidate) {
|
||||
navigate('/find-a-candidate');
|
||||
}
|
||||
|
||||
@ -195,7 +197,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={createNewSession}
|
||||
disabled={loading || !candidate}
|
||||
disabled={loading || !selectedCandidate}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
New Session
|
||||
@ -277,7 +279,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
||||
|
||||
return (
|
||||
<Box ref={ref} sx={{ width: "100%", height: "100%", display: "flex", flexGrow: 1, flexDirection: "column" }}>
|
||||
{candidate && <CandidateInfo action={`Chat with Backstory about ${candidate.firstName}`} elevation={4} candidate={candidate} sx={{ minHeight: "max-content" }} />}
|
||||
{selectedCandidate && <CandidateInfo action={`Chat with Backstory about ${selectedCandidate.firstName}`} elevation={4} candidate={selectedCandidate} sx={{ minHeight: "max-content" }} />}
|
||||
|
||||
<Box sx={{ display: "flex", mt: 1, gap: 1, height: "100%", position: 'relative' }}>
|
||||
{/* Drawer */}
|
||||
|
@ -31,7 +31,7 @@ import {
|
||||
TipsAndUpdates as TipsIcon,
|
||||
SettingsBackupRestore
|
||||
} from '@mui/icons-material';
|
||||
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { LoadingPage } from './LoadingPage';
|
||||
import { LoginRequired } from './LoginRequired';
|
||||
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||
@ -45,7 +45,7 @@ interface DashboardProps extends BackstoryPageProps {
|
||||
const CandidateDashboardPage: React.FC<DashboardProps> = (props: DashboardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { setSnack } = props;
|
||||
const { user, isLoading, isInitializing, isAuthenticated } = useSecureAuth();
|
||||
const { user, isLoading, isInitializing, isAuthenticated } = useAuth();
|
||||
const profileCompletion = 75;
|
||||
const sidebarItems = [
|
||||
{ icon: <DashboardIcon />, text: 'Dashboard', active: true },
|
||||
|
@ -6,10 +6,12 @@ import Box from '@mui/material/Box';
|
||||
import { BackstoryPageProps } from '../components/BackstoryTab';
|
||||
import { CandidateInfo } from 'components/CandidateInfo';
|
||||
import { Candidate } from "../types/types";
|
||||
import { useUser } from 'hooks/useUser';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { useSelectedCandidate } from 'hooks/GlobalContext';
|
||||
|
||||
const CandidateListingPage = (props: BackstoryPageProps) => {
|
||||
const { apiClient, setCandidate } = useUser();
|
||||
const { apiClient } = useAuth();
|
||||
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
|
||||
const navigate = useNavigate();
|
||||
const { setSnack } = props;
|
||||
const [candidates, setCandidates] = useState<Candidate[] | null>(null);
|
||||
@ -56,9 +58,14 @@ const CandidateListingPage = (props: BackstoryPageProps) => {
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{candidates?.map((u, i) =>
|
||||
<Box key={`${u.username}`}
|
||||
onClick={() => { setCandidate(u); navigate("/chat"); }}
|
||||
onClick={() => { setSelectedCandidate(u); navigate("/chat"); }}
|
||||
sx={{ cursor: "pointer" }}>
|
||||
<CandidateInfo sx={{ maxWidth: "320px", "cursor": "pointer", "&:hover": { border: "2px solid orange" }, border: "2px solid transparent" }} candidate={u} />
|
||||
{selectedCandidate?.id === u.id &&
|
||||
<CandidateInfo sx={{ maxWidth: "320px", "cursor": "pointer", border: "2px solid yellow", "&:hover": { border: "2px solid orange" } }} candidate={u} />
|
||||
}
|
||||
{selectedCandidate?.id !== u.id &&
|
||||
<CandidateInfo sx={{ maxWidth: "320px", "cursor": "pointer", border: "2px solid transparent", "&:hover": { border: "2px solid orange" } }} candidate={u} />
|
||||
}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
@ -20,7 +20,7 @@ import { Scrollable } from '../components/Scrollable';
|
||||
import { Pulse } from 'components/Pulse';
|
||||
import { StreamingResponse } from 'services/api-client';
|
||||
import { ChatContext, ChatMessage, ChatMessageBase, ChatSession, ChatQuery } from 'types/types';
|
||||
import { useUser } from 'hooks/useUser';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
|
||||
const emptyUser: Candidate = {
|
||||
description: "[blank]",
|
||||
@ -46,7 +46,7 @@ const emptyUser: Candidate = {
|
||||
};
|
||||
|
||||
const GenerateCandidate = (props: BackstoryElementProps) => {
|
||||
const { apiClient } = useUser();
|
||||
const { apiClient } = useAuth();
|
||||
const { setSnack, submitQuery } = props;
|
||||
const [streaming, setStreaming] = useState<string>('');
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
|
@ -35,15 +35,14 @@ import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
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';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
|
||||
// Main component
|
||||
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||
const theme = useTheme();
|
||||
const { user } = useSecureAuth();
|
||||
const { user } = useAuth();
|
||||
|
||||
// State management
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
@ -54,7 +53,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
|
||||
const [analysisStarted, setAnalysisStarted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openUploadDialog, setOpenUploadDialog] = useState(false);
|
||||
const { apiClient } = useUser();
|
||||
const { apiClient } = useAuth();
|
||||
const { setSnack } = props;
|
||||
const [candidates, setCandidates] = useState<Candidate[] | null>(null);
|
||||
|
||||
|
@ -52,8 +52,7 @@ import { E164Number } from 'libphonenumber-js/core';
|
||||
import './LoginPage.css';
|
||||
|
||||
import { ApiClient } from 'services/api-client';
|
||||
import { useUser } from 'hooks/useUser';
|
||||
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { LocationInput } from 'components/LocationInput';
|
||||
import { Location } from 'types/types';
|
||||
|
||||
@ -61,7 +60,7 @@ import { Candidate } from 'types/types'
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||
|
||||
type UserRegistrationType = 'viewer' | 'candidate' | 'employer';
|
||||
type UserRegistrationType = 'candidate' | 'employer';
|
||||
|
||||
interface LoginRequest {
|
||||
login: string;
|
||||
@ -92,14 +91,13 @@ const apiClient = new ApiClient();
|
||||
|
||||
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||
const { setSnack } = props;
|
||||
const { guest } = useUser();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [phone, setPhone] = useState<E164Number | null>(null);
|
||||
const { createCandidateAccount, createViewerAccount, user, login, isLoading, error } = useSecureAuth();
|
||||
const { createCandidateAccount, guest, user, login, isLoading, error } = useAuth();
|
||||
const [passwordValidation, setPasswordValidation] = useState<{ isValid: boolean; issues: string[] }>({ isValid: true, issues: [] });
|
||||
const name = (user?.userType === 'candidate' || user?.userType === 'viewer') ? user.username : user?.email || '';
|
||||
const name = (user?.userType === 'candidate') ? user.username : user?.email || '';
|
||||
const [location, setLocation] = useState<Partial<Location>>({});
|
||||
|
||||
// Password visibility states
|
||||
@ -202,12 +200,6 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||
const handleUserTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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) => {
|
||||
@ -234,22 +226,17 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||
setSuccess(null);
|
||||
|
||||
// For now, all non-employer registrations go through candidate creation
|
||||
// This would need to be updated when viewer and employer APIs are available
|
||||
// This would need to be updated when 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 {
|
||||
if (registerForm.userType === 'candidate') {
|
||||
window.location.href = '/candidate/dashboard';
|
||||
}
|
||||
}
|
||||
@ -269,12 +256,6 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||
// Get user type icon and description
|
||||
const getUserTypeInfo = (userType: UserRegistrationType) => {
|
||||
switch (userType) {
|
||||
case 'viewer':
|
||||
return {
|
||||
icon: <ViewIcon />,
|
||||
title: 'Viewer',
|
||||
description: 'Chat with Backstory about candidates and explore the platform.'
|
||||
};
|
||||
case 'candidate':
|
||||
return {
|
||||
icon: <Person />,
|
||||
@ -491,7 +472,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||
onChange={handleUserTypeChange}
|
||||
sx={{ gap: 1 }}
|
||||
>
|
||||
{(['viewer', 'candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
|
||||
{(['candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
|
||||
const info = getUserTypeInfo(userType);
|
||||
return (
|
||||
<FormControlLabel
|
||||
|
@ -8,14 +8,16 @@ import { BackstoryPageProps } from '../components/BackstoryTab';
|
||||
import { Conversation, ConversationHandle } from '../components/Conversation';
|
||||
import { BackstoryQuery } from '../components/BackstoryQuery';
|
||||
import { CandidateInfo } from 'components/CandidateInfo';
|
||||
import { useUser } from "../hooks/useUser";
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { Candidate } from 'types/types';
|
||||
|
||||
const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
|
||||
const { setSnack, submitQuery } = props;
|
||||
const { candidate } = useUser();
|
||||
const { user } = useAuth();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [questions, setQuestions] = useState<React.ReactElement[]>([]);
|
||||
const candidate: Candidate | null = user?.userType === 'candidate' ? user : null;
|
||||
|
||||
// console.log("ChatPage candidate =>", candidate);
|
||||
useEffect(() => {
|
||||
|
@ -5,7 +5,7 @@ import { Box } from "@mui/material";
|
||||
import { SetSnackType } from '../components/Snack';
|
||||
import { LoadingComponent } from "../components/LoadingComponent";
|
||||
import { User, Guest, Candidate } from 'types/types';
|
||||
import { useUser } from "hooks/useUser";
|
||||
import { useAuth } from "hooks/AuthContext";
|
||||
|
||||
interface CandidateRouteProps {
|
||||
guest?: Guest | null;
|
||||
@ -14,7 +14,7 @@ interface CandidateRouteProps {
|
||||
};
|
||||
|
||||
const CandidateRoute: React.FC<CandidateRouteProps> = (props: CandidateRouteProps) => {
|
||||
const { apiClient } = useUser();
|
||||
const { apiClient } = useAuth();
|
||||
const { setSnack } = props;
|
||||
const { username } = useParams<{ username: string }>();
|
||||
const [candidate, setCandidate] = useState<Candidate|null>(null);
|
||||
|
@ -30,7 +30,6 @@ import {
|
||||
convertJobApplicationFromApi,
|
||||
convertChatSessionFromApi,
|
||||
convertChatMessageFromApi,
|
||||
convertViewerFromApi,
|
||||
convertFromApi,
|
||||
convertArrayFromApi
|
||||
} from 'types/types';
|
||||
@ -81,14 +80,6 @@ export interface CreateEmployerRequest {
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CreateViewerRequest {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequest {
|
||||
email: string;
|
||||
}
|
||||
@ -238,20 +229,6 @@ class ApiClient {
|
||||
return handleApiResponse<Types.AuthResponse>(response);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Viewer Methods with Date Conversion
|
||||
// ============================
|
||||
|
||||
async createViewer(request: CreateViewerRequest): Promise<Types.Viewer> {
|
||||
const response = await fetch(`${this.baseUrl}/viewers`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest(request))
|
||||
});
|
||||
|
||||
return this.handleApiResponseWithConversion<Types.Viewer>(response, 'Viewer');
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Candidate Methods with Date Conversion
|
||||
// ============================
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Generated TypeScript types from Pydantic models
|
||||
// Source: src/backend/models.py
|
||||
// Generated on: 2025-05-30T09:39:47.716115
|
||||
// Generated on: 2025-05-30T18:07:14.923475
|
||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||
|
||||
// ============================
|
||||
@ -57,7 +57,7 @@ export type UserGender = "female" | "male";
|
||||
|
||||
export type UserStatus = "active" | "inactive" | "pending" | "banned";
|
||||
|
||||
export type UserType = "candidate" | "employer" | "viewer" | "guest";
|
||||
export type UserType = "candidate" | "employer" | "guest";
|
||||
|
||||
export type VectorStoreType = "chroma";
|
||||
|
||||
@ -160,7 +160,7 @@ export interface BaseUserWithType {
|
||||
lastLogin?: Date;
|
||||
profileImage?: string;
|
||||
status: "active" | "inactive" | "pending" | "banned";
|
||||
userType: "candidate" | "employer" | "viewer" | "guest";
|
||||
userType: "candidate" | "employer" | "guest";
|
||||
}
|
||||
|
||||
export interface Candidate {
|
||||
@ -673,23 +673,6 @@ 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;
|
||||
@ -1077,23 +1060,6 @@ export function convertUserActivityFromApi(data: any): UserActivity {
|
||||
timestamp: new Date(data.timestamp),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convert Viewer from API response, parsing date fields
|
||||
* Date fields: createdAt, updatedAt, lastLogin
|
||||
*/
|
||||
export function convertViewerFromApi(data: any): Viewer {
|
||||
if (!data) return data;
|
||||
|
||||
return {
|
||||
...data,
|
||||
// Convert createdAt from ISO string to Date
|
||||
createdAt: new Date(data.createdAt),
|
||||
// Convert updatedAt from ISO string to Date
|
||||
updatedAt: new Date(data.updatedAt),
|
||||
// Convert lastLogin from ISO string to Date
|
||||
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convert WorkExperience from API response, parsing date fields
|
||||
* Date fields: startDate, endDate
|
||||
@ -1168,8 +1134,6 @@ export function convertFromApi<T>(data: any, modelType: string): T {
|
||||
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:
|
||||
@ -1188,7 +1152,7 @@ export function convertArrayFromApi<T>(data: any[], modelType: string): T[] {
|
||||
// Union Types
|
||||
// ============================
|
||||
|
||||
export type User = Candidate | Employer | Viewer;
|
||||
export type User = Candidate | Employer;
|
||||
|
||||
// Export all types
|
||||
export type { };
|
||||
|
@ -49,7 +49,7 @@ from llm_manager import llm_manager
|
||||
# =============================
|
||||
from models import (
|
||||
# User models
|
||||
Candidate, Employer, Viewer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse,
|
||||
Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse,
|
||||
|
||||
# Job models
|
||||
Job, JobApplication, ApplicationStatus,
|
||||
@ -159,26 +159,6 @@ class LoginRequest(BaseModel):
|
||||
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
|
||||
@ -278,11 +258,6 @@ async def get_current_user(
|
||||
) -> BaseUserWithType:
|
||||
"""Get current user from database"""
|
||||
try:
|
||||
# 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:
|
||||
@ -440,11 +415,6 @@ async def login(
|
||||
employer_data = await database.get_employer(user_data["id"])
|
||||
if 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']}")
|
||||
@ -634,10 +604,6 @@ 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(
|
||||
@ -666,100 +632,6 @@ 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
|
||||
# ============================
|
||||
|
@ -15,7 +15,6 @@ T = TypeVar('T')
|
||||
class UserType(str, Enum):
|
||||
CANDIDATE = "candidate"
|
||||
EMPLOYER = "employer"
|
||||
VIEWER = "viewer"
|
||||
GUEST = "guest"
|
||||
|
||||
class UserGender(str, Enum):
|
||||
@ -389,10 +388,6 @@ 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
|
||||
@ -460,7 +455,7 @@ class Authentication(BaseModel):
|
||||
class AuthResponse(BaseModel):
|
||||
access_token: str = Field(..., alias="accessToken")
|
||||
refresh_token: str = Field(..., alias="refreshToken")
|
||||
user: Candidate | Employer | Viewer
|
||||
user: Candidate | Employer
|
||||
expires_at: int = Field(..., alias="expiresAt")
|
||||
model_config = {
|
||||
"populate_by_name": True # Allow both field names and aliases
|
||||
|
Loading…
x
Reference in New Issue
Block a user