feat: add permission-gated navigation and admin access

This commit is contained in:
2026-06-16 16:16:03 +05:30
parent 893b49c701
commit 964b177ae1
18 changed files with 401 additions and 112 deletions

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

View File

@@ -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<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',
header: 'Name',
@@ -53,16 +59,23 @@ export function useRoleColumns({
</Badge>
),
},
{
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
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)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
@@ -72,11 +85,12 @@ export function useRoleColumns({
<RotateCcw className="mr-2 size-4" />
{role.effective_status ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
},
],
[isStatusPending, onEdit, onToggleStatus],
);
});
return columns;
}, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]);
}

View File

@@ -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 || []),

View File

@@ -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={
<PermissionGuard permissions={PERMISSIONS.ROLE.CREATE}>
<Button onClick={openCreate}>
<Plus className="mr-2 size-4" />
Add Role
</Button>
</PermissionGuard>
}
/>

View File

@@ -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<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',
header: 'Name',
@@ -80,16 +86,23 @@ export function useUserColumns({
</Badge>
),
},
{
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
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)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
@@ -99,11 +112,12 @@ export function useUserColumns({
<RotateCcw className="mr-2 size-4" />
{user.effective_status === 'active' ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
},
],
[onEdit, onToggleStatus, pendingUserId],
);
});
return columns;
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingUserId]);
}

View File

@@ -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={
<PermissionGuard permissions={PERMISSIONS.USER.CREATE}>
<Button onClick={openCreate}>
<Plus className="mr-2 size-4" />
Add User
</Button>
</PermissionGuard>
}
/>

21
src/app/not-found.tsx Normal file
View 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>
);
}

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

View File

@@ -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<typeof Sidebar>) {
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<typeof Sidebar>) {
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{data.navMain.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title} isActive={pathname === item.url}>
<Link href={item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{navItems.map((item) => (
<SidebarNavItem key={item.title} item={item} pathname={pathname} />
))}
</SidebarMenu>
</SidebarGroup>
@@ -105,3 +64,59 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof 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
View 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
View 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);
}

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

View File

@@ -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 <AppLoader label="Preparing workspace" />;
}
if (!accessToken || !user || !canAccessRoute) {
return null;
}

View File

@@ -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 <AppLoader label={accessToken ? 'Preparing workspace' : 'Loading'} />;
}
if (isInitialized && accessToken && user) return <AppLoader label="Opening dashboard" />;
return children;
}

View File

@@ -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;
}

View File

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

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

View File

@@ -6,6 +6,7 @@ export const ROUTES = {
CHAINAGE: '/segment',
ROLES: '/roles',
USERS: '/users',
ACCESS: '/access',
ACCOUNT: '/account',
UPLOAD: '/upload',
RESULTS: '/results',