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
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:
2
packages/web/.env.example
Normal file
2
packages/web/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:3001
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<h1 className="logo"><Link to="/">🌿 Basil</Link></h1>
|
||||
<nav>
|
||||
<Link to="/">Cookbooks</Link>
|
||||
<Link to="/recipes">All Recipes</Link>
|
||||
<Link to="/recipes/new">New Recipe</Link>
|
||||
<Link to="/recipes/import">Import Recipe</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<AuthProvider>
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<h1 className="logo"><Link to="/">🌿 Basil</Link></h1>
|
||||
<nav>
|
||||
<Link to="/">Cookbooks</Link>
|
||||
<Link to="/recipes">All Recipes</Link>
|
||||
<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 />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
<main className="main">
|
||||
<div className="container">
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<p>Basil - Your Recipe Manager</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<p>Basil - Your Recipe Manager</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
46
packages/web/src/components/ProtectedRoute.tsx
Normal file
46
packages/web/src/components/ProtectedRoute.tsx
Normal 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;
|
||||
127
packages/web/src/components/UserMenu.tsx
Normal file
127
packages/web/src/components/UserMenu.tsx
Normal 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;
|
||||
143
packages/web/src/contexts/AuthContext.tsx
Normal file
143
packages/web/src/contexts/AuthContext.tsx
Normal 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;
|
||||
77
packages/web/src/pages/AuthCallback.tsx
Normal file
77
packages/web/src/pages/AuthCallback.tsx
Normal 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;
|
||||
125
packages/web/src/pages/Login.tsx
Normal file
125
packages/web/src/pages/Login.tsx
Normal 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;
|
||||
211
packages/web/src/pages/Register.tsx
Normal file
211
packages/web/src/pages/Register.tsx
Normal 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;
|
||||
275
packages/web/src/services/auth.service.ts
Normal file
275
packages/web/src/services/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
257
packages/web/src/styles/Auth.css
Normal file
257
packages/web/src/styles/Auth.css
Normal 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;
|
||||
}
|
||||
}
|
||||
157
packages/web/src/styles/UserMenu.css
Normal file
157
packages/web/src/styles/UserMenu.css
Normal 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;
|
||||
}
|
||||
70
packages/web/src/types/auth.ts
Normal file
70
packages/web/src/types/auth.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user