feat: add permission-gated navigation and admin access
This commit is contained in:
20
src/app/(modules)/access/page.tsx
Normal file
20
src/app/(modules)/access/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ShieldX } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AccessPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[calc(100vh-9rem)] items-center justify-center">
|
||||||
|
<div className="flex max-w-md flex-col items-center text-center">
|
||||||
|
<div className="mb-4 flex size-14 items-center justify-center rounded-full border bg-card">
|
||||||
|
<ShieldX className="size-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold">Access denied</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
You do not have permission to access any available module. Contact your administrator if
|
||||||
|
you need access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { Edit, RotateCcw } from 'lucide-react';
|
|||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
|
||||||
interface UseRoleColumnsParams {
|
interface UseRoleColumnsParams {
|
||||||
@@ -19,8 +21,12 @@ export function useRoleColumns({
|
|||||||
onToggleStatus,
|
onToggleStatus,
|
||||||
isStatusPending,
|
isStatusPending,
|
||||||
}: UseRoleColumnsParams): ColumnDef<Role>[] {
|
}: UseRoleColumnsParams): ColumnDef<Role>[] {
|
||||||
return useMemo(
|
const { hasPermission } = usePermissions();
|
||||||
() => [
|
const canEdit = hasPermission(PERMISSIONS.ROLE.UPDATE);
|
||||||
|
const canDelete = hasPermission(PERMISSIONS.ROLE.DELETE);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const columns: ColumnDef<Role>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
@@ -53,16 +59,23 @@ export function useRoleColumns({
|
|||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
if (!canEdit && !canDelete) return columns;
|
||||||
cell: ({ row }) => {
|
|
||||||
const role = row.original;
|
columns.push({
|
||||||
return (
|
id: 'actions',
|
||||||
<div className="flex justify-end gap-2">
|
header: () => <div className="text-right">Actions</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const role = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{canEdit ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(role)}>
|
<Button variant="outline" size="sm" onClick={() => onEdit(role)}>
|
||||||
<Edit className="size-4" />
|
<Edit className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDelete ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -72,11 +85,12 @@ export function useRoleColumns({
|
|||||||
<RotateCcw className="mr-2 size-4" />
|
<RotateCcw className="mr-2 size-4" />
|
||||||
{role.effective_status ? 'Deactivate' : 'Activate'}
|
{role.effective_status ? 'Deactivate' : 'Activate'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
);
|
</div>
|
||||||
},
|
);
|
||||||
},
|
},
|
||||||
],
|
});
|
||||||
[isStatusPending, onEdit, onToggleStatus],
|
|
||||||
);
|
return columns;
|
||||||
|
}, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,11 @@ export function useRolesQuery(params: UseRolesQueryParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePermissionsTreeQuery() {
|
export function usePermissionsTreeQuery(enabled: boolean) {
|
||||||
const permissionsQuery = useQuery({
|
const permissionsQuery = useQuery({
|
||||||
queryKey: ['permissions', 'tree'],
|
queryKey: ['permissions', 'tree'],
|
||||||
queryFn: permissionService.getPermissions,
|
queryFn: permissionService.getPermissions,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
const permissionTree = useMemo(
|
const permissionTree = useMemo(
|
||||||
() => mapPermissionTree(permissionsQuery.data?.tree || []),
|
() => mapPermissionTree(permissionsQuery.data?.tree || []),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Plus, ShieldCheck } from 'lucide-react';
|
|||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
import { PoweredBy } from '@/components/powered-by';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionGuard } from '@/guards';
|
||||||
import { useAppStore } from '@/store/app.store';
|
import { useAppStore } from '@/store/app.store';
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ export default function RolesPage() {
|
|||||||
searchTerm: debouncedSearchTerm,
|
searchTerm: debouncedSearchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
});
|
});
|
||||||
const permissionsQuery = usePermissionsTreeQuery();
|
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
|
||||||
const roleForm = useRoleForm({
|
const roleForm = useRoleForm({
|
||||||
organizationId,
|
organizationId,
|
||||||
permissionTree: permissionsQuery.permissionTree,
|
permissionTree: permissionsQuery.permissionTree,
|
||||||
@@ -105,10 +107,12 @@ export default function RolesPage() {
|
|||||||
description="Manage roles and permission access"
|
description="Manage roles and permission access"
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={openCreate}>
|
<PermissionGuard permissions={PERMISSIONS.ROLE.CREATE}>
|
||||||
<Plus className="mr-2 size-4" />
|
<Button onClick={openCreate}>
|
||||||
Add Role
|
<Plus className="mr-2 size-4" />
|
||||||
</Button>
|
Add Role
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Edit, RotateCcw } from 'lucide-react';
|
|||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
import type { AdministrationUser } from '@/types';
|
import type { AdministrationUser } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -28,8 +30,12 @@ export function useUserColumns({
|
|||||||
onToggleStatus,
|
onToggleStatus,
|
||||||
pendingUserId,
|
pendingUserId,
|
||||||
}: UseUserColumnsParams): ColumnDef<AdministrationUser>[] {
|
}: UseUserColumnsParams): ColumnDef<AdministrationUser>[] {
|
||||||
return useMemo(
|
const { hasPermission } = usePermissions();
|
||||||
() => [
|
const canEdit = hasPermission(PERMISSIONS.USER.UPDATE);
|
||||||
|
const canDelete = hasPermission(PERMISSIONS.USER.DELETE);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const columns: ColumnDef<AdministrationUser>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'first_name',
|
accessorKey: 'first_name',
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
@@ -80,16 +86,23 @@ export function useUserColumns({
|
|||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
if (!canEdit && !canDelete) return columns;
|
||||||
cell: ({ row }) => {
|
|
||||||
const user = row.original;
|
columns.push({
|
||||||
return (
|
id: 'actions',
|
||||||
<div className="flex justify-end gap-2">
|
header: () => <div className="text-right">Actions</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const user = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{canEdit ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(user)}>
|
<Button variant="outline" size="sm" onClick={() => onEdit(user)}>
|
||||||
<Edit className="size-4" />
|
<Edit className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDelete ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -99,11 +112,12 @@ export function useUserColumns({
|
|||||||
<RotateCcw className="mr-2 size-4" />
|
<RotateCcw className="mr-2 size-4" />
|
||||||
{user.effective_status === 'active' ? 'Deactivate' : 'Activate'}
|
{user.effective_status === 'active' ? 'Deactivate' : 'Activate'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
);
|
</div>
|
||||||
},
|
);
|
||||||
},
|
},
|
||||||
],
|
});
|
||||||
[onEdit, onToggleStatus, pendingUserId],
|
|
||||||
);
|
return columns;
|
||||||
|
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingUserId]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Plus, Users } from 'lucide-react';
|
|||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
import { PoweredBy } from '@/components/powered-by';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionGuard } from '@/guards';
|
||||||
import type { AdministrationUser } from '@/types';
|
import type { AdministrationUser } from '@/types';
|
||||||
|
|
||||||
import { useUserColumns } from './components/UserColumns';
|
import { useUserColumns } from './components/UserColumns';
|
||||||
@@ -103,10 +105,12 @@ export default function UsersPage() {
|
|||||||
description="Manage users and role assignment"
|
description="Manage users and role assignment"
|
||||||
icon={Users}
|
icon={Users}
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={openCreate}>
|
<PermissionGuard permissions={PERMISSIONS.USER.CREATE}>
|
||||||
<Plus className="mr-2 size-4" />
|
<Button onClick={openCreate}>
|
||||||
Add User
|
<Plus className="mr-2 size-4" />
|
||||||
</Button>
|
Add User
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
21
src/app/not-found.tsx
Normal file
21
src/app/not-found.tsx
Normal file
@@ -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 (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">404</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold">Page not found</h1>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
The page you are looking for does not exist or may have been moved.
|
||||||
|
</p>
|
||||||
|
<Button asChild className="mt-6">
|
||||||
|
<Link href={ROUTES.DASHBOARD}>Go to dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/AppLoader.tsx
Normal file
18
src/components/AppLoader.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
interface AppLoaderProps {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppLoader({ label: _label }: AppLoaderProps) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex min-h-screen items-center justify-center bg-background"
|
||||||
|
aria-label="Loading"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-2.5 animate-bounce rounded-full bg-primary [animation-delay:-0.24s]" />
|
||||||
|
<span className="size-2.5 animate-bounce rounded-full bg-primary [animation-delay:-0.12s]" />
|
||||||
|
<span className="size-2.5 animate-bounce rounded-full bg-primary" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import * as React from 'react';
|
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 { 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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -12,6 +14,9 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
@@ -19,50 +24,11 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useAppStore } from '@/store/app.store';
|
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<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
const authUser = useAppStore((state) => state.user);
|
const authUser = useAppStore((state) => state.user);
|
||||||
|
const navItems = filterMenuItems(menuItems, hasPermission);
|
||||||
const user = {
|
const user = {
|
||||||
name: [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || 'Admin',
|
name: [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || 'Admin',
|
||||||
email: authUser?.email || '',
|
email: authUser?.email || '',
|
||||||
@@ -85,15 +51,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{data.navMain.map((item) => (
|
{navItems.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarNavItem key={item.title} item={item} pathname={pathname} />
|
||||||
<SidebarMenuButton asChild tooltip={item.title} isActive={pathname === item.url}>
|
|
||||||
<Link href={item.url}>
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@@ -105,3 +64,59 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Collapsible asChild defaultOpen={isChildActive} className="group/collapsible">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.children?.map((child) => (
|
||||||
|
<SidebarMenuSubItem key={child.title}>
|
||||||
|
{child.path ? (
|
||||||
|
<SidebarMenuSubButton asChild isActive={pathname === child.path}>
|
||||||
|
<Link href={child.path}>
|
||||||
|
<child.icon />
|
||||||
|
<span>{child.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
) : null}
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
{item.path ? (
|
||||||
|
<SidebarMenuButton asChild tooltip={item.title} isActive={isActive}>
|
||||||
|
<Link href={item.path}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
) : (
|
||||||
|
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
22
src/config/app.routes.ts
Normal file
22
src/config/app.routes.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
88
src/config/menu.config.ts
Normal file
88
src/config/menu.config.ts
Normal file
@@ -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<SVGProps<SVGSVGElement>>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
17
src/constants/permissions.ts
Normal file
17
src/constants/permissions.ts
Normal file
@@ -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;
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { AppLoader } from '@/components/AppLoader';
|
||||||
import { useAppStore } from '@/store/app.store';
|
import { useAppStore } from '@/store/app.store';
|
||||||
import { useAuthStore } from '@/store/auth.store';
|
import { useAuthStore } from '@/store/auth.store';
|
||||||
|
import { getRoutePermission } from '@/config/app.routes';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
import { ROUTES } from '@/utils/routes';
|
import { ROUTES } from '@/utils/routes';
|
||||||
import { useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const accessToken = useAuthStore((state) => state.accessToken);
|
const accessToken = useAuthStore((state) => state.accessToken);
|
||||||
const isInitialized = useAppStore((state) => state.isInitialized);
|
const isInitialized = useAppStore((state) => state.isInitialized);
|
||||||
const user = useAppStore((state) => state.user);
|
const user = useAppStore((state) => state.user);
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
const requiredPermission = getRoutePermission(pathname);
|
||||||
|
const canAccessRoute = hasPermission(requiredPermission);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
@@ -21,7 +28,17 @@ export function AuthGuard({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [accessToken, isInitialized, router]);
|
}, [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 <AppLoader label="Preparing workspace" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken || !user || !canAccessRoute) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { AppLoader } from '@/components/AppLoader';
|
||||||
import { useAppStore } from '@/store/app.store';
|
import { useAppStore } from '@/store/app.store';
|
||||||
import { useAuthStore } from '@/store/auth.store';
|
import { useAuthStore } from '@/store/auth.store';
|
||||||
import { ROUTES } from '@/utils/routes';
|
import { ROUTES } from '@/utils/routes';
|
||||||
@@ -19,7 +20,11 @@ export function GuestGuard({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [accessToken, isInitialized, router, user]);
|
}, [accessToken, isInitialized, router, user]);
|
||||||
|
|
||||||
if (!isInitialized || (accessToken && user)) return null;
|
if (!isInitialized) {
|
||||||
|
return <AppLoader label={accessToken ? 'Preparing workspace' : 'Loading'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitialized && accessToken && user) return <AppLoader label="Opening dashboard" />;
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAppStore } from '@/store/app.store';
|
import { usePermissions, type PermissionInput } from '@/hooks/usePermissions';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
type PermissionGuardProps = {
|
type PermissionGuardProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
permissions: string[];
|
permissions?: PermissionInput;
|
||||||
mode?: 'all' | 'any';
|
|
||||||
fallback?: ReactNode;
|
fallback?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PermissionGuard({
|
export function PermissionGuard({ children, permissions, fallback = null }: PermissionGuardProps) {
|
||||||
children,
|
const { hasPermission } = usePermissions();
|
||||||
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()));
|
|
||||||
|
|
||||||
if (!hasPermission) return fallback;
|
if (!hasPermission(permissions)) return fallback;
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const useLoginForm = () => {
|
|||||||
await initializeAuthenticatedApp();
|
await initializeAuthenticatedApp();
|
||||||
|
|
||||||
toast.success('Login successful');
|
toast.success('Login successful');
|
||||||
router.push(ROUTES.DASHBOARD);
|
router.replace(ROUTES.DASHBOARD);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
|
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/hooks/usePermissions.ts
Normal file
39
src/hooks/usePermissions.ts
Normal file
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export const ROUTES = {
|
|||||||
CHAINAGE: '/segment',
|
CHAINAGE: '/segment',
|
||||||
ROLES: '/roles',
|
ROLES: '/roles',
|
||||||
USERS: '/users',
|
USERS: '/users',
|
||||||
|
ACCESS: '/access',
|
||||||
ACCOUNT: '/account',
|
ACCOUNT: '/account',
|
||||||
UPLOAD: '/upload',
|
UPLOAD: '/upload',
|
||||||
RESULTS: '/results',
|
RESULTS: '/results',
|
||||||
|
|||||||
Reference in New Issue
Block a user