diff --git a/src/App.tsx b/src/App.tsx
index 9d8208b..d79ec25 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,9 +1,10 @@
-/**
- * Main Application Component
- * Enterprise-grade task management application
- */
-
import { useState, Suspense, lazy } from 'react';
+import { Routes, Route, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom';
+import type { UserRole } from './core/types';
+import { ROUTES, STORAGE_KEYS } from './core/constants';
+import { getFromStorage, setInStorage } from './core/utils/storage';
+import type { AppView } from './routing/appViewMapping';
+import { getPathForView, getViewFromPath } from './routing/appViewMapping';
import { TaskBoard, TaskProvider } from './features/tasks';
import { AuthProvider, useAuth } from './features/auth';
import { ProjectProvider } from './features/projects';
@@ -14,17 +15,47 @@ import { Avatar } from './shared/components/Avatar';
import { CountBadge } from './shared/components/Badge';
import { Dropdown, DropdownItem, DropdownDivider, DropdownLabel } from './shared/components/Dropdown';
import { Button } from './shared/components/Button';
+import { LoginPage } from './features/auth/components/LoginPage';
+import { RegisterPage } from './features/auth/components/RegisterPage';
+import { ForgotPasswordPage } from './features/auth/components/ForgotPasswordPage';
+
+const ProjectDashboard = lazy(() => import('./features/projects/components/ProjectDashboard').then((m) => ({ default: m.ProjectDashboard })));
+const TeamList = lazy(() => import('./features/teams/components/TeamList').then((m) => ({ default: m.TeamList })));
+const AnalyticsDashboard = lazy(() => import('./features/analytics/components/AnalyticsDashboard').then((m) => ({ default: m.AnalyticsDashboard })));
+
+interface AuthGateProps {
+ children: JSX.Element;
+ requiredRole?: UserRole | UserRole[];
+ requiredPermission?: string;
+}
-// Lazy load heavy components
-const ProjectDashboard = lazy(() => import('./features/projects/components/ProjectDashboard').then(m => ({ default: m.ProjectDashboard })));
-const TeamList = lazy(() => import('./features/teams/components/TeamList').then(m => ({ default: m.TeamList })));
-const AnalyticsDashboard = lazy(() => import('./features/analytics/components/AnalyticsDashboard').then(m => ({ default: m.AnalyticsDashboard })));
+function AuthGate({ children, requiredRole, requiredPermission }: AuthGateProps) {
+ const { isAuthenticated, isLoading, hasRole, hasPermission } = useAuth();
+ const location = useLocation();
-// ============================================
-// NAVIGATION
-// ============================================
+ if (isLoading) {
+ return (
+
+
+ Checking authentication...
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ if (requiredRole && !hasRole(requiredRole)) {
+ return ;
+ }
-type AppView = 'tasks' | 'projects' | 'teams' | 'analytics' | 'settings';
+ if (requiredPermission && !hasPermission(requiredPermission)) {
+ return ;
+ }
+
+ return children;
+}
interface NavItemProps {
icon: React.ReactNode;
@@ -306,18 +337,21 @@ interface MainContentProps {
}
function MainContent({ currentView }: MainContentProps) {
+ const params = useParams<{ projectId?: string }>();
+ const projectIdFromParams = params.projectId || 'proj_001';
+
const renderContent = () => {
switch (currentView) {
case 'tasks':
- return (
-
-
-
+ return (
+
+
+
);
case 'projects':
return (
}>
-
+
);
case 'teams':
@@ -451,17 +485,37 @@ function SettingsPage() {
// ============================================
function AppLayout() {
- const [currentView, setCurrentView] = useState('tasks');
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
+ const location = useLocation();
+ const navigate = useNavigate();
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
+ const stored = getFromStorage(STORAGE_KEYS.SIDEBAR_COLLAPSED);
+ return stored ?? false;
+ });
const [searchOpen, setSearchOpen] = useState(false);
+ const currentView = getViewFromPath(location.pathname);
+
+ const handleViewChange = (view: AppView) => {
+ const targetPath = getPathForView(view);
+ if (location.pathname !== targetPath) {
+ navigate(targetPath);
+ }
+ };
+
+ const handleToggleCollapse = () => {
+ setSidebarCollapsed((prev) => {
+ const next = !prev;
+ setInStorage(STORAGE_KEYS.SIDEBAR_COLLAPSED, next);
+ return next;
+ });
+ };
return (
setSidebarCollapsed(!sidebarCollapsed)}
+ onToggleCollapse={handleToggleCollapse}
/>
setSearchOpen(true)} />
@@ -471,7 +525,7 @@ function AppLayout() {
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
onNavigate={(url) => {
- console.log('Navigate to:', url);
+ navigate(url);
setSearchOpen(false);
}}
/>
@@ -479,16 +533,140 @@ function AppLayout() {
);
}
-// ============================================
-// APP WITH PROVIDERS
-// ============================================
+function ForbiddenPage() {
+ const navigate = useNavigate();
+
+ return (
+
+
+
Access denied
+
You do not have permission to view this page.
+
+
+
+ );
+}
+
+function NotFoundPage() {
+ const navigate = useNavigate();
+
+ return (
+
+
+
Page not found
+
The page you are looking for does not exist.
+
+
+
+ );
+}
+
+function AppRoutes() {
+ const { isAuthenticated } = useAuth();
+
+ return (
+
+ }
+ />
+ : }
+ />
+ : }
+ />
+ : }
+ />
+ }
+ />
+
+
+
+ )}
+ />
+
+
+
+ )}
+ />
+
+
+
+ )}
+ />
+
+
+
+ )}
+ />
+
+
+
+ )}
+ />
+
+
+
+ )}
+ />
+
+
+
+ )}
+ />
+
+
+
+ )}
+ />
+ } />
+ } />
+ } />
+
+ );
+}
function App() {
return (
-
+
diff --git a/src/AppRouter.test.tsx b/src/AppRouter.test.tsx
new file mode 100644
index 0000000..1e9d075
--- /dev/null
+++ b/src/AppRouter.test.tsx
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { render, screen } from '@testing-library/react';
+import App from './App';
+
+function renderWithRouter(initialEntries: string[]) {
+ return render(
+
+
+ } />
+
+ ,
+ );
+}
+
+describe('App routing', () => {
+ it('renders login page on /login', () => {
+ renderWithRouter(['/login']);
+ expect(screen.getByText(/welcome back/i)).toBeInTheDocument();
+ });
+
+ it('renders not found page for unknown routes', () => {
+ renderWithRouter(['/unknown-route']);
+ expect(screen.getByText(/page not found/i)).toBeInTheDocument();
+ });
+});
+
+
diff --git a/src/features/auth/components/ForgotPasswordPage.tsx b/src/features/auth/components/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..be649e1
--- /dev/null
+++ b/src/features/auth/components/ForgotPasswordPage.tsx
@@ -0,0 +1,18 @@
+import { useNavigate } from 'react-router-dom';
+import { ROUTES } from '../../../core/constants';
+import { ForgotPasswordForm } from './ForgotPasswordForm';
+
+export function ForgotPasswordPage() {
+ const navigate = useNavigate();
+
+ return (
+
+ undefined}
+ onBack={() => navigate(ROUTES.LOGIN)}
+ />
+
+ );
+}
+
+
diff --git a/src/features/auth/components/LoginPage.tsx b/src/features/auth/components/LoginPage.tsx
new file mode 100644
index 0000000..94302c2
--- /dev/null
+++ b/src/features/auth/components/LoginPage.tsx
@@ -0,0 +1,32 @@
+import { useMemo } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { ROUTES } from '../../../core/constants';
+import { LoginForm } from './LoginForm';
+
+interface LocationState {
+ from?: {
+ pathname: string;
+ };
+}
+
+export function LoginPage() {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const redirectPath = useMemo(() => {
+ const state = location.state as LocationState | null;
+ return state?.from?.pathname || ROUTES.TASKS;
+ }, [location.state]);
+
+ return (
+
+ navigate(redirectPath, { replace: true })}
+ onForgotPassword={() => navigate(ROUTES.FORGOT_PASSWORD)}
+ onRegister={() => navigate(ROUTES.REGISTER)}
+ />
+
+ );
+}
+
+
diff --git a/src/features/auth/components/RegisterPage.tsx b/src/features/auth/components/RegisterPage.tsx
new file mode 100644
index 0000000..4fa635c
--- /dev/null
+++ b/src/features/auth/components/RegisterPage.tsx
@@ -0,0 +1,18 @@
+import { useNavigate } from 'react-router-dom';
+import { ROUTES } from '../../../core/constants';
+import { RegisterForm } from './RegisterForm';
+
+export function RegisterPage() {
+ const navigate = useNavigate();
+
+ return (
+
+ navigate(ROUTES.TASKS)}
+ onLogin={() => navigate(ROUTES.LOGIN)}
+ />
+
+ );
+}
+
+
diff --git a/src/features/auth/components/index.ts b/src/features/auth/components/index.ts
index 0884153..536a427 100644
--- a/src/features/auth/components/index.ts
+++ b/src/features/auth/components/index.ts
@@ -1,10 +1,9 @@
-/**
- * Auth Components Exports
- */
-
export { LoginForm } from './LoginForm';
export { RegisterForm } from './RegisterForm';
export { ForgotPasswordForm } from './ForgotPasswordForm';
export { ProfileSettings } from './ProfileSettings';
export { SecuritySettings } from './SecuritySettings';
+export { LoginPage } from './LoginPage';
+export { RegisterPage } from './RegisterPage';
+export { ForgotPasswordPage } from './ForgotPasswordPage';
diff --git a/src/main.tsx b/src/main.tsx
index bef5202..25e7a34 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,13 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import './index.css';
+import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
-)
+);
diff --git a/src/routing/appViewMapping.test.tsx b/src/routing/appViewMapping.test.tsx
new file mode 100644
index 0000000..9ae1834
--- /dev/null
+++ b/src/routing/appViewMapping.test.tsx
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { ROUTES } from '../core/constants';
+import { getPathForView, getViewFromPath } from './appViewMapping';
+
+describe('appViewMapping', () => {
+ it('maps views to paths', () => {
+ expect(getPathForView('tasks')).toBe(ROUTES.TASKS);
+ expect(getPathForView('projects')).toBe(ROUTES.PROJECTS);
+ expect(getPathForView('teams')).toBe(ROUTES.TEAMS);
+ expect(getPathForView('analytics')).toBe(ROUTES.ANALYTICS);
+ expect(getPathForView('settings')).toBe(ROUTES.SETTINGS);
+ });
+
+ it('derives views from base paths', () => {
+ expect(getViewFromPath(ROUTES.TASKS)).toBe('tasks');
+ expect(getViewFromPath(ROUTES.PROJECTS)).toBe('projects');
+ expect(getViewFromPath(ROUTES.TEAMS)).toBe('teams');
+ expect(getViewFromPath(ROUTES.ANALYTICS)).toBe('analytics');
+ expect(getViewFromPath(ROUTES.SETTINGS)).toBe('settings');
+ });
+
+ it('derives views from nested paths', () => {
+ expect(getViewFromPath('/tasks/task_123')).toBe('tasks');
+ expect(getViewFromPath('/projects/project_123')).toBe('projects');
+ expect(getViewFromPath('/teams/team_123')).toBe('teams');
+ expect(getViewFromPath('/analytics/overview')).toBe('analytics');
+ expect(getViewFromPath('/settings/profile')).toBe('settings');
+ });
+});
+
+
diff --git a/src/routing/appViewMapping.ts b/src/routing/appViewMapping.ts
new file mode 100644
index 0000000..320ba23
--- /dev/null
+++ b/src/routing/appViewMapping.ts
@@ -0,0 +1,39 @@
+import { ROUTES } from '../core/constants';
+
+export type AppView = 'tasks' | 'projects' | 'teams' | 'analytics' | 'settings';
+
+export function getPathForView(view: AppView): string {
+ switch (view) {
+ case 'projects':
+ return ROUTES.PROJECTS;
+ case 'teams':
+ return ROUTES.TEAMS;
+ case 'analytics':
+ return ROUTES.ANALYTICS;
+ case 'settings':
+ return ROUTES.SETTINGS;
+ default:
+ return ROUTES.TASKS;
+ }
+}
+
+export function getViewFromPath(pathname: string): AppView {
+ if (pathname.startsWith(ROUTES.PROJECTS)) {
+ return 'projects';
+ }
+ if (pathname.startsWith(ROUTES.TEAMS)) {
+ return 'teams';
+ }
+ if (pathname.startsWith(ROUTES.ANALYTICS)) {
+ return 'analytics';
+ }
+ if (pathname.startsWith(ROUTES.SETTINGS) || pathname.startsWith(ROUTES.PROFILE)) {
+ return 'settings';
+ }
+ if (pathname.startsWith(ROUTES.MY_TASKS) || pathname.startsWith(ROUTES.TASKS)) {
+ return 'tasks';
+ }
+ return 'tasks';
+}
+
+
diff --git a/src/test/setupTests.ts b/src/test/setupTests.ts
new file mode 100644
index 0000000..491e973
--- /dev/null
+++ b/src/test/setupTests.ts
@@ -0,0 +1,3 @@
+import '@testing-library/jest-dom';
+
+
diff --git a/tsconfig.node.json b/tsconfig.node.json
index db0becc..26f8317 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -5,20 +5,16 @@
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
-
- /* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
-
- /* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "vitest.config.ts"]
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..27a3e6b
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/test/setupTests.ts',
+ css: true,
+ },
+});
+
+