feat: add frontend authentication with React context and protected routes
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled

Implement complete frontend authentication system with login, registration,
OAuth callback handling, and protected routes.

Frontend Features:
- AuthContext with React Context API for global auth state
- Auto token refresh (every 14 minutes)
- Persistent authentication with localStorage
- Protected route wrapper component
- Beautiful authentication UI with green theme

Pages:
- Login page with email/password and Google OAuth button
- Register page with password validation
- OAuth callback handler for Google sign-in
- User menu dropdown with profile and logout

Components:
- ProtectedRoute - Redirects unauthenticated users to login
- UserMenu - Dropdown with user info, navigation, and logout
- Admin role support in route protection

Services:
- auth.service.ts - Complete API integration
  - Login, register, logout
  - Token management and refresh
  - Get current user
  - Password reset (backend ready)
  - Email verification (backend ready)
  - Google OAuth redirect

State Management:
- Global auth context with useAuth hook
- User state persistence
- Loading states
- Auto token refresh mechanism
- Secure token storage

Styling:
- Auth.css - Login/register pages with gradient background
- UserMenu.css - Dropdown menu styling
- Updated App.css - Header layout for user menu

Routes Protected:
- All existing routes now require authentication
- Redirect to login with return URL
- Support for admin-only routes

Environment:
- VITE_API_URL configuration
- .env.example added

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 05:54:26 +00:00
parent 2d53b9e283
commit 69677901d6
13 changed files with 1617 additions and 37 deletions

View File

@@ -0,0 +1,2 @@
# API Configuration
VITE_API_URL=http://localhost:3001

View File

