backstory/frontend/src/pages/LoginPage.tsx

745 lines
25 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Paper,
TextField,
Button,
Typography,
Grid,
Alert,
CircularProgress,
Tabs,
Tab,
AppBar,
Toolbar,
Card,
CardContent,
Divider,
Avatar,
IconButton,
InputAdornment,
List,
ListItem,
ListItemIcon,
ListItemText,
Collapse,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
Chip
} from '@mui/material';
import {
Person,
PersonAdd,
AccountCircle,
ExitToApp,
Visibility,
VisibilityOff,
CheckCircle,
Cancel,
ExpandLess,
ExpandMore,
Visibility as ViewIcon,
Work,
Business
} from '@mui/icons-material';
import 'react-phone-number-input/style.css';
import PhoneInput from 'react-phone-number-input';
import { E164Number } from 'libphonenumber-js/core';
import './LoginPage.css';
import { ApiClient } from 'services/api-client';
import { useAuth } from 'hooks/AuthContext';
import { LocationInput } from 'components/LocationInput';
import { Location } from 'types/types';
import { Candidate } from 'types/types'
import { useNavigate } from 'react-router-dom';
import { BackstoryPageProps } from 'components/BackstoryTab';
type UserRegistrationType = 'candidate' | 'employer';
interface LoginRequest {
login: string;
password: string;
}
interface RegisterRequest {
userType: UserRegistrationType;
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
confirmPassword: string;
phone?: string;
// Employer specific fields (placeholder)
companyName?: string;
industry?: string;
companySize?: string;
}
interface PasswordRequirement {
label: string;
met: boolean;
}
const apiClient = new ApiClient();
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const { setSnack } = props;
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, guest, user, login, isLoading, error } = useAuth();
const [passwordValidation, setPasswordValidation] = useState<{ isValid: boolean; issues: string[] }>({ isValid: true, issues: [] });
const name = (user?.userType === 'candidate') ? user.username : user?.email || '';
const [location, setLocation] = useState<Partial<Location>>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Password visibility states
const [showLoginPassword, setShowLoginPassword] = useState(false);
const [showRegisterPassword, setShowRegisterPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [showPasswordRequirements, setShowPasswordRequirements] = useState(false);
const handleLocationChange = (location: Partial<Location>) => {
setLocation(location);
console.log('Location updated:', location);
};
// Login form state
const [loginForm, setLoginForm] = useState<LoginRequest>({
login: '',
password: ''
});
// Register form state
const [registerForm, setRegisterForm] = useState<RegisterRequest>({
userType: 'candidate',
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
confirmPassword: '',
phone: '',
companyName: '',
industry: '',
companySize: ''
});
// Password requirements validation
const getPasswordRequirements = (password: string): PasswordRequirement[] => {
return [
{
label: 'At least 8 characters long',
met: password.length >= 8
},
{
label: 'Contains uppercase letter',
met: /[A-Z]/.test(password)
},
{
label: 'Contains lowercase letter',
met: /[a-z]/.test(password)
},
{
label: 'Contains number',
met: /\d/.test(password)
},
{
label: 'Contains special character (!@#$%^&*)',
met: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
}
];
};
const passwordRequirements = getPasswordRequirements(registerForm.password);
const passwordsMatch = registerForm.password === registerForm.confirmPassword;
const hasPasswordMatchError = registerForm.confirmPassword.length > 0 && !passwordsMatch;
useEffect(() => {
if (phone !== registerForm.phone && phone) {
console.log({ phone });
setRegisterForm({ ...registerForm, phone });
}
}, [phone, registerForm]);
useEffect(() => {
if (!loading || !error) {
return;
}
if (loading && error) {
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
setSnack(data.error.message, "error");
setTimeout(() => {
setErrorMessage(null);
setLoading(false);
}, 3000);
}
}, [error, loading]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setSuccess(null);
const success = await login(loginForm);
if (success) {
setSuccess('Login successful!');
setLoading(false);
}
};
const handlePasswordChange = (password: string) => {
setRegisterForm(prev => ({ ...prev, password }));
setPasswordValidation(apiClient.validatePasswordStrength(password));
// Show requirements if password has content and isn't valid
if (password.length > 0) {
const requirements = getPasswordRequirements(password);
const allMet = requirements.every(req => req.met);
if (!allMet && !showPasswordRequirements) {
setShowPasswordRequirements(true);
}
if (allMet && showPasswordRequirements) {
setShowPasswordRequirements(false);
}
}
};
const handleUserTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const userType = event.target.value as UserRegistrationType;
setRegisterForm(prev => ({ ...prev, userType }));
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
// Check if employer registration is attempted
if (registerForm.userType === 'employer') {
setSnack('Employer registration is not yet supported. Please contact support for employer account setup.', "warning");
return;
}
// Validate passwords match
if (!passwordsMatch) {
return;
}
// Validate password requirements
const allRequirementsMet = passwordRequirements.every(req => req.met);
if (!allRequirementsMet) {
return;
}
setLoading(true);
setSuccess(null);
// For now, all non-employer registrations go through candidate creation
// This would need to be updated when employer APIs are available
let success;
switch (registerForm.userType) {
case 'candidate':
success = await createCandidateAccount(registerForm);
break;
}
if (success) {
// Redirect based on user type
if (registerForm.userType === 'candidate') {
window.location.href = '/candidate/dashboard';
}
setLoading(false);
}
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
setSuccess(null);
};
// Toggle password visibility functions
const toggleLoginPasswordVisibility = () => setShowLoginPassword(!showLoginPassword);
const toggleRegisterPasswordVisibility = () => setShowRegisterPassword(!showRegisterPassword);
const toggleConfirmPasswordVisibility = () => setShowConfirmPassword(!showConfirmPassword);
// Get user type icon and description
const getUserTypeInfo = (userType: UserRegistrationType) => {
switch (userType) {
case 'candidate':
return {
icon: <Person />,
title: 'Candidate',
description: 'Use Backstory to generate your resume and help you manage your career. Optionally let people interact with your profile.'
};
case 'employer':
return {
icon: <Business />,
title: 'Employer',
description: 'Post jobs and find talent (Coming Soon.)'
};
}
};
// If user is logged in, show their profile
if (user) {
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Card elevation={3}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<Avatar sx={{ mr: 2, bgcolor: 'primary.main' }}>
<AccountCircle />
</Avatar>
<Typography variant="h4" component="h1">
User Profile
</Typography>
</Box>
<Divider sx={{ mb: 3 }} />
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Username:</strong> {name}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Email:</strong> {user.email}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
{/* <strong>Status:</strong> {user.status} */}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Phone:</strong> {user.phone || 'Not provided'}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Account type:</strong> {user.userType}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Last Login:</strong> {
user.lastLogin
? user.lastLogin.toLocaleString()
: 'N/A'
}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Member Since:</strong> {user.createdAt.toLocaleDateString()}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
</Container>
);
}
const validateInput = (value: string) => {
if (!value) return 'This field is required';
// Username: alphanumeric, 3-20 characters, no @
const usernameRegex = /^[a-zA-Z0-9]{3,20}$/;
// Email: basic email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (usernameRegex.test(value)) return '';
if (emailRegex.test(value)) return '';
return 'Enter a valid username (3-20 alphanumeric characters) or email';
};
const handleLoginChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setLoginForm({ ...loginForm, login: value });
};
return (
<Container maxWidth="sm" sx={{ mt: 4 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h4" component="h1" gutterBottom align="center" color="primary">
Backstory
</Typography>
{guest && (
<Card sx={{ mb: 3, bgcolor: 'grey.50' }} elevation={1}>
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
Guest Session Active
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
Session ID: {guest.sessionId}
</Typography>
<Typography variant="body2" color="text.secondary">
Created: {guest.createdAt.toLocaleString()}
</Typography>
</CardContent>
</Card>
)}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab icon={<Person />} label="Login" />
<Tab icon={<PersonAdd />} label="Register" />
</Tabs>
</Box>
{errorMessage && (
<Alert severity="error" sx={{ mb: 2 }}>
{errorMessage}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
{success}
</Alert>
)}
{tabValue === 0 && (
<Box component="form" onSubmit={handleLogin}>
<Typography variant="h5" gutterBottom>
Sign In
</Typography>
<TextField
fullWidth
label="Username or Email"
type="text"
value={loginForm.login}
onChange={handleLoginChange}
margin="normal"
required
disabled={loading}
variant="outlined"
placeholder="Enter username or email"
/>
<TextField
fullWidth
label="Password"
type={showLoginPassword ? 'text' : 'password'}
value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
autoComplete='current-password'
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={toggleLoginPasswordVisibility}
edge="end"
disabled={loading}
>
{showLoginPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <Person />}
>
{loading ? 'Signing In...' : 'Sign In'}
</Button>
</Box>
)}
{tabValue === 1 && (
<Box component="form" onSubmit={handleRegister}>
<Typography variant="h5" gutterBottom>
Create Account
</Typography>
{/* User Type Selection */}
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
<FormLabel component="legend" sx={{ mb: 2 }}>
<Typography variant="h6">Select Account Type</Typography>
</FormLabel>
<RadioGroup
value={registerForm.userType}
onChange={handleUserTypeChange}
sx={{ gap: 1 }}
>
{(['candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
const info = getUserTypeInfo(userType);
return (
<FormControlLabel
key={userType}
value={userType}
disabled={loading || userType === 'employer'}
control={<Radio />}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
{info.icon}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
{info.title}
{userType === 'employer' && (
<Chip
label="Coming Soon"
size="small"
color="warning"
sx={{ ml: 1 }}
/>
)}
</Typography>
<Typography variant="body2" color="text.secondary">
{info.description}
</Typography>
</Box>
</Box>
}
sx={{
border: '1px solid',
borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
borderRadius: 1,
p: 1,
m: 0,
bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
'&:hover': {
bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
},
opacity: userType === 'employer' ? 0.6 : 1
}}
/>
);
})}
</RadioGroup>
</FormControl>
{/* Employer Placeholder */}
{registerForm.userType === 'employer' && (
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Employer Registration Coming Soon
</Typography>
<Typography variant="body2">
We're currently building our employer features. If you're interested in posting jobs
and finding talent, please contact our support team at support@backstory.com for
early access.
</Typography>
</Alert>
)}
{/* Basic Information Fields */}
{registerForm.userType !== 'employer' && (
<>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="First Name"
value={registerForm.firstName}
onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
required
disabled={loading}
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Last Name"
value={registerForm.lastName}
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
required
disabled={loading}
variant="outlined"
/>
</Grid>
</Grid>
<TextField
fullWidth
label="Username"
value={registerForm.username}
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
/>
<TextField
fullWidth
label="Email"
type="email"
value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
/>
{/* Conditional fields based on user type */}
{registerForm.userType === 'candidate' && (
<>
<PhoneInput
label="Phone (Optional)"
placeholder="Enter phone number"
defaultCountry='US'
value={registerForm.phone}
disabled={loading}
onChange={(v) => setPhone(v as E164Number)}
/>
<LocationInput
value={location}
onChange={handleLocationChange}
showCity
helperText="Include your city for more specific job matches"
/>
</>
)}
<TextField
fullWidth
label="Password"
type={showRegisterPassword ? 'text' : 'password'}
value={registerForm.password}
onChange={(e) => handlePasswordChange(e.target.value)}
margin="normal"
required
disabled={loading}
variant="outlined"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={toggleRegisterPasswordVisibility}
edge="end"
disabled={loading}
>
{showRegisterPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
{/* Password Requirements */}
{registerForm.password.length > 0 && (
<Box sx={{ mt: 1, mb: 1 }}>
<Button
onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
size="small"
sx={{ mb: 1 }}
>
Password Requirements
</Button>
<Collapse in={showPasswordRequirements}>
<Paper variant="outlined" sx={{ p: 2 }}>
<List dense>
{passwordRequirements.map((requirement, index) => (
<ListItem key={index} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
{requirement.met ? (
<CheckCircle color="success" fontSize="small" />
) : (
<Cancel color="error" fontSize="small" />
)}
</ListItemIcon>
<ListItemText
primary={requirement.label}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
color: requirement.met ? 'success.main' : 'error.main'
}
}}
/>
</ListItem>
))}
</List>
</Paper>
</Collapse>
</Box>
)}
<TextField
fullWidth
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
value={registerForm.confirmPassword}
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
error={hasPasswordMatchError}
helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle confirm password visibility"
onClick={toggleConfirmPasswordVisibility}
edge="end"
disabled={loading}
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
>
{loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
</Button>
</>
)}
</Box>
)}
</Paper>
</Container>
);
};
export { LoginPage };