feat: Complete implementation of Tasks 1-5 - CIM Document Processor
Backend Infrastructure: - Complete Express server setup with security middleware (helmet, CORS, rate limiting) - Comprehensive error handling and logging with Winston - Authentication system with JWT tokens and session management - Database models and migrations for Users, Documents, Feedback, and Processing Jobs - API routes structure for authentication and document management - Integration tests for all server components (86 tests passing) Frontend Infrastructure: - React application with TypeScript and Vite - Authentication UI with login form, protected routes, and logout functionality - Authentication context with proper async state management - Component tests with proper async handling (25 tests passing) - Tailwind CSS styling and responsive design Key Features: - User registration, login, and authentication - Protected routes with role-based access control - Comprehensive error handling and user feedback - Database schema with proper relationships - Security middleware and validation - Production-ready build configuration Test Coverage: 111/111 tests passing Tasks Completed: 1-5 (Project setup, Database, Auth system, Frontend UI, Backend infrastructure) Ready for Task 6: File upload backend infrastructure
This commit is contained in:
5
frontend/.env.example
Normal file
5
frontend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Frontend Environment Variables
|
||||
VITE_API_BASE_URL=http://localhost:5000/api
|
||||
VITE_APP_NAME=CIM Document Processor
|
||||
VITE_MAX_FILE_SIZE=104857600
|
||||
VITE_ALLOWED_FILE_TYPES=application/pdf
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CIM Document Processor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6705
frontend/package-lock.json
generated
Normal file
6705
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "cim-processor-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.1.4",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.0",
|
||||
"vitest": "^0.34.6"
|
||||
}
|
||||
}
|
||||
119
frontend/src/App.tsx
Normal file
119
frontend/src/App.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LogoutButton from './components/LogoutButton';
|
||||
|
||||
// Simple dashboard component for demonstration
|
||||
const Dashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<nav className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
CIM Document Processor
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
Welcome, {user?.name || user?.email}
|
||||
</span>
|
||||
<LogoutButton variant="link" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="border-4 border-dashed border-gray-200 rounded-lg h-96 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-medium text-gray-900 mb-4">
|
||||
Dashboard
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Welcome to the CIM Document Processor dashboard.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Role: {user?.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Login page component
|
||||
const LoginPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Redirect to dashboard if already authenticated
|
||||
if (user) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
CIM Document Processor
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Unauthorized page component
|
||||
const UnauthorizedPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You don't have permission to access this resource.
|
||||
</p>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
168
frontend/src/components/LoginForm.tsx
Normal file
168
frontend/src/components/LoginForm.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { validateLoginForm } from '../utils/validation';
|
||||
import { cn } from '../utils/cn';
|
||||
import { Eye, EyeOff, LogIn } from 'lucide-react';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ onSuccess }) => {
|
||||
const { login, isLoading, error } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<{ email?: string; password?: string }>({});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
// Clear field-specific error when user starts typing
|
||||
if (formErrors[name as keyof typeof formErrors]) {
|
||||
setFormErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous errors
|
||||
setFormErrors({});
|
||||
|
||||
// Validate form
|
||||
const validation = validateLoginForm(formData.email, formData.password);
|
||||
if (!validation.isValid) {
|
||||
setFormErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(formData);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
// Error is handled by the auth context
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="bg-white shadow-lg rounded-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Sign In</h1>
|
||||
<p className="text-gray-600 mt-2">Access your CIM Document Processor</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
||||
formErrors.email ? "border-red-300" : "border-gray-300"
|
||||
)}
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{formErrors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{formErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 pr-10 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
||||
formErrors.password ? "border-red-300" : "border-gray-300"
|
||||
)}
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{formErrors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{formErrors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"w-full flex justify-center items-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white",
|
||||
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500",
|
||||
isLoading
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-blue-600 hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="h-4 w-4 mr-2" />
|
||||
Sign In
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
81
frontend/src/components/LogoutButton.tsx
Normal file
81
frontend/src/components/LogoutButton.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { cn } from '../utils/cn';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
interface LogoutButtonProps {
|
||||
className?: string;
|
||||
showConfirmation?: boolean;
|
||||
variant?: 'button' | 'link';
|
||||
}
|
||||
|
||||
export const LogoutButton: React.FC<LogoutButtonProps> = ({
|
||||
className,
|
||||
showConfirmation = true,
|
||||
variant = 'button',
|
||||
}) => {
|
||||
const { logout, isLoading } = useAuth();
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (showConfirmation && !showConfirmDialog) {
|
||||
setShowConfirmDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await logout();
|
||||
setShowConfirmDialog(false);
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowConfirmDialog(false);
|
||||
};
|
||||
|
||||
if (showConfirmDialog) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Confirm Logout</h3>
|
||||
<p className="text-gray-600 mb-6">Are you sure you want to sign out?</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Signing out...' : 'Sign Out'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const baseClasses = variant === 'button'
|
||||
? "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
||||
: "inline-flex items-center text-sm text-gray-700 hover:text-red-600 focus:outline-none focus:underline";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className={cn(baseClasses, className)}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{isLoading ? 'Signing out...' : 'Sign Out'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoutButton;
|
||||
42
frontend/src/components/ProtectedRoute.tsx
Normal file
42
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
requiredRole?: 'user' | 'admin';
|
||||
fallbackPath?: string;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requiredRole,
|
||||
fallbackPath = '/login',
|
||||
}) => {
|
||||
const { user, isLoading, isInitialized } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Show loading spinner while checking authentication
|
||||
if (isLoading || !isInitialized) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!user) {
|
||||
return <Navigate to={fallbackPath} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Check role-based access if required
|
||||
if (requiredRole && user.role !== requiredRole) {
|
||||
// If user doesn't have required role, redirect to unauthorized page or dashboard
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
341
frontend/src/components/__tests__/LoginForm.test.tsx
Normal file
341
frontend/src/components/__tests__/LoginForm.test.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import LoginForm from '../LoginForm';
|
||||
import { AuthProvider } from '../../contexts/AuthContext';
|
||||
import { authService } from '../../services/authService';
|
||||
|
||||
// Mock the auth service
|
||||
vi.mock('../../services/authService', () => ({
|
||||
authService: {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
getToken: vi.fn(),
|
||||
getCurrentUser: vi.fn(),
|
||||
validateToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const MockedAuthService = authService as any;
|
||||
|
||||
// Wrapper component for tests
|
||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
);
|
||||
|
||||
// Helper to wait for auth initialization
|
||||
const waitForAuthInit = async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
|
||||
}, { timeout: 5000 });
|
||||
};
|
||||
|
||||
describe('LoginForm', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Set up default mocks to prevent async initialization issues
|
||||
MockedAuthService.getToken.mockReturnValue(null);
|
||||
MockedAuthService.getCurrentUser.mockReturnValue(null);
|
||||
MockedAuthService.validateToken.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('renders login form with all required fields', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation errors for empty fields', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error for invalid email format', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form');
|
||||
|
||||
await act(async () => {
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
await user.type(passwordInput, 'password123');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/please enter a valid email address/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for short password', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form');
|
||||
|
||||
await act(async () => {
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, '123');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/password must be at least 6 characters long/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles password visibility', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement;
|
||||
const toggleButtons = screen.getAllByRole('button');
|
||||
const toggleButton = toggleButtons.find(button => button.getAttribute('type') === 'button' && !button.textContent?.includes('Sign'));
|
||||
|
||||
expect(passwordInput.type).toBe('password');
|
||||
|
||||
if (toggleButton) {
|
||||
await act(async () => {
|
||||
await user.click(toggleButton);
|
||||
});
|
||||
expect(passwordInput.type).toBe('text');
|
||||
|
||||
await act(async () => {
|
||||
await user.click(toggleButton);
|
||||
});
|
||||
expect(passwordInput.type).toBe('password');
|
||||
}
|
||||
});
|
||||
|
||||
it('clears field errors when user starts typing', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form');
|
||||
|
||||
// Trigger validation error
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Start typing to clear error
|
||||
await act(async () => {
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/email is required/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls login service with correct credentials', async () => {
|
||||
const mockAuthResult = {
|
||||
user: { id: '1', email: 'test@example.com', name: 'Test User', role: 'user' as const, createdAt: '2023-01-01', updatedAt: '2023-01-01' },
|
||||
token: 'mock-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
};
|
||||
|
||||
MockedAuthService.login.mockResolvedValue(mockAuthResult);
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form');
|
||||
|
||||
await act(async () => {
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockedAuthService.login).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during login', async () => {
|
||||
MockedAuthService.login.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/signing in.../i)).toBeInTheDocument();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error message when login fails', async () => {
|
||||
MockedAuthService.login.mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form');
|
||||
|
||||
await act(async () => {
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSuccess callback when login succeeds', async () => {
|
||||
const mockOnSuccess = vi.fn();
|
||||
const mockAuthResult = {
|
||||
user: { id: '1', email: 'test@example.com', name: 'Test User', role: 'user' as const, createdAt: '2023-01-01', updatedAt: '2023-01-01' },
|
||||
token: 'mock-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
};
|
||||
|
||||
MockedAuthService.login.mockResolvedValue(mockAuthResult);
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSuccess={mockOnSuccess} />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form');
|
||||
|
||||
await act(async () => {
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
269
frontend/src/components/__tests__/LogoutButton.test.tsx
Normal file
269
frontend/src/components/__tests__/LogoutButton.test.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import LogoutButton from '../LogoutButton';
|
||||
import { AuthProvider } from '../../contexts/AuthContext';
|
||||
import { authService } from '../../services/authService';
|
||||
|
||||
// Mock the auth service
|
||||
vi.mock('../../services/authService', () => ({
|
||||
authService: {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
getToken: vi.fn(),
|
||||
getCurrentUser: vi.fn(),
|
||||
validateToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const MockedAuthService = authService as any;
|
||||
|
||||
// Wrapper component for tests
|
||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
);
|
||||
|
||||
// Helper to wait for auth initialization
|
||||
const waitForAuthInit = async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument();
|
||||
}, { timeout: 5000 });
|
||||
};
|
||||
|
||||
describe('LogoutButton', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
MockedAuthService.getToken.mockReturnValue('mock-token');
|
||||
MockedAuthService.getCurrentUser.mockReturnValue({
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'user',
|
||||
});
|
||||
MockedAuthService.validateToken.mockResolvedValue({
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'user',
|
||||
});
|
||||
MockedAuthService.logout.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders logout button with default variant', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-red-600'); // Button variant styling
|
||||
});
|
||||
|
||||
it('renders logout link with link variant', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton variant="link" />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).not.toHaveClass('bg-red-600'); // Link variant styling
|
||||
});
|
||||
|
||||
it('shows confirmation dialog when showConfirmation is true', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton showConfirmation={true} />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
await act(async () => {
|
||||
await user.click(button);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/confirm logout/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/are you sure you want to sign out/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show confirmation dialog when showConfirmation is false', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton showConfirmation={false} />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
await act(async () => {
|
||||
await user.click(button);
|
||||
});
|
||||
|
||||
// Should not show confirmation dialog, should call logout directly
|
||||
await waitFor(() => {
|
||||
expect(MockedAuthService.logout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls logout service when confirmed', async () => {
|
||||
// Ensure the mock is properly set up
|
||||
MockedAuthService.logout.mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton showConfirmation={true} />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
await act(async () => {
|
||||
await user.click(button);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/confirm logout/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// In the confirmation dialog, there's only one "Sign Out" button
|
||||
const confirmButton = screen.getByRole('button', { name: /sign out/i });
|
||||
await act(async () => {
|
||||
await user.click(confirmButton);
|
||||
});
|
||||
|
||||
// Wait for the logout to be called
|
||||
await waitFor(() => {
|
||||
expect(MockedAuthService.logout).toHaveBeenCalled();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('cancels logout when cancel button is clicked', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton showConfirmation={true} />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
await act(async () => {
|
||||
await user.click(button);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/confirm logout/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await act(async () => {
|
||||
await user.click(cancelButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/confirm logout/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(MockedAuthService.logout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows loading state during logout', async () => {
|
||||
// Mock logout to be slow so we can see loading state
|
||||
MockedAuthService.logout.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton showConfirmation={false} />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
await act(async () => {
|
||||
await user.click(button);
|
||||
});
|
||||
|
||||
// Should show loading state immediately
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/signing out.../i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const loadingButton = screen.getByText(/signing out.../i).closest('button');
|
||||
expect(loadingButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles logout errors gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
MockedAuthService.logout.mockRejectedValue(new Error('Logout failed'));
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton showConfirmation={false} />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
await act(async () => {
|
||||
await user.click(button);
|
||||
});
|
||||
|
||||
// The error is logged in AuthContext, not directly in the component
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applies custom className', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LogoutButton className="custom-class" />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAuthInit();
|
||||
|
||||
const button = screen.getByRole('button', { name: /sign out/i });
|
||||
expect(button).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
132
frontend/src/components/__tests__/ProtectedRoute.test.tsx
Normal file
132
frontend/src/components/__tests__/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProtectedRoute from '../ProtectedRoute';
|
||||
|
||||
// Mock the useAuth hook to control its output in tests
|
||||
const mockUseAuth = vi.fn();
|
||||
vi.mock('../../contexts/AuthContext', () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
const TestComponent: React.FC = () => <div>Protected Content</div>;
|
||||
const LoginComponent: React.FC = () => <div>Login Page</div>;
|
||||
const UnauthorizedComponent: React.FC = () => <div>Unauthorized Page</div>;
|
||||
|
||||
const renderWithRouter = (ui: React.ReactNode, { initialEntries = ['/protected'] } = {}) => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginComponent />} />
|
||||
<Route path="/unauthorized" element={<UnauthorizedComponent />} />
|
||||
<Route path="/protected" element={ui} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('shows a loading spinner while authentication is in progress', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<ProtectedRoute>
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to the login page if the user is not authenticated', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<ProtectedRoute>
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the protected content if the user is authenticated', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: { id: '1', role: 'user' },
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<ProtectedRoute>
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to an unauthorized page if the user does not have the required role', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: { id: '1', role: 'user' },
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Unauthorized Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the protected content if the user has the required admin role', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: { id: '1', role: 'admin' },
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the protected content if the user has the required user role', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: { id: '1', role: 'user' },
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<ProtectedRoute requiredRole="user">
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
18
frontend/src/config/env.ts
Normal file
18
frontend/src/config/env.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Frontend environment configuration
|
||||
export const config = {
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api',
|
||||
appName: import.meta.env.VITE_APP_NAME || 'CIM Document Processor',
|
||||
maxFileSize: parseInt(import.meta.env.VITE_MAX_FILE_SIZE || '104857600'), // 100MB
|
||||
allowedFileTypes: (import.meta.env.VITE_ALLOWED_FILE_TYPES || 'application/pdf').split(','),
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredEnvVars = ['VITE_API_BASE_URL'];
|
||||
|
||||
for (const envVar of requiredEnvVars) {
|
||||
if (!import.meta.env[envVar]) {
|
||||
console.warn(`Warning: ${envVar} is not set in environment variables`);
|
||||
}
|
||||
}
|
||||
|
||||
export default config;
|
||||
105
frontend/src/contexts/AuthContext.tsx
Normal file
105
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { User, LoginCredentials, AuthContextType } from '../types/auth';
|
||||
import { authService } from '../services/authService';
|
||||
|
||||
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 [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize auth state from localStorage and validate token
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const storedToken = authService.getToken();
|
||||
const storedUser = authService.getCurrentUser();
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
// Validate token with backend
|
||||
const validatedUser = await authService.validateToken();
|
||||
if (validatedUser) {
|
||||
setUser(validatedUser);
|
||||
setToken(storedToken);
|
||||
} else {
|
||||
// Token is invalid, clear everything
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
setError('Failed to initialize authentication');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (credentials: LoginCredentials): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const authResult = await authService.login(credentials);
|
||||
setUser(authResult.user);
|
||||
setToken(authResult.token);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Continue with logout even if API call fails
|
||||
} finally {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
error,
|
||||
isInitialized,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
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;
|
||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
103
frontend/src/services/authService.ts
Normal file
103
frontend/src/services/authService.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import axios from 'axios';
|
||||
import { config } from '../config/env';
|
||||
import { LoginCredentials, AuthResult, User } from '../types/auth';
|
||||
|
||||
const API_BASE_URL = config.apiBaseUrl;
|
||||
|
||||
class AuthService {
|
||||
private token: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Initialize token from localStorage
|
||||
this.token = localStorage.getItem('auth_token');
|
||||
if (this.token) {
|
||||
this.setAuthHeader(this.token);
|
||||
}
|
||||
}
|
||||
|
||||
private setAuthHeader(token: string) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
private removeAuthHeader() {
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
|
||||
async login(credentials: LoginCredentials): Promise<AuthResult> {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/login`, credentials);
|
||||
const authResult: AuthResult = response.data;
|
||||
|
||||
// Store token and set auth header
|
||||
this.token = authResult.token;
|
||||
localStorage.setItem('auth_token', authResult.token);
|
||||
localStorage.setItem('refresh_token', authResult.refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(authResult.user));
|
||||
|
||||
this.setAuthHeader(authResult.token);
|
||||
|
||||
return authResult;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(error.response?.data?.message || 'Login failed');
|
||||
}
|
||||
throw new Error('An unexpected error occurred');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
if (this.token) {
|
||||
await axios.post(`${API_BASE_URL}/auth/logout`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with logout even if API call fails
|
||||
console.error('Logout API call failed:', error);
|
||||
} finally {
|
||||
// Clear local storage and auth header
|
||||
this.token = null;
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
this.removeAuthHeader();
|
||||
}
|
||||
}
|
||||
|
||||
async validateToken(): Promise<User | null> {
|
||||
if (!this.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/auth/validate`);
|
||||
return response.data.user;
|
||||
} catch (error) {
|
||||
// Token is invalid, clear it
|
||||
this.logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentUser(): User | null {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
return JSON.parse(userStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.token;
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
export default authService;
|
||||
55
frontend/src/test/setup.ts
Normal file
55
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: 'http://localhost:3000',
|
||||
origin: 'http://localhost:3000',
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock console.error to prevent noise in tests
|
||||
const originalConsoleError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = vi.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
localStorageMock.getItem.mockClear();
|
||||
localStorageMock.setItem.mockClear();
|
||||
localStorageMock.removeItem.mockClear();
|
||||
localStorageMock.clear.mockClear();
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Helper to wait for async operations to complete
|
||||
export const waitForAsync = async () => {
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
};
|
||||
29
frontend/src/types/auth.ts
Normal file
29
frontend/src/types/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
user: User;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
6
frontend/src/utils/cn.ts
Normal file
6
frontend/src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
43
frontend/src/utils/validation.ts
Normal file
43
frontend/src/utils/validation.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const validateEmail = (email: string): string | null => {
|
||||
if (!email) {
|
||||
return 'Email is required';
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validatePassword = (password: string): string | null => {
|
||||
if (!password) {
|
||||
return 'Password is required';
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return 'Password must be at least 6 characters long';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateLoginForm = (email: string, password: string) => {
|
||||
const errors: { email?: string; password?: string } = {};
|
||||
|
||||
const emailError = validateEmail(email);
|
||||
if (emailError) {
|
||||
errors.email = emailError;
|
||||
}
|
||||
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) {
|
||||
errors.password = passwordError;
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
};
|
||||
};
|
||||
32
frontend/tailwind.config.js
Normal file
32
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
},
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
35
frontend/tsconfig.json
Normal file
35
frontend/tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Types */
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
0
frontend/verify-auth.js
Normal file
0
frontend/verify-auth.js
Normal file
27
frontend/vite.config.ts
Normal file
27
frontend/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user