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 { 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]);
|
||||
}
|
||||
|
||||
@@ -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 || []),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
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';
|
||||
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
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
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',
|
||||
ROLES: '/roles',
|
||||
USERS: '/users',
|
||||
ACCESS: '/access',
|
||||
ACCOUNT: '/account',
|
||||
UPLOAD: '/upload',
|
||||
RESULTS: '/results',
|
||||
|
||||
Reference in New Issue
Block a user