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, + }, +}); + +