Updated UI, auth flows, and refactored

This commit is contained in:
James Ketr 2025-05-30 11:20:41 -07:00
parent 8f6c39c3f7
commit a03497a552
23 changed files with 1140 additions and 1247 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ chromadb/**
chromadb-prod/**
dev-keys/**
**/__pycache__/**
.vscode

View File

@ -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>
);
};

View File

@ -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 {

View File

@ -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);

View File

@ -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>('');

View File

@ -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%",

View File

@ -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>
</>
);
};

View 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}</>;
}

View 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;
}

View File

@ -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 />;
}
*/

View File

@ -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
};

View File

@ -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 */}

View File

@ -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 },

View File

@ -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>

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -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(() => {

View File

@ -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);

View File

@ -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
// ============================

View File

@ -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 { };

View File

@ -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
# ============================

View File

@ -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