From 69677901d618c58b371174fbd50b1d92982f50e9 Mon Sep 17 00:00:00 2001 From: Paul R Kartchner Date: Tue, 25 Nov 2025 05:54:26 +0000 Subject: [PATCH] feat: add frontend authentication with React context and protected routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/web/.env.example | 2 + packages/web/src/App.css | 83 +++++- packages/web/src/App.tsx | 81 +++--- .../web/src/components/ProtectedRoute.tsx | 46 +++ packages/web/src/components/UserMenu.tsx | 127 ++++++++ packages/web/src/contexts/AuthContext.tsx | 143 +++++++++ packages/web/src/pages/AuthCallback.tsx | 77 +++++ packages/web/src/pages/Login.tsx | 125 ++++++++ packages/web/src/pages/Register.tsx | 211 ++++++++++++++ packages/web/src/services/auth.service.ts | 275 ++++++++++++++++++ packages/web/src/styles/Auth.css | 257 ++++++++++++++++ packages/web/src/styles/UserMenu.css | 157 ++++++++++ packages/web/src/types/auth.ts | 70 +++++ 13 files changed, 1617 insertions(+), 37 deletions(-) create mode 100644 packages/web/.env.example create mode 100644 packages/web/src/components/ProtectedRoute.tsx create mode 100644 packages/web/src/components/UserMenu.tsx create mode 100644 packages/web/src/contexts/AuthContext.tsx create mode 100644 packages/web/src/pages/AuthCallback.tsx create mode 100644 packages/web/src/pages/Login.tsx create mode 100644 packages/web/src/pages/Register.tsx create mode 100644 packages/web/src/services/auth.service.ts create mode 100644 packages/web/src/styles/Auth.css create mode 100644 packages/web/src/styles/UserMenu.css create mode 100644 packages/web/src/types/auth.ts diff --git a/packages/web/.env.example b/packages/web/.env.example new file mode 100644 index 0000000..8608d23 --- /dev/null +++ b/packages/web/.env.example @@ -0,0 +1,2 @@ +# API Configuration +VITE_API_URL=http://localhost:3001 diff --git a/packages/web/src/App.css b/packages/web/src/App.css index 4883a3f..0235a81 100644 --- a/packages/web/src/App.css +++ b/packages/web/src/App.css @@ -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 { diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 1515c00..ba19d9a 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -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,41 +19,50 @@ import './App.css'; function App() { return ( -
-
-
-

🌿 Basil

- -
-
+ +
+
+
+

🌿 Basil

+ + +
+
-
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
+
+
+ + {/* Public Routes */} + } /> + } /> + } /> -
-
-

Basil - Your Recipe Manager

-
-
-
+ {/* Protected Routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + +
+
+

Basil - Your Recipe Manager

+
+
+
+
); } diff --git a/packages/web/src/components/ProtectedRoute.tsx b/packages/web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..0e2f588 --- /dev/null +++ b/packages/web/src/components/ProtectedRoute.tsx @@ -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 = ({ children, requireAdmin = false }) => { + const { isAuthenticated, isAdmin, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+
+

Loading...

+
+ ); + } + + if (!isAuthenticated) { + // Redirect to login and save the attempted location + return ; + } + + if (requireAdmin && !isAdmin) { + // User is authenticated but not an admin + return ( +
+

Access Denied

+

You don't have permission to access this page.

+
+ ); + } + + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/packages/web/src/components/UserMenu.tsx b/packages/web/src/components/UserMenu.tsx new file mode 100644 index 0000000..acbd0f7 --- /dev/null +++ b/packages/web/src/components/UserMenu.tsx @@ -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(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 ( +
+ Sign In + Sign Up +
+ ); + } + + 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 ( +
+ + + {isOpen && ( +
+
+
+ {user.name || 'User'} + {user.email} + {isAdmin && Admin} +
+
+ +
+ + + +
+ + +
+ )} +
+ ); +}; + +export default UserMenu; diff --git a/packages/web/src/contexts/AuthContext.tsx b/packages/web/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..f584270 --- /dev/null +++ b/packages/web/src/contexts/AuthContext.tsx @@ -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(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(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 => { + 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 => { + 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 => { + 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 {children}; +}; + +/** + * 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; diff --git a/packages/web/src/pages/AuthCallback.tsx b/packages/web/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..4238ba2 --- /dev/null +++ b/packages/web/src/pages/AuthCallback.tsx @@ -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(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 ( +
+
+
+

Authentication Error

+

{error}

+

Redirecting to login...

+
+
+
+ ); + } + + return ( +
+
+
+
+

Completing sign in...

+
+
+
+ ); +}; + +export default AuthCallback; diff --git a/packages/web/src/pages/Login.tsx b/packages/web/src/pages/Login.tsx new file mode 100644 index 0000000..872982b --- /dev/null +++ b/packages/web/src/pages/Login.tsx @@ -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 ( +
+
+
+

🌿 Basil

+

Welcome Back

+

Sign in to your recipe collection

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + placeholder="your@email.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + placeholder="••••••••" + /> +
+ +
+ + Forgot password? + +
+ + +
+ +
+ or +
+ + + +
+

+ Don't have an account?{' '} + Sign up +

+
+
+
+ ); +}; + +export default Login; diff --git a/packages/web/src/pages/Register.tsx b/packages/web/src/pages/Register.tsx new file mode 100644 index 0000000..3235c26 --- /dev/null +++ b/packages/web/src/pages/Register.tsx @@ -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 ( +
+
+
+

🌿 Basil

+

Registration Successful!

+
+ +
+

+ We've sent a verification email to {email}. +

+

+ Please check your inbox and click the verification link to activate your account. +

+
+ +
+

+ Already verified?{' '} + Sign in +

+
+
+
+ ); + } + + return ( +
+
+
+

🌿 Basil

+

Create Account

+

Start your recipe collection journey

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setName(e.target.value)} + autoComplete="name" + placeholder="Your name" + /> +
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + placeholder="your@email.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + placeholder="••••••••" + /> + + Min 8 characters, with uppercase, lowercase, and number + +
+ +
+ + setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + placeholder="••••••••" + /> +
+ + +
+ +
+ or +
+ + + +
+

+ Already have an account?{' '} + Sign in +

+
+
+
+ ); +}; + +export default Register; diff --git a/packages/web/src/services/auth.service.ts b/packages/web/src/services/auth.service.ts new file mode 100644 index 0000000..8b27d70 --- /dev/null +++ b/packages/web/src/services/auth.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await authService.refreshToken(); + return true; + } catch (error) { + tokenService.clearTokens(); + return false; + } +} diff --git a/packages/web/src/styles/Auth.css b/packages/web/src/styles/Auth.css new file mode 100644 index 0000000..6ef6f57 --- /dev/null +++ b/packages/web/src/styles/Auth.css @@ -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; + } +} diff --git a/packages/web/src/styles/UserMenu.css b/packages/web/src/styles/UserMenu.css new file mode 100644 index 0000000..bc904bf --- /dev/null +++ b/packages/web/src/styles/UserMenu.css @@ -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; +} diff --git a/packages/web/src/types/auth.ts b/packages/web/src/types/auth.ts new file mode 100644 index 0000000..c16e40e --- /dev/null +++ b/packages/web/src/types/auth.ts @@ -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; + register: (credentials: RegisterCredentials) => Promise; + logout: () => void; + refreshAuth: () => Promise; + isAuthenticated: boolean; + isAdmin: boolean; +} + +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + token: string; + password: string; +} + +export interface PasswordStrengthResult { + valid: boolean; + errors: string[]; +}