@@ -35,6 +35,7 @@ body {
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
}
.logo {
@@ -164,12 +165,18 @@ nav a:hover {
}
.servings-control {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.servings-adjuster {
display: flex;
align-items: center;
gap: 0.5rem;
}
.servings-control button {
.servings-adjuster button {
width: 32px;
height: 32px;
padding: 0;
@@ -186,16 +193,16 @@ nav a:hover {
transition: background-color 0.2s;
}
.servings-control button:hover:not(:disabled) {
.servings-adjuster button:hover:not(:disabled) {
background-color: #3d6821;
}
.servings-control button:disabled {
.servings-adjuster button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.servings-control .reset-button {
.servings-adjuster .reset-button {
width: auto;
height: auto;
padding: 0.25rem 0.75rem;
@@ -204,6 +211,38 @@ nav a:hover {
background-color: #666;
}
.quick-scale-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.quick-scale-buttons .scale-button {
width: auto;
height: auto;
min-width: 50px;
padding: 0.4rem 0.75rem;
font-size: 0.9rem;
font-weight: 600;
border-radius: 4px;
background-color: #4a7c2d;
color: white;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.quick-scale-buttons .scale-button:hover {
background-color: #5a8c3d;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.quick-scale-buttons .scale-button:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.servings-control .reset-button:hover {
background-color: #777;
}
@@ -428,6 +467,42 @@ button:disabled {
gap: 0.75rem;
margin-bottom: 1rem;
align-items: flex-start;
background-color: white;
padding: 0.75rem;
border-radius: 6px;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.instruction-row:hover {
border-color: #e0e0e0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.instruction-row.dragging {
opacity: 0.5;
background-color: #f5f5f5;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
border-color: #2d5016;
}
.instruction-drag-handle {
cursor: grab;
color: #999;
font-size: 1.2rem;
display: flex;
align-items: center;
padding: 0.25rem;
user-select: none;
margin-top: 0.5rem;
}
.instruction-drag-handle:hover {
color: #2d5016;
}
.instruction-drag-handle:active {
cursor: grabbing;
}
.instruction-number {

View File

@@ -1,4 +1,10 @@
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import UserMenu from './components/UserMenu';
import Login from './pages/Login';
import Register from './pages/Register';
import AuthCallback from './pages/AuthCallback';
import Cookbooks from './pages/Cookbooks';
import CookbookDetail from './pages/CookbookDetail';
import EditCookbook from './pages/EditCookbook';
@@ -13,6 +19,7 @@ import './App.css';
function App() {
return (
<Router>
<AuthProvider>
<div className="app">
<header className="header">
<div className="container">
@@ -23,21 +30,28 @@ function App() {
<Link to="/recipes/new">New Recipe</Link>
<Link to="/recipes/import">Import Recipe</Link>
</nav>
<UserMenu />
</div>
</header>
<main className="main">
<div className="container">
<Routes>
<Route path="/" element={<Cookbooks />} />
<Route path="/cookbooks/:id" element={<CookbookDetail />} />
<Route path="/cookbooks/:id/edit" element={<EditCookbook />} />
<Route path="/recipes" element={<RecipeList />} />
<Route path="/recipes/:id" element={<RecipeDetail />} />
<Route path="/recipes/:id/edit" element={<UnifiedEditRecipe />} />
<Route path="/recipes/:id/cook" element={<CookingMode />} />
<Route path="/recipes/new" element={<NewRecipe />} />
<Route path="/recipes/import" element={<RecipeImport />} />
{/* Public Routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/auth/callback" element={<AuthCallback />} />
{/* Protected Routes */}
<Route path="/" element={<ProtectedRoute><Cookbooks /></ProtectedRoute>} />
<Route path="/cookbooks/:id" element={<ProtectedRoute><CookbookDetail /></ProtectedRoute>} />
<Route path="/cookbooks/:id/edit" element={<ProtectedRoute><EditCookbook /></ProtectedRoute>} />
<Route path="/recipes" element={<ProtectedRoute><RecipeList /></ProtectedRoute>} />
<Route path="/recipes/:id" element={<ProtectedRoute><RecipeDetail /></ProtectedRoute>} />
<Route path="/recipes/:id/edit" element={<ProtectedRoute><UnifiedEditRecipe /></ProtectedRoute>} />
<Route path="/recipes/:id/cook" element={<ProtectedRoute><CookingMode /></ProtectedRoute>} />
<Route path="/recipes/new" element={<ProtectedRoute><NewRecipe /></ProtectedRoute>} />
<Route path="/recipes/import" element={<ProtectedRoute><RecipeImport /></ProtectedRoute>} />
</Routes>
</div>
</main>
@@ -48,6 +62,7 @@ function App() {
</div>
</footer>
</div>
</AuthProvider>
</Router>
);
}

View File

@@ -0,0 +1,46 @@
/**
* Protected Route Component
* Redirects to login if not authenticated
*/
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requireAdmin?: boolean;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requireAdmin = false }) => {
const { isAuthenticated, isAdmin, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
// Redirect to login and save the attempted location
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requireAdmin && !isAdmin) {
// User is authenticated but not an admin
return (
<div className="error-container">
<h2>Access Denied</h2>
<p>You don't have permission to access this page.</p>
</div>
);
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,127 @@
/**
* User Menu Component
* Displays user info and logout button in navbar
*/
import React, { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import '../styles/UserMenu.css';
const UserMenu: React.FC = () => {
const { user, logout, isAdmin } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
if (!user) {
return (
<div className="user-menu-auth-links">
<Link to="/login" className="auth-link">Sign In</Link>
<Link to="/register" className="auth-link primary">Sign Up</Link>
</div>
);
}
const getInitials = (name?: string | null, email?: string) => {
if (name) {
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
}
return email ? email[0].toUpperCase() : 'U';
};
const handleLogout = () => {
logout();
setIsOpen(false);
};
return (
<div className="user-menu" ref={menuRef}>
<button
className="user-menu-button"
onClick={() => setIsOpen(!isOpen)}
aria-label="User menu"
>
{user.avatar ? (
<img src={user.avatar} alt={user.name || user.email} className="user-avatar" />
) : (
<div className="user-avatar-placeholder">
{getInitials(user.name, user.email)}
</div>
)}
</button>
{isOpen && (
<div className="user-menu-dropdown">
<div className="user-menu-header">
<div className="user-info">
<strong>{user.name || 'User'}</strong>
<small>{user.email}</small>
{isAdmin && <span className="admin-badge">Admin</span>}
</div>
</div>
<div className="user-menu-divider"></div>
<nav className="user-menu-links">
<Link
to="/profile"
className="user-menu-link"
onClick={() => setIsOpen(false)}
>
Profile Settings
</Link>
<Link
to="/my-recipes"
className="user-menu-link"
onClick={() => setIsOpen(false)}
>
My Recipes
</Link>
<Link
to="/my-cookbooks"
className="user-menu-link"
onClick={() => setIsOpen(false)}
>
My Cookbooks
</Link>
{isAdmin && (
<>
<div className="user-menu-divider"></div>
<Link
to="/admin"
className="user-menu-link admin"
onClick={() => setIsOpen(false)}
>
Admin Dashboard
</Link>
</>
)}
</nav>
<div className="user-menu-divider"></div>
<button
className="user-menu-logout"
onClick={handleLogout}
>
Sign Out
</button>
</div>
)}
</div>
);
};
export default UserMenu;

View File

@@ -0,0 +1,143 @@
/**
* Authentication Context
* Provides global auth state and functions
*/
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import {
User,
LoginCredentials,
RegisterCredentials,
AuthContextType,
} from '../types/auth';
import { authService, tokenService, handleTokenRefresh } from '../services/auth.service';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// Initialize auth state from localStorage
useEffect(() => {
const initAuth = async () => {
const storedUser = tokenService.getUser();
const accessToken = tokenService.getAccessToken();
if (storedUser && accessToken) {
try {
// Verify token is still valid by fetching current user
const currentUser = await authService.getCurrentUser();
setUser(currentUser);
} catch (error) {
console.error('Failed to verify token:', error);
// Try to refresh token
const refreshed = await handleTokenRefresh();
if (refreshed) {
try {
const currentUser = await authService.getCurrentUser();
setUser(currentUser);
} catch (err) {
// Token refresh failed, clear auth
tokenService.clearTokens();
setUser(null);
}
} else {
tokenService.clearTokens();
setUser(null);
}
}
}
setLoading(false);
};
initAuth();
}, []);
// Auto-refresh token before it expires (every 14 minutes for 15min tokens)
useEffect(() => {
if (!user) return;
const refreshInterval = setInterval(async () => {
try {
await handleTokenRefresh();
} catch (error) {
console.error('Auto token refresh failed:', error);
// If refresh fails, log out user
logout();
}
}, 14 * 60 * 1000); // 14 minutes
return () => clearInterval(refreshInterval);
}, [user]);
const login = async (credentials: LoginCredentials): Promise<void> => {
setLoading(true);
try {
const response = await authService.login(credentials);
setUser(response.user);
} catch (error) {
setLoading(false);
throw error;
}
setLoading(false);
};
const register = async (credentials: RegisterCredentials): Promise<void> => {
setLoading(true);
try {
await authService.register(credentials);
// Note: After registration, user needs to verify email before logging in
} catch (error) {
setLoading(false);
throw error;
}
setLoading(false);
};
const logout = (): void => {
authService.logout();
setUser(null);
};
const refreshAuth = async (): Promise<void> => {
try {
const currentUser = await authService.getCurrentUser();
setUser(currentUser);
} catch (error) {
console.error('Failed to refresh auth:', error);
throw error;
}
};
const value: AuthContextType = {
user,
loading,
login,
register,
logout,
refreshAuth,
isAuthenticated: !!user,
isAdmin: user?.role === 'ADMIN',
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
/**
* Custom hook to use auth context
*/
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export default AuthContext;

View File

@@ -0,0 +1,77 @@
/**
* OAuth Callback Handler
* Handles Google OAuth redirect and token storage
*/
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { tokenService } from '../services/auth.service';
import { useAuth } from '../contexts/AuthContext';
const AuthCallback: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { refreshAuth } = useAuth();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handleCallback = async () => {
const accessToken = searchParams.get('accessToken');
const refreshToken = searchParams.get('refreshToken');
const errorParam = searchParams.get('error');
if (errorParam) {
setError('Authentication failed. Please try again.');
setTimeout(() => navigate('/login'), 3000);
return;
}
if (accessToken && refreshToken) {
// Store tokens
tokenService.setTokens(accessToken, refreshToken);
try {
// Refresh auth context to load user
await refreshAuth();
// Redirect to home
navigate('/', { replace: true });
} catch (err) {
setError('Failed to complete authentication');
setTimeout(() => navigate('/login'), 3000);
}
} else {
setError('Invalid authentication response');
setTimeout(() => navigate('/login'), 3000);
}
};
handleCallback();
}, [searchParams, navigate, refreshAuth]);
if (error) {
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-error">
<h2>Authentication Error</h2>
<p>{error}</p>
<p>Redirecting to login...</p>
</div>
</div>
</div>
);
}
return (
<div className="auth-container">
<div className="auth-card">
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Completing sign in...</p>
</div>
</div>
</div>
);
};
export default AuthCallback;

View File

@@ -0,0 +1,125 @@
/**
* Login Page
*/
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { authService } from '../services/auth.service';
import '../styles/Auth.css';
const Login: React.FC = () => {
const navigate = useNavigate();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login({ email, password });
navigate('/');
} catch (err: any) {
setError(err.message || 'Login failed');
} finally {
setLoading(false);
}
};
const handleGoogleLogin = () => {
authService.googleLogin();
};
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<h1>🌿 Basil</h1>
<h2>Welcome Back</h2>
<p>Sign in to your recipe collection</p>
</div>
{error && (
<div className="auth-error">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="your@email.com"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
placeholder="••••••••"
/>
</div>
<div className="form-footer">
<Link to="/forgot-password" className="forgot-password-link">
Forgot password?
</Link>
</div>
<button
type="submit"
className="auth-button primary"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="auth-divider">
<span>or</span>
</div>
<button
type="button"
onClick={handleGoogleLogin}
className="auth-button google"
>
<svg className="google-icon" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</button>
<div className="auth-links">
<p>
Don't have an account?{' '}
<Link to="/register">Sign up</Link>
</p>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,211 @@
/**
* Register Page
*/
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { authService } from '../services/auth.service';
import '../styles/Auth.css';
const Register: React.FC = () => {
const navigate = useNavigate();
const { register } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const validatePassword = (pwd: string): string[] => {
const errors: string[] = [];
if (pwd.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(pwd)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(pwd)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(pwd)) {
errors.push('Password must contain at least one number');
}
return errors;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validate passwords match
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
// Validate password strength
const passwordErrors = validatePassword(password);
if (passwordErrors.length > 0) {
setError(passwordErrors.join('. '));
return;
}
setLoading(true);
try {
await register({ email, password, name });
setSuccess(true);
} catch (err: any) {
setError(err.message || 'Registration failed');
setLoading(false);
}
};
const handleGoogleSignup = () => {
authService.googleLogin();
};
if (success) {
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<h1>🌿 Basil</h1>
<h2>Registration Successful!</h2>
</div>
<div className="auth-success">
<p>
We've sent a verification email to <strong>{email}</strong>.
</p>
<p>
Please check your inbox and click the verification link to activate your account.
</p>
</div>
<div className="auth-links">
<p>
Already verified?{' '}
<Link to="/login">Sign in</Link>
</p>
</div>
</div>
</div>
);
}
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<h1>🌿 Basil</h1>
<h2>Create Account</h2>
<p>Start your recipe collection journey</p>
</div>
{error && (
<div className="auth-error">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label htmlFor="name">Name (optional)</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
placeholder="Your name"
/>
</div>
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="your@email.com"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password *</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder="••••••••"
/>
<small className="form-help">
Min 8 characters, with uppercase, lowercase, and number
</small>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password *</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
placeholder="••••••••"
/>
</div>
<button
type="submit"
className="auth-button primary"
disabled={loading}
>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>
<div className="auth-divider">
<span>or</span>
</div>
<button
type="button"
onClick={handleGoogleSignup}
className="auth-button google"
>
<svg className="google-icon" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign up with Google
</button>
<div className="auth-links">
<p>
Already have an account?{' '}
<Link to="/login">Sign in</Link>
</p>
</div>
</div>
</div>
);
};
export default Register;

View File

@@ -0,0 +1,275 @@
/**
* Authentication Service
* Handles all auth-related API calls and token management
*/
import {
User,
LoginCredentials,
RegisterCredentials,
LoginResponse,
RegisterResponse,
ForgotPasswordRequest,
ResetPasswordRequest,
} from '../types/auth';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const AUTH_ENDPOINT = `${API_URL}/api/auth`;
// Token storage keys
const ACCESS_TOKEN_KEY = 'basil_access_token';
const REFRESH_TOKEN_KEY = 'basil_refresh_token';
const USER_KEY = 'basil_user';
/**
* Token Management
*/
export const tokenService = {
getAccessToken: (): string | null => {
return localStorage.getItem(ACCESS_TOKEN_KEY);
},
getRefreshToken: (): string | null => {
return localStorage.getItem(REFRESH_TOKEN_KEY);
},
setTokens: (accessToken: string, refreshToken: string): void => {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
},
clearTokens: (): void => {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
},
saveUser: (user: User): void => {
localStorage.setItem(USER_KEY, JSON.stringify(user));
},
getUser: (): User | null => {
const userStr = localStorage.getItem(USER_KEY);
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
},
};
/**
* HTTP Helper with auth headers
*/
async function fetchWithAuth(
url: string,
options: RequestInit = {}
): Promise<Response> {
const token = tokenService.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(url, {
...options,
headers,
});
}
/**
* Authentication API Service
*/
export const authService = {
/**
* Register a new user
*/
async register(credentials: RegisterCredentials): Promise<RegisterResponse> {
const response = await fetch(`${AUTH_ENDPOINT}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || error.error || 'Registration failed');
}
return response.json();
},
/**
* Login with email and password
*/
async login(credentials: LoginCredentials): Promise<LoginResponse> {
const response = await fetch(`${AUTH_ENDPOINT}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || error.error || 'Login failed');
}
const data: LoginResponse = await response.json();
// Store tokens and user
tokenService.setTokens(data.accessToken, data.refreshToken);
tokenService.saveUser(data.user);
return data;
},
/**
* Logout
*/
async logout(): Promise<void> {
const refreshToken = tokenService.getRefreshToken();
try {
await fetchWithAuth(`${AUTH_ENDPOINT}/logout`, {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
} catch (error) {
console.error('Logout error:', error);
} finally {
tokenService.clearTokens();
}
},
/**
* Get current user info
*/
async getCurrentUser(): Promise<User> {
const response = await fetchWithAuth(`${AUTH_ENDPOINT}/me`);
if (!response.ok) {
throw new Error('Failed to get user info');
}
const data = await response.json();
const user = data.user;
// Update stored user
tokenService.saveUser(user);
return user;
},
/**
* Refresh access token
*/
async refreshToken(): Promise<string> {
const refreshToken = tokenService.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await fetch(`${AUTH_ENDPOINT}/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
tokenService.clearTokens();
throw new Error('Token refresh failed');
}
const data = await response.json();
localStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken);
return data.accessToken;
},
/**
* Request password reset
*/
async forgotPassword(request: ForgotPasswordRequest): Promise<void> {
const response = await fetch(`${AUTH_ENDPOINT}/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Password reset request failed');
}
},
/**
* Reset password with token
*/
async resetPassword(request: ResetPasswordRequest): Promise<void> {
const response = await fetch(`${AUTH_ENDPOINT}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Password reset failed');
}
},
/**
* Verify email with token
*/
async verifyEmail(token: string): Promise<void> {
const response = await fetch(`${AUTH_ENDPOINT}/verify-email/${token}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Email verification failed');
}
},
/**
* Resend verification email
*/
async resendVerification(email: string): Promise<void> {
const response = await fetch(`${AUTH_ENDPOINT}/resend-verification`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to resend verification email');
}
},
/**
* Initiate Google OAuth login
*/
googleLogin(): void {
window.location.href = `${AUTH_ENDPOINT}/google`;
},
};
/**
* Auto-refresh token interceptor
* Call this when you get a 401 response
*/
export async function handleTokenRefresh(): Promise<boolean> {
try {
await authService.refreshToken();
return true;
} catch (error) {
tokenService.clearTokens();
return false;
}
}

View File

@@ -0,0 +1,257 @@
/**
* Authentication Pages Styling
*/
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2d5016 0%, #4a7c2d 100%);
padding: 2rem 1rem;
}
.auth-card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
max-width: 420px;
width: 100%;
padding: 2.5rem;
}
.auth-header {
text-align: center;
margin-bottom: 2rem;
}
.auth-header h1 {
font-size: 2.5rem;
margin: 0 0 0.5rem 0;
color: #2d5016;
}
.auth-header h2 {
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
color: #333;
}
.auth-header p {
color: #666;
margin: 0;
}
.auth-error {
background-color: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.auth-success {
background-color: #efe;
border: 1px solid #cfc;
color: #363;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
}
.auth-success p {
margin: 0.5rem 0;
}
.auth-form {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #4a7c2d;
box-shadow: 0 0 0 3px rgba(74, 124, 45, 0.1);
}
.form-help {
display: block;
margin-top: 0.25rem;
color: #666;
font-size: 0.8rem;
}
.form-footer {
margin-bottom: 1rem;
text-align: right;
}
.forgot-password-link {
color: #4a7c2d;
text-decoration: none;
font-size: 0.9rem;
}
.forgot-password-link:hover {
text-decoration: underline;
}
.auth-button {
width: 100%;
padding: 0.875rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.auth-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-button.primary {
background-color: #2d5016;
color: white;
}
.auth-button.primary:hover:not(:disabled) {
background-color: #3d6020;
}
.auth-button.google {
background-color: white;
color: #333;
border: 1px solid #ddd;
}
.auth-button.google:hover:not(:disabled) {
background-color: #f8f8f8;
border-color: #ccc;
}
.google-icon {
width: 20px;
height: 20px;
}
.auth-divider {
margin: 1.5rem 0;
text-align: center;
position: relative;
}
.auth-divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background-color: #ddd;
}
.auth-divider span {
position: relative;
background-color: white;
padding: 0 1rem;
color: #666;
font-size: 0.9rem;
}
.auth-links {
text-align: center;
margin-top: 1.5rem;
}
.auth-links p {
color: #666;
font-size: 0.9rem;
margin: 0;
}
.auth-links a {
color: #4a7c2d;
text-decoration: none;
font-weight: 600;
}
.auth-links a:hover {
text-decoration: underline;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #4a7c2d;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-container {
text-align: center;
padding: 3rem 2rem;
}
.error-container h2 {
color: #c33;
margin-bottom: 1rem;
}
@media (max-width: 480px) {
.auth-card {
padding: 2rem 1.5rem;
}
.auth-header h1 {
font-size: 2rem;
}
.auth-header h2 {
font-size: 1.25rem;
}
}

View File

@@ -0,0 +1,157 @@
/**
* User Menu Styling
*/
.user-menu {
position: relative;
}
.user-menu-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
border-radius: 50%;
overflow: hidden;
width: 40px;
height: 40px;
}
.user-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #4a7c2d;
color: white;
font-weight: 600;
font-size: 0.9rem;
}
.user-menu-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 240px;
z-index: 1000;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.user-menu-header {
padding: 1rem;
}
.user-info strong {
display: block;
color: #333;
margin-bottom: 0.25rem;
}
.user-info small {
display: block;
color: #666;
font-size: 0.85rem;
}
.admin-badge {
display: inline-block;
background-color: #4a7c2d;
color: white;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
margin-top: 0.5rem;
text-transform: uppercase;
}
.user-menu-divider {
height: 1px;
background-color: #eee;
margin: 0.5rem 0;
}
.user-menu-links {
padding: 0.5rem 0;
}
.user-menu-link {
display: block;
padding: 0.75rem 1rem;
color: #333;
text-decoration: none;
transition: background-color 0.2s;
}
.user-menu-link:hover {
background-color: #f5f5f5;
}
.user-menu-link.admin {
color: #4a7c2d;
font-weight: 600;
}
.user-menu-logout {
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
color: #c33;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: background-color 0.2s;
}
.user-menu-logout:hover {
background-color: #fee;
}
.user-menu-auth-links {
display: flex;
gap: 1rem;
}
.auth-link {
padding: 0.5rem 1rem;
text-decoration: none;
color: #333;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s;
}
.auth-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.auth-link.primary {
background-color: #2d5016;
color: white;
}
.auth-link.primary:hover {
background-color: #3d6020;
}

View File

@@ -0,0 +1,70 @@
/**
* Authentication types for frontend
*/
export interface User {
id: string;
email: string;
name?: string | null;
username?: string | null;
avatar?: string | null;
role: 'USER' | 'ADMIN';
provider: string;
emailVerified: boolean;
createdAt: string;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterCredentials {
email: string;
password: string;
name?: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface LoginResponse extends AuthTokens {
message: string;
user: User;
}
export interface RegisterResponse {
message: string;
user: {
id: string;
email: string;
name?: string | null;
};
}
export interface AuthContextType {
user: User | null;
loading: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
register: (credentials: RegisterCredentials) => Promise<void>;
logout: () => void;
refreshAuth: () => Promise<void>;
isAuthenticated: boolean;
isAdmin: boolean;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ResetPasswordRequest {
token: string;
password: string;
}
export interface PasswordStrengthResult {
valid: boolean;
errors: string[];
}