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:
Jon
2025-07-27 13:29:26 -04:00
commit 5a3c961bfc
72 changed files with 24326 additions and 0 deletions

5
frontend/.env.example Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

46
frontend/package.json Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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();
});
});
});

View 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');
});
});

View 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();
});
});

View 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;

View 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
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
frontend/src/main.tsx Normal file
View 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>
);

View 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;

View 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));
});
};

View 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
View 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));
}

View 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,
};
};

View 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
View 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" }]
}

View 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
View File

27
frontend/vite.config.ts Normal file
View 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'],
},
})