diff --git a/src/app/(modules)/access/page.tsx b/src/app/(modules)/access/page.tsx new file mode 100644 index 0000000..50194e6 --- /dev/null +++ b/src/app/(modules)/access/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ShieldX } from 'lucide-react'; + +export default function AccessPage() { + return ( +
+
+
+ +
+

Access denied

+

+ You do not have permission to access any available module. Contact your administrator if + you need access. +

+
+
+ ); +} diff --git a/src/app/(modules)/roles/components/RoleColumns.tsx b/src/app/(modules)/roles/components/RoleColumns.tsx index 63b2be2..d3a74f6 100644 --- a/src/app/(modules)/roles/components/RoleColumns.tsx +++ b/src/app/(modules)/roles/components/RoleColumns.tsx @@ -6,6 +6,8 @@ import { Edit, RotateCcw } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { PERMISSIONS } from '@/constants/permissions'; +import { usePermissions } from '@/hooks/usePermissions'; import type { Role } from '@/types'; interface UseRoleColumnsParams { @@ -19,8 +21,12 @@ export function useRoleColumns({ onToggleStatus, isStatusPending, }: UseRoleColumnsParams): ColumnDef[] { - return useMemo( - () => [ + const { hasPermission } = usePermissions(); + const canEdit = hasPermission(PERMISSIONS.ROLE.UPDATE); + const canDelete = hasPermission(PERMISSIONS.ROLE.DELETE); + + return useMemo(() => { + const columns: ColumnDef[] = [ { accessorKey: 'name', header: 'Name', @@ -53,16 +59,23 @@ export function useRoleColumns({ ), }, - { - id: 'actions', - header: () =>
Actions
, - cell: ({ row }) => { - const role = row.original; - return ( -
+ ]; + + if (!canEdit && !canDelete) return columns; + + columns.push({ + id: 'actions', + header: () =>
Actions
, + cell: ({ row }) => { + const role = row.original; + return ( +
+ {canEdit ? ( + ) : null} + {canDelete ? ( -
- ); - }, + ) : null} +
+ ); }, - ], - [isStatusPending, onEdit, onToggleStatus], - ); + }); + + return columns; + }, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]); } diff --git a/src/app/(modules)/roles/hooks/useRoleQueries.ts b/src/app/(modules)/roles/hooks/useRoleQueries.ts index 378ef59..2e6718c 100644 --- a/src/app/(modules)/roles/hooks/useRoleQueries.ts +++ b/src/app/(modules)/roles/hooks/useRoleQueries.ts @@ -47,10 +47,11 @@ export function useRolesQuery(params: UseRolesQueryParams) { }); } -export function usePermissionsTreeQuery() { +export function usePermissionsTreeQuery(enabled: boolean) { const permissionsQuery = useQuery({ queryKey: ['permissions', 'tree'], queryFn: permissionService.getPermissions, + enabled, }); const permissionTree = useMemo( () => mapPermissionTree(permissionsQuery.data?.tree || []), diff --git a/src/app/(modules)/roles/page.tsx b/src/app/(modules)/roles/page.tsx index 4d388e1..dda24ac 100644 --- a/src/app/(modules)/roles/page.tsx +++ b/src/app/(modules)/roles/page.tsx @@ -6,6 +6,8 @@ import { Plus, ShieldCheck } from 'lucide-react'; import { PageHeader } from '@/components/page-header'; import { PoweredBy } from '@/components/powered-by'; import { Button } from '@/components/ui/button'; +import { PERMISSIONS } from '@/constants/permissions'; +import { PermissionGuard } from '@/guards'; import { useAppStore } from '@/store/app.store'; import type { Role } from '@/types'; @@ -41,7 +43,7 @@ export default function RolesPage() { searchTerm: debouncedSearchTerm, statusFilter, }); - const permissionsQuery = usePermissionsTreeQuery(); + const permissionsQuery = usePermissionsTreeQuery(isSheetOpen); const roleForm = useRoleForm({ organizationId, permissionTree: permissionsQuery.permissionTree, @@ -105,10 +107,12 @@ export default function RolesPage() { description="Manage roles and permission access" icon={ShieldCheck} actions={ - + + + } /> diff --git a/src/app/(modules)/users/components/UserColumns.tsx b/src/app/(modules)/users/components/UserColumns.tsx index 292be02..ba80947 100644 --- a/src/app/(modules)/users/components/UserColumns.tsx +++ b/src/app/(modules)/users/components/UserColumns.tsx @@ -6,6 +6,8 @@ import { Edit, RotateCcw } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { PERMISSIONS } from '@/constants/permissions'; +import { usePermissions } from '@/hooks/usePermissions'; import type { AdministrationUser } from '@/types'; function formatDate(value?: string | null) { @@ -28,8 +30,12 @@ export function useUserColumns({ onToggleStatus, pendingUserId, }: UseUserColumnsParams): ColumnDef[] { - return useMemo( - () => [ + const { hasPermission } = usePermissions(); + const canEdit = hasPermission(PERMISSIONS.USER.UPDATE); + const canDelete = hasPermission(PERMISSIONS.USER.DELETE); + + return useMemo(() => { + const columns: ColumnDef[] = [ { accessorKey: 'first_name', header: 'Name', @@ -80,16 +86,23 @@ export function useUserColumns({ ), }, - { - id: 'actions', - header: () =>
Actions
, - cell: ({ row }) => { - const user = row.original; - return ( -
+ ]; + + if (!canEdit && !canDelete) return columns; + + columns.push({ + id: 'actions', + header: () =>
Actions
, + cell: ({ row }) => { + const user = row.original; + return ( +
+ {canEdit ? ( + ) : null} + {canDelete ? ( -
- ); - }, + ) : null} +
+ ); }, - ], - [onEdit, onToggleStatus, pendingUserId], - ); + }); + + return columns; + }, [canDelete, canEdit, onEdit, onToggleStatus, pendingUserId]); } diff --git a/src/app/(modules)/users/page.tsx b/src/app/(modules)/users/page.tsx index 3e4a686..20bedb7 100644 --- a/src/app/(modules)/users/page.tsx +++ b/src/app/(modules)/users/page.tsx @@ -6,6 +6,8 @@ import { Plus, Users } from 'lucide-react'; import { PageHeader } from '@/components/page-header'; import { PoweredBy } from '@/components/powered-by'; import { Button } from '@/components/ui/button'; +import { PERMISSIONS } from '@/constants/permissions'; +import { PermissionGuard } from '@/guards'; import type { AdministrationUser } from '@/types'; import { useUserColumns } from './components/UserColumns'; @@ -103,10 +105,12 @@ export default function UsersPage() { description="Manage users and role assignment" icon={Users} actions={ - + + + } /> diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..f2bf662 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link'; + +import { Button } from '@/components/ui/button'; +import { ROUTES } from '@/utils/routes'; + +export default function NotFound() { + return ( +
+
+

404

+

Page not found

+

+ The page you are looking for does not exist or may have been moved. +

+ +
+
+ ); +} diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx new file mode 100644 index 0000000..5e26237 --- /dev/null +++ b/src/components/AppLoader.tsx @@ -0,0 +1,18 @@ +interface AppLoaderProps { + label?: string; +} + +export function AppLoader({ label: _label }: AppLoaderProps) { + return ( +
+
+ + + +
+
+ ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 58857e9..9a30d57 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -1,8 +1,10 @@ 'use client'; import * as React from 'react'; -import { LayoutDashboard, Plus, Layers, Package, Milestone, ShieldCheck, Users } from 'lucide-react'; +import { ChevronRight, Package } from 'lucide-react'; import { NavUser } from '@/components/nav-user'; -import { ROUTES } from '@/utils/routes'; +import { filterMenuItems, menuItems, type MenuItem } from '@/config/menu.config'; +import { usePermissions } from '@/hooks/usePermissions'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Sidebar, SidebarContent, @@ -12,6 +14,9 @@ import { SidebarMenu, SidebarMenuItem, SidebarMenuButton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, SidebarGroup, SidebarGroupLabel, } from '@/components/ui/sidebar'; @@ -19,50 +24,11 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useAppStore } from '@/store/app.store'; -// This is the navigation data -const data = { - navMain: [ - { - title: 'Dashboard', - url: ROUTES.DASHBOARD, - icon: LayoutDashboard, - }, - { - title: 'New Analysis', - url: ROUTES.UPLOAD, - icon: Plus, - }, - { - title: 'Project', - url: ROUTES.PROJECT, - icon: Layers, - }, - { - title: 'Package', - url: ROUTES.PACKAGE, - icon: Package, - }, - { - title: 'Segment', - url: ROUTES.CHAINAGE, - icon: Milestone, - }, - { - title: 'Roles', - url: ROUTES.ROLES, - icon: ShieldCheck, - }, - { - title: 'Users', - url: ROUTES.USERS, - icon: Users, - }, - ], -}; - export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname(); + const { hasPermission } = usePermissions(); const authUser = useAppStore((state) => state.user); + const navItems = filterMenuItems(menuItems, hasPermission); const user = { name: [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || 'Admin', email: authUser?.email || '', @@ -85,15 +51,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - {data.navMain.map((item) => ( - - - - {item.icon && } - {item.title} - - - + {navItems.map((item) => ( + ))} @@ -105,3 +64,59 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ); } + +function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }) { + const isActive = item.path ? pathname === item.path : false; + const isChildActive = item.children?.some((child) => child.path === pathname) ?? false; + const hasChildren = Boolean(item.children?.length); + + if (hasChildren) { + return ( + + + + + + {item.title} + + + + + + {item.children?.map((child) => ( + + {child.path ? ( + + + + {child.title} + + + ) : null} + + ))} + + + + + ); + } + + return ( + + {item.path ? ( + + + + {item.title} + + + ) : ( + + + {item.title} + + )} + + ); +} diff --git a/src/config/app.routes.ts b/src/config/app.routes.ts new file mode 100644 index 0000000..0d7270a --- /dev/null +++ b/src/config/app.routes.ts @@ -0,0 +1,22 @@ +import { PERMISSIONS } from '@/constants/permissions'; +import { ROUTES } from '@/utils/routes'; + +export type AppRoute = { + path: string; + permission?: string; +}; + +export const appRoutes: AppRoute[] = [ + { + path: ROUTES.ROLES, + permission: PERMISSIONS.ROLE.READ, + }, + { + path: ROUTES.USERS, + permission: PERMISSIONS.USER.READ, + }, +]; + +export function getRoutePermission(pathname: string) { + return appRoutes.find((route) => route.path === pathname)?.permission; +} diff --git a/src/config/menu.config.ts b/src/config/menu.config.ts new file mode 100644 index 0000000..246f372 --- /dev/null +++ b/src/config/menu.config.ts @@ -0,0 +1,88 @@ +import { + LayoutDashboard, + Layers, + Milestone, + Package, + Plus, + Settings, + ShieldCheck, + Users, +} from 'lucide-react'; +import type { ComponentType, SVGProps } from 'react'; + +import { PERMISSIONS } from '@/constants/permissions'; +import { ROUTES } from '@/utils/routes'; + +export type MenuItem = { + title: string; + path?: string; + icon: ComponentType>; + permission?: string; + children?: MenuItem[]; +}; + +export const menuItems: MenuItem[] = [ + { + title: 'Dashboard', + path: ROUTES.DASHBOARD, + icon: LayoutDashboard, + }, + { + title: 'New Analysis', + path: ROUTES.UPLOAD, + icon: Plus, + }, + { + title: 'Project', + path: ROUTES.PROJECT, + icon: Layers, + }, + { + title: 'Package', + path: ROUTES.PACKAGE, + icon: Package, + }, + { + title: 'Segment', + path: ROUTES.CHAINAGE, + icon: Milestone, + }, + { + title: 'Administration', + icon: Settings, + children: [ + { + title: 'Roles', + path: ROUTES.ROLES, + icon: ShieldCheck, + permission: PERMISSIONS.ROLE.READ, + }, + { + title: 'Users', + path: ROUTES.USERS, + icon: Users, + permission: PERMISSIONS.USER.READ, + }, + ], + }, +]; + +export function filterMenuItems( + items: MenuItem[], + hasPermission: (permission?: string) => boolean, +): MenuItem[] { + return items + .map((item): MenuItem | null => { + if (!hasPermission(item.permission)) return null; + + const children = item.children ? filterMenuItems(item.children, hasPermission) : undefined; + + if (item.children && children?.length === 0 && !item.path) return null; + + return { + ...item, + children, + }; + }) + .filter((item): item is MenuItem => item !== null); +} diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts new file mode 100644 index 0000000..cb63963 --- /dev/null +++ b/src/constants/permissions.ts @@ -0,0 +1,17 @@ +export const PERMISSIONS = { + ROLE: { + CREATE: 'administration.roles.create', + READ: 'administration.roles.reads', + UPDATE: 'administration.roles.update', + DELETE: 'administration.roles.delete', + }, + USER: { + CREATE: 'administration.users.create', + READ: 'administration.users.read', + UPDATE: 'administration.users.update', + DELETE: 'administration.users.delete', + }, + LOG: { + VIEW: 'log.view', + }, +} as const; diff --git a/src/guards/AuthGuard.tsx b/src/guards/AuthGuard.tsx index 5855cce..3c0da28 100644 --- a/src/guards/AuthGuard.tsx +++ b/src/guards/AuthGuard.tsx @@ -1,17 +1,24 @@ 'use client'; +import { AppLoader } from '@/components/AppLoader'; import { useAppStore } from '@/store/app.store'; import { useAuthStore } from '@/store/auth.store'; +import { getRoutePermission } from '@/config/app.routes'; +import { usePermissions } from '@/hooks/usePermissions'; import { ROUTES } from '@/utils/routes'; -import { useRouter } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import type { ReactNode } from 'react'; import { useEffect } from 'react'; export function AuthGuard({ children }: { children: ReactNode }) { const router = useRouter(); + const pathname = usePathname(); const accessToken = useAuthStore((state) => state.accessToken); const isInitialized = useAppStore((state) => state.isInitialized); const user = useAppStore((state) => state.user); + const { hasPermission } = usePermissions(); + const requiredPermission = getRoutePermission(pathname); + const canAccessRoute = hasPermission(requiredPermission); useEffect(() => { if (!isInitialized) return; @@ -21,7 +28,17 @@ export function AuthGuard({ children }: { children: ReactNode }) { } }, [accessToken, isInitialized, router]); - if (!isInitialized || !accessToken || !user) { + useEffect(() => { + if (!isInitialized || !accessToken || !user || canAccessRoute) return; + + router.replace(ROUTES.ACCESS); + }, [accessToken, canAccessRoute, isInitialized, router, user]); + + if (!isInitialized || (accessToken && !user)) { + return ; + } + + if (!accessToken || !user || !canAccessRoute) { return null; } diff --git a/src/guards/GuestGuard.tsx b/src/guards/GuestGuard.tsx index 36ed946..c70a7d6 100644 --- a/src/guards/GuestGuard.tsx +++ b/src/guards/GuestGuard.tsx @@ -1,5 +1,6 @@ 'use client'; +import { AppLoader } from '@/components/AppLoader'; import { useAppStore } from '@/store/app.store'; import { useAuthStore } from '@/store/auth.store'; import { ROUTES } from '@/utils/routes'; @@ -19,7 +20,11 @@ export function GuestGuard({ children }: { children: ReactNode }) { } }, [accessToken, isInitialized, router, user]); - if (!isInitialized || (accessToken && user)) return null; + if (!isInitialized) { + return ; + } + + if (isInitialized && accessToken && user) return ; return children; } diff --git a/src/guards/PermissionGuard.tsx b/src/guards/PermissionGuard.tsx index 01d3d82..41b0b15 100644 --- a/src/guards/PermissionGuard.tsx +++ b/src/guards/PermissionGuard.tsx @@ -1,29 +1,18 @@ 'use client'; -import { useAppStore } from '@/store/app.store'; +import { usePermissions, type PermissionInput } from '@/hooks/usePermissions'; import type { ReactNode } from 'react'; type PermissionGuardProps = { children: ReactNode; - permissions: string[]; - mode?: 'all' | 'any'; + permissions?: PermissionInput; fallback?: ReactNode; }; -export function PermissionGuard({ - children, - permissions, - mode = 'all', - fallback = null, -}: PermissionGuardProps) { - const grantedPermissions = useAppStore((state) => state.permissions); - const normalizedPermissions = grantedPermissions.map((permission) => permission.toLowerCase()); - const hasPermission = - mode === 'all' - ? permissions.every((permission) => normalizedPermissions.includes(permission.toLowerCase())) - : permissions.some((permission) => normalizedPermissions.includes(permission.toLowerCase())); +export function PermissionGuard({ children, permissions, fallback = null }: PermissionGuardProps) { + const { hasPermission } = usePermissions(); - if (!hasPermission) return fallback; + if (!hasPermission(permissions)) return fallback; return children; } diff --git a/src/hooks/useLoginForm.ts b/src/hooks/useLoginForm.ts index 817ebf9..65416b3 100644 --- a/src/hooks/useLoginForm.ts +++ b/src/hooks/useLoginForm.ts @@ -42,7 +42,7 @@ export const useLoginForm = () => { await initializeAuthenticatedApp(); toast.success('Login successful'); - router.push(ROUTES.DASHBOARD); + router.replace(ROUTES.DASHBOARD); } catch (error) { toast.error(error instanceof Error ? error.message : 'Unable to sign in'); } diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts new file mode 100644 index 0000000..8679989 --- /dev/null +++ b/src/hooks/usePermissions.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useMemo } from 'react'; + +import { useAppStore } from '@/store/app.store'; + +export type PermissionInput = string | undefined; + +export function hasPermissionValue({ + grantedPermissions, + permissions, +}: { + grantedPermissions: string[]; + permissions: PermissionInput; +}) { + if (!permissions) return true; + + const normalizedPermissions = new Set( + grantedPermissions.map((permission) => permission.toLowerCase()), + ); + + return normalizedPermissions.has(permissions.toLowerCase()); +} + +export function usePermissions() { + const grantedPermissions = useAppStore((state) => state.permissions); + + return useMemo( + () => ({ + grantedPermissions, + hasPermission: (permissions: PermissionInput) => + hasPermissionValue({ + grantedPermissions, + permissions, + }), + }), + [grantedPermissions], + ); +} diff --git a/src/utils/routes.ts b/src/utils/routes.ts index 0b37858..201820f 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -6,6 +6,7 @@ export const ROUTES = { CHAINAGE: '/segment', ROLES: '/roles', USERS: '/users', + ACCESS: '/access', ACCOUNT: '/account', UPLOAD: '/upload', RESULTS: '/results',