changes; Roles Module created

This commit is contained in:
2025-10-19 14:23:00 -06:00
parent c2218345ee
commit 4034c7e9b1
18 changed files with 2034 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
/**
* Create Role Button Component
* Button trigger for creating a new role
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
import { CreateRoleDialog } from './create-role-dialog';
export function CreateRoleButton() {
const [open, setOpen] = useState(false);
const handleSuccess = () => {
// Query invalidation is handled in the dialog component
// No need to manually refetch here
};
return (
<>
<Button onClick={() => setOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
Create Role
</Button>
<CreateRoleDialog
open={open}
onOpenChange={setOpen}
onSuccess={handleSuccess}
/>
</>
);
}

View File

@@ -0,0 +1,205 @@
/**
* Create Role Dialog Component
* Modal dialog for creating a new role with permissions
*/
'use client';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { FormInput } from '@/components/forms/form-input';
import { FormTextarea } from '@/components/forms/form-textarea';
import { Separator } from '@/components/ui/separator';
import {
usePostApiServicesAppRoleCreate,
useGetApiServicesAppRoleGetallpermissions,
getApiServicesAppRoleGetallQueryKey,
} from '@/api/hooks';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { createRoleSchema, toCreateRoleDto, type CreateRoleFormValues } from './role-form-schema';
import {
FormField,
FormItem,
FormLabel,
FormDescription,
FormControl,
FormMessage,
} from '@/components/ui/form';
import { PermissionsSelector } from './permissions-selector';
interface CreateRoleDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function CreateRoleDialog({ open, onOpenChange, onSuccess }: CreateRoleDialogProps) {
const queryClient = useQueryClient();
const createMutation = usePostApiServicesAppRoleCreate();
const { data: permissionsData, isLoading: permissionsLoading } =
useGetApiServicesAppRoleGetallpermissions();
const form = useForm<CreateRoleFormValues>({
resolver: zodResolver(createRoleSchema),
defaultValues: {
name: '',
displayName: '',
description: '',
grantedPermissions: [],
},
});
// Reset form when dialog closes
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
const onSubmit = async (values: CreateRoleFormValues) => {
try {
const dto = toCreateRoleDto(values);
await createMutation.mutateAsync({
data: dto,
});
// Invalidate roles query to refresh the table
await queryClient.invalidateQueries({
queryKey: getApiServicesAppRoleGetallQueryKey(),
});
toast.success(`Role "${values.displayName || values.name}" created successfully`);
onSuccess();
onOpenChange(false);
form.reset();
} catch (error: any) {
toast.error(error?.message || 'Failed to create role');
}
};
const handleClose = () => {
form.reset();
onOpenChange(false);
};
const availablePermissions = permissionsData?.items || [];
const isSubmitting = createMutation.isPending;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Role</DialogTitle>
<DialogDescription>
Create a new role and assign permissions. All fields marked with * are required.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Basic Information</h3>
<div className="grid gap-4 md:grid-cols-2">
<FormInput
control={form.control}
name="name"
label="Role Name"
placeholder="Content_Editor"
required
disabled={isSubmitting}
description="Internal role identifier"
/>
<FormInput
control={form.control}
name="displayName"
label="Display Name"
placeholder="Content Editor"
required
disabled={isSubmitting}
description="Human-readable name"
/>
</div>
<FormTextarea
control={form.control}
name="description"
label="Description"
placeholder="Can create and edit content..."
disabled={isSubmitting}
description="Brief description of the role's purpose"
rows={3}
/>
</div>
<Separator />
{/* Permissions */}
<div className="space-y-4">
<FormField
control={form.control}
name="grantedPermissions"
render={({ field }) => (
<FormItem>
<div className="mb-4">
<FormLabel>Permissions</FormLabel>
<FormDescription>
Select the permissions to grant to this role
</FormDescription>
</div>
{permissionsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading permissions...
</div>
) : availablePermissions.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-sm text-muted-foreground">
No permissions available in the system.
</p>
</div>
) : (
<FormControl>
<PermissionsSelector
permissions={availablePermissions}
selectedPermissions={field.value || []}
onPermissionsChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
)}
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Creating...' : 'Create Role'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,148 @@
/**
* Delete Role Dialog Component
* Confirmation dialog for deleting a role with protection for system roles
*/
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
useDeleteApiServicesAppRoleDelete,
getApiServicesAppRoleGetallQueryKey,
} from '@/api/hooks';
import { toast } from 'sonner';
import { Loader2, AlertTriangle, Lock } from 'lucide-react';
import type { RoleDto } from '@/api/types';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface DeleteRoleDialogProps {
role: RoleDto | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
isStatic?: boolean;
}
export function DeleteRoleDialog({
role,
open,
onOpenChange,
onSuccess,
isStatic = false,
}: DeleteRoleDialogProps) {
const queryClient = useQueryClient();
const [isDeleting, setIsDeleting] = useState(false);
const deleteMutation = useDeleteApiServicesAppRoleDelete();
const handleDelete = async () => {
if (!role?.id) return;
// Extra protection for system roles
if (isStatic) {
toast.error('System roles cannot be deleted');
return;
}
setIsDeleting(true);
try {
await deleteMutation.mutateAsync({
params: { Id: role.id },
});
// Invalidate roles query to refresh the table
await queryClient.invalidateQueries({
queryKey: getApiServicesAppRoleGetallQueryKey(),
});
toast.success(`Role "${role.displayName || role.name}" deleted successfully`);
onSuccess();
onOpenChange(false);
} catch (error: any) {
toast.error(error?.message || 'Failed to delete role');
} finally {
setIsDeleting(false);
}
};
if (!role) return null;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Role</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the role{' '}
<span className="font-semibold text-foreground">
{role.displayName || role.name}
</span>
?
</AlertDialogDescription>
</AlertDialogHeader>
{isStatic ? (
<Alert variant="destructive">
<Lock className="h-4 w-4" />
<AlertDescription>
<strong>System Role Protection:</strong> This is a system role and cannot be
deleted. System roles are essential for application functionality.
</AlertDescription>
</Alert>
) : (
<>
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="text-sm text-destructive">
<strong>Warning:</strong> Deleting this role will:
</p>
<ul className="mt-2 list-inside list-disc text-sm text-destructive">
<li>Permanently remove the role and its permissions</li>
<li>Remove this role from all users who have it assigned</li>
<li>This action cannot be undone</li>
</ul>
</div>
{role.grantedPermissions && role.grantedPermissions.length > 0 && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This role currently has{' '}
<strong>{role.grantedPermissions.length} permission(s)</strong> granted. These
permissions will be removed for all users assigned to this role.
</AlertDescription>
</Alert>
)}
</>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
{!isStatic && (
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isDeleting ? 'Deleting...' : 'Delete Role'}
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,267 @@
/**
* Edit Role Dialog Component
* Modal dialog for editing an existing role with permissions
* Protected for system/static roles
*/
'use client';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { FormInput } from '@/components/forms/form-input';
import { FormTextarea } from '@/components/forms/form-textarea';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
usePutApiServicesAppRoleUpdate,
useGetApiServicesAppRoleGetroleforedit,
useGetApiServicesAppRoleGetallpermissions,
getApiServicesAppRoleGetallQueryKey,
} from '@/api/hooks';
import { toast } from 'sonner';
import { Loader2, Lock, AlertTriangle } from 'lucide-react';
import { updateRoleSchema, toUpdateRoleDto, type UpdateRoleFormValues } from './role-form-schema';
import type { RoleDto } from '@/api/types';
import {
FormField,
FormItem,
FormLabel,
FormDescription,
FormControl,
FormMessage,
} from '@/components/ui/form';
import { PermissionsSelector } from './permissions-selector';
interface EditRoleDialogProps {
role: RoleDto | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
/**
* Determine if a role is a system role
*/
function isSystemRole(roleName: string): boolean {
const systemRoles = ['Admin', 'Administrator', 'User', 'Guest', 'SuperAdmin'];
return systemRoles.includes(roleName);
}
export function EditRoleDialog({ role, open, onOpenChange, onSuccess }: EditRoleDialogProps) {
const queryClient = useQueryClient();
const updateMutation = usePutApiServicesAppRoleUpdate();
// Fetch role details with permissions
const { data: roleForEdit, isLoading: roleLoading } = useGetApiServicesAppRoleGetroleforedit(
{ Id: role?.id },
{
query: {
enabled: !!role?.id && open,
},
}
);
const { data: permissionsData, isLoading: permissionsLoading } =
useGetApiServicesAppRoleGetallpermissions({
query: {
enabled: open,
},
});
const form = useForm<UpdateRoleFormValues>({
resolver: zodResolver(updateRoleSchema),
defaultValues: {
id: 0,
name: '',
displayName: '',
description: '',
grantedPermissions: [],
isStatic: false,
},
});
const isStatic = form.watch('isStatic');
// Populate form when role data changes
useEffect(() => {
if (roleForEdit && open) {
const roleData = roleForEdit.role;
const grantedPerms = roleForEdit.grantedPermissionNames || [];
if (roleData) {
form.reset({
id: roleData.id!,
name: roleData.name,
displayName: roleData.displayName,
description: roleData.description || '',
grantedPermissions: grantedPerms,
isStatic: roleData.isStatic || isSystemRole(roleData.name),
});
}
}
}, [roleForEdit, open, form]);
const onSubmit = async (values: UpdateRoleFormValues) => {
try {
const dto = toUpdateRoleDto(values);
await updateMutation.mutateAsync({
data: dto as any,
});
// Invalidate roles query to refresh the table
await queryClient.invalidateQueries({
queryKey: getApiServicesAppRoleGetallQueryKey(),
});
toast.success(`Role "${values.displayName || values.name}" updated successfully`);
onSuccess();
onOpenChange(false);
form.reset();
} catch (error: any) {
toast.error(error?.message || 'Failed to update role');
}
};
const handleClose = () => {
form.reset();
onOpenChange(false);
};
const availablePermissions = permissionsData?.items || [];
const isSubmitting = updateMutation.isPending;
const isLoading = roleLoading || permissionsLoading;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Role</DialogTitle>
<DialogDescription>
Update role information and permissions.
</DialogDescription>
</DialogHeader>
{isStatic && (
<Alert variant="default" className="border-amber-500 bg-amber-50 dark:bg-amber-950">
<Lock className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertDescription className="text-amber-700 dark:text-amber-300">
<strong>System Role:</strong> This is a protected system role. The role name cannot
be changed, but you can modify permissions.
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading role details...</span>
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Basic Information</h3>
<div className="grid gap-4 md:grid-cols-2">
<FormInput
control={form.control}
name="name"
label="Role Name"
placeholder="Content_Editor"
required
disabled={isSubmitting || isStatic}
description={isStatic ? 'Cannot modify system role name' : 'Internal role identifier'}
/>
<FormInput
control={form.control}
name="displayName"
label="Display Name"
placeholder="Content Editor"
required
disabled={isSubmitting}
description="Human-readable name"
/>
</div>
<FormTextarea
control={form.control}
name="description"
label="Description"
placeholder="Can create and edit content..."
disabled={isSubmitting}
description="Brief description of the role's purpose"
rows={3}
/>
</div>
<Separator />
{/* Permissions */}
<div className="space-y-4">
<FormField
control={form.control}
name="grantedPermissions"
render={({ field }) => (
<FormItem>
<div className="mb-4">
<FormLabel>Permissions</FormLabel>
<FormDescription>
Modify the permissions granted to this role
</FormDescription>
</div>
{permissionsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading permissions...
</div>
) : availablePermissions.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-sm text-muted-foreground">
No permissions available in the system.
</p>
</div>
) : (
<FormControl>
<PermissionsSelector
permissions={availablePermissions}
selectedPermissions={field.value || []}
onPermissionsChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
)}
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Updating...' : 'Update Role'}
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,21 @@
/**
* Permissions Count Badge Component
* Displays the number of permissions granted to a role
*/
import { Badge } from '@/components/ui/badge';
import { Shield } from 'lucide-react';
interface PermissionsCountBadgeProps {
count: number;
showIcon?: boolean;
}
export function PermissionsCountBadge({ count, showIcon = true }: PermissionsCountBadgeProps) {
return (
<Badge variant="secondary" className="gap-1">
{showIcon && <Shield className="h-3 w-3" />}
{count} {count === 1 ? 'permission' : 'permissions'}
</Badge>
);
}

View File

@@ -0,0 +1,296 @@
/**
* Permissions Selector Component
* Grouped, searchable permission checkboxes for role management
*/
'use client';
import { useState, useMemo } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible';
import { Badge } from '@/components/ui/badge';
import { Search, ChevronDown, ChevronRight } from 'lucide-react';
import type { FlatPermissionDto } from '@/api/types';
interface PermissionsSelectorProps {
permissions: FlatPermissionDto[];
selectedPermissions: string[];
onPermissionsChange: (permissions: string[]) => void;
disabled?: boolean;
}
interface PermissionGroup {
name: string;
permissions: FlatPermissionDto[];
}
/**
* Extract group name from permission name
* E.g., "Pages.Users.Create" -> "Pages.Users"
*/
function getPermissionGroup(permissionName: string): string {
const parts = permissionName.split('.');
if (parts.length > 1) {
return parts.slice(0, -1).join('.');
}
return 'General';
}
/**
* Get permission display name (last part)
* E.g., "Pages.Users.Create" -> "Create"
*/
function getPermissionShortName(permissionName: string): string {
const parts = permissionName.split('.');
return parts[parts.length - 1];
}
export function PermissionsSelector({
permissions,
selectedPermissions,
onPermissionsChange,
disabled = false
}: PermissionsSelectorProps) {
const [searchQuery, setSearchQuery] = useState('');
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
// Group permissions by category
const groupedPermissions = useMemo(() => {
const groups: Record<string, FlatPermissionDto[]> = {};
permissions.forEach((permission) => {
if (!permission.name) return;
const groupName = getPermissionGroup(permission.name);
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(permission);
});
return Object.entries(groups)
.map(([name, perms]) => ({ name, permissions: perms }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [permissions]);
// Filter groups based on search
const filteredGroups = useMemo(() => {
if (!searchQuery) return groupedPermissions;
return groupedPermissions
.map((group) => ({
...group,
permissions: group.permissions.filter((perm) =>
perm.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
perm.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
perm.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
}))
.filter((group) => group.permissions.length > 0);
}, [groupedPermissions, searchQuery]);
// Handle permission toggle
const handlePermissionToggle = (permissionName: string) => {
if (disabled) return;
const isSelected = selectedPermissions.includes(permissionName);
if (isSelected) {
onPermissionsChange(selectedPermissions.filter((p) => p !== permissionName));
} else {
onPermissionsChange([...selectedPermissions, permissionName]);
}
};
// Handle group select all
const handleGroupSelectAll = (group: PermissionGroup) => {
if (disabled) return;
const groupPermissionNames = group.permissions
.map((p) => p.name)
.filter((name): name is string => !!name);
const allSelected = groupPermissionNames.every((name) =>
selectedPermissions.includes(name)
);
if (allSelected) {
// Deselect all in group
onPermissionsChange(
selectedPermissions.filter((p) => !groupPermissionNames.includes(p))
);
} else {
// Select all in group
const newSelected = [...selectedPermissions];
groupPermissionNames.forEach((name) => {
if (!newSelected.includes(name)) {
newSelected.push(name);
}
});
onPermissionsChange(newSelected);
}
};
// Handle global select all
const handleSelectAll = () => {
if (disabled) return;
const allPermissionNames = permissions
.map((p) => p.name)
.filter((name): name is string => !!name);
if (selectedPermissions.length === allPermissionNames.length) {
onPermissionsChange([]);
} else {
onPermissionsChange(allPermissionNames);
}
};
// Toggle group expansion
const toggleGroup = (groupName: string) => {
setExpandedGroups((prev) =>
prev.includes(groupName)
? prev.filter((g) => g !== groupName)
: [...prev, groupName]
);
};
// Check if group is fully selected
const isGroupFullySelected = (group: PermissionGroup) => {
const groupPermissionNames = group.permissions
.map((p) => p.name)
.filter((name): name is string => !!name);
return groupPermissionNames.every((name) => selectedPermissions.includes(name));
};
// Check if group is partially selected
const isGroupPartiallySelected = (group: PermissionGroup) => {
const groupPermissionNames = group.permissions
.map((p) => p.name)
.filter((name): name is string => !!name);
const selectedCount = groupPermissionNames.filter((name) =>
selectedPermissions.includes(name)
).length;
return selectedCount > 0 && selectedCount < groupPermissionNames.length;
};
return (
<div className="space-y-4">
{/* Search and Select All */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search permissions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
disabled={disabled}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={disabled}
>
{selectedPermissions.length === permissions.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
{/* Selected count */}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{selectedPermissions.length} of {permissions.length} selected
</span>
</div>
{/* Permission groups */}
<ScrollArea className="h-[400px] rounded-md border p-4">
<div className="space-y-4">
{filteredGroups.map((group) => {
const isExpanded = expandedGroups.includes(group.name) || !!searchQuery;
const isFullySelected = isGroupFullySelected(group);
const isPartiallySelected = isGroupPartiallySelected(group);
return (
<div key={group.name} className="space-y-2">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => toggleGroup(group.name)}
className="flex items-center gap-2 font-medium hover:text-primary transition-colors"
disabled={disabled}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span>{group.name}</span>
<Badge variant="secondary" className="text-xs">
{group.permissions.length}
</Badge>
</button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleGroupSelectAll(group)}
disabled={disabled}
>
{isFullySelected ? 'Deselect All' : 'Select All'}
</Button>
</div>
{isExpanded && (
<div className="ml-6 space-y-2">
{group.permissions.map((permission) => (
<div
key={permission.name}
className="flex items-start space-x-2 rounded-md p-2 hover:bg-muted/50"
>
<Checkbox
id={permission.name}
checked={selectedPermissions.includes(permission.name || '')}
onCheckedChange={() => handlePermissionToggle(permission.name || '')}
disabled={disabled}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={permission.name}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{permission.displayName || getPermissionShortName(permission.name || '')}
</label>
{permission.description && (
<p className="text-xs text-muted-foreground">
{permission.description}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
})}
{filteredGroups.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No permissions found matching &quot;{searchQuery}&quot;
</div>
)}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,105 @@
/**
* Role Actions Dropdown Component
* Dropdown menu with actions for each role row
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
MoreHorizontal,
Pencil,
Trash2,
Lock,
} from 'lucide-react';
import { EditRoleDialog } from './edit-role-dialog';
import { DeleteRoleDialog } from './delete-role-dialog';
import type { RoleDto } from '@/api/types';
interface RoleActionsDropdownProps {
role: RoleDto;
onSuccess?: () => void;
}
/**
* Determine if a role is a system role
*/
function isSystemRole(roleName: string): boolean {
const systemRoles = ['Admin', 'Administrator', 'User', 'Guest', 'SuperAdmin'];
return systemRoles.includes(roleName);
}
export function RoleActionsDropdown({ role, onSuccess }: RoleActionsDropdownProps) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const isStatic = isSystemRole(role.name);
const handleSuccess = () => {
onSuccess?.();
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
disabled={isStatic}
className={isStatic ? 'opacity-50 cursor-not-allowed' : 'text-destructive focus:text-destructive'}
>
{isStatic ? (
<>
<Lock className="mr-2 h-4 w-4" />
Protected
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Edit Dialog */}
<EditRoleDialog
role={role}
open={editOpen}
onOpenChange={setEditOpen}
onSuccess={handleSuccess}
/>
{/* Delete Dialog */}
<DeleteRoleDialog
role={role}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onSuccess={handleSuccess}
isStatic={isStatic}
/>
</>
);
}

View File

@@ -0,0 +1,99 @@
/**
* Role Form Validation Schemas
* Zod schemas for creating and updating roles
*/
import { z } from 'zod';
/**
* Schema for creating a new role
*/
export const createRoleSchema = z.object({
name: z
.string()
.min(1, 'Role name is required')
.max(32, 'Role name must be less than 32 characters')
.regex(
/^[a-zA-Z0-9\s_-]+$/,
'Role name can only contain letters, numbers, spaces, hyphens and underscores'
),
displayName: z
.string()
.min(1, 'Display name is required')
.max(64, 'Display name must be less than 64 characters'),
description: z
.string()
.max(5000, 'Description must be less than 5000 characters')
.optional()
.or(z.literal(''))
.nullable(),
grantedPermissions: z.array(z.string()).optional().nullable(),
});
/**
* Schema for updating an existing role
* Note: isStatic roles have restrictions on editing
*/
export const updateRoleSchema = z.object({
id: z.number(),
name: z
.string()
.min(1, 'Role name is required')
.max(32, 'Role name must be less than 32 characters')
.regex(
/^[a-zA-Z0-9\s_-]+$/,
'Role name can only contain letters, numbers, spaces, hyphens and underscores'
),
displayName: z
.string()
.min(1, 'Display name is required')
.max(64, 'Display name must be less than 64 characters'),
description: z
.string()
.max(5000, 'Description must be less than 5000 characters')
.optional()
.or(z.literal(''))
.nullable(),
grantedPermissions: z.array(z.string()).optional().nullable(),
isStatic: z.boolean().optional(),
});
/**
* Type inference for create role form
*/
export type CreateRoleFormValues = z.infer<typeof createRoleSchema>;
/**
* Type inference for update role form
*/
export type UpdateRoleFormValues = z.infer<typeof updateRoleSchema>;
/**
* Helper to transform form values to CreateRoleDto
* Removes empty description and normalizes data
*/
export function toCreateRoleDto(values: CreateRoleFormValues) {
const { description, ...rest } = values;
// Only include description if it was provided
if (description && description.length > 0) {
return { ...rest, description };
}
return rest;
}
/**
* Helper to transform form values to UpdateRoleDto
* Removes empty description and normalizes data
*/
export function toUpdateRoleDto(values: UpdateRoleFormValues) {
const { description, isStatic, ...rest } = values;
// Only include description if it was provided
if (description && description.length > 0) {
return { ...rest, description };
}
return rest;
}

View File

@@ -0,0 +1,30 @@
/**
* Role Static Badge Component
* Displays badge for system/static roles that cannot be deleted
*/
import { Badge } from '@/components/ui/badge';
import { Lock, Unlock } from 'lucide-react';
interface RoleStaticBadgeProps {
isStatic: boolean;
showIcon?: boolean;
}
export function RoleStaticBadge({ isStatic, showIcon = true }: RoleStaticBadgeProps) {
if (isStatic) {
return (
<Badge variant="outline" className="gap-1 border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-400">
{showIcon && <Lock className="h-3 w-3" />}
System Role
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1">
{showIcon && <Unlock className="h-3 w-3" />}
Custom Role
</Badge>
);
}

View File

@@ -0,0 +1,92 @@
/**
* Roles Stats Cards Component
* Displays statistics cards for roles overview
*/
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Shield, Lock, Unlock, TrendingUp } from 'lucide-react';
import type { RoleDto } from '@/api/types';
interface RolesStatsCardsProps {
roles: RoleDto[];
}
/**
* Determine if a role is a system role
*/
function isSystemRole(roleName: string): boolean {
const systemRoles = ['Admin', 'Administrator', 'User', 'Guest', 'SuperAdmin'];
return systemRoles.includes(roleName);
}
export function RolesStatsCards({ roles }: RolesStatsCardsProps) {
const totalRoles = roles.length;
const staticRoles = roles.filter((role) => isSystemRole(role.name)).length;
const customRoles = totalRoles - staticRoles;
// Calculate average permissions per role
const totalPermissions = roles.reduce(
(sum, role) => sum + (role.grantedPermissions?.length || 0),
0
);
const avgPermissions = totalRoles > 0 ? Math.round(totalPermissions / totalRoles) : 0;
const stats = [
{
title: 'Total Roles',
value: totalRoles,
description: 'All roles in the system',
icon: Shield,
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-50 dark:bg-blue-950',
},
{
title: 'System Roles',
value: staticRoles,
description: 'Protected system roles',
icon: Lock,
color: 'text-amber-600 dark:text-amber-400',
bgColor: 'bg-amber-50 dark:bg-amber-950',
},
{
title: 'Custom Roles',
value: customRoles,
description: 'User-created roles',
icon: Unlock,
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-50 dark:bg-green-950',
},
{
title: 'Avg. Permissions',
value: avgPermissions,
description: 'Average per role',
icon: TrendingUp,
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-50 dark:bg-purple-950',
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<div className={`rounded-md p-2 ${stat.bgColor}`}>
<Icon className={`h-4 w-4 ${stat.color}`} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className="text-xs text-muted-foreground mt-1">{stat.description}</p>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,56 @@
/**
* Roles Stats Section Component
* Wrapper component for role statistics with data fetching
*/
'use client';
import { useGetApiServicesAppRoleGetall } from '@/api/hooks';
import { RolesStatsCards } from './roles-stats-cards';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
export function RolesStatsSection() {
const { data, isLoading, isError, error } = useGetApiServicesAppRoleGetall({
query: {
refetchOnWindowFocus: false,
},
});
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
<Skeleton className="h-8 w-16 mt-2" />
<Skeleton className="h-3 w-32 mt-2" />
</CardContent>
</Card>
))}
</div>
);
}
if (isError) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">Failed to load role statistics: {error?.message}</span>
</div>
</CardContent>
</Card>
);
}
const roles = data?.items || [];
return <RolesStatsCards roles={roles} />;
}

View File

@@ -0,0 +1,162 @@
/**
* Roles Table Column Definitions
* TanStack Table column configuration for roles list
*/
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { Checkbox } from '@/components/ui/checkbox';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { RoleStaticBadge } from './role-static-badge';
import { PermissionsCountBadge } from './permissions-count-badge';
import { RoleActionsDropdown } from './role-actions-dropdown';
import type { RoleDto } from '@/api/types';
/**
* Get role initials for avatar
*/
function getRoleInitials(name: string, displayName: string): string {
const nameParts = displayName?.split(' ') || name?.split(' ') || [];
if (nameParts.length >= 2) {
return `${nameParts[0].charAt(0)}${nameParts[1].charAt(0)}`.toUpperCase();
}
if (nameParts.length === 1) {
return nameParts[0].substring(0, 2).toUpperCase();
}
return '?';
}
/**
* Truncate text with ellipsis
*/
function truncateText(text: string | null | undefined, maxLength: number): string {
if (!text) return '-';
if (text.length <= maxLength) return text;
return `${text.substring(0, maxLength)}...`;
}
export const getRolesTableColumns = (): ColumnDef<RoleDto>[] => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Role Name" />,
cell: ({ row }) => {
const role = row.original;
const initials = getRoleInitials(role.name, role.displayName);
return (
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9 bg-primary/10">
<AvatarFallback className="text-xs font-medium bg-primary/10 text-primary">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="font-medium">{role.name}</span>
{role.normalizedName && (
<span className="text-xs font-mono text-muted-foreground">{role.normalizedName}</span>
)}
</div>
</div>
);
},
enableSorting: true,
},
{
accessorKey: 'displayName',
header: ({ column }) => <DataTableColumnHeader column={column} title="Display Name" />,
cell: ({ row }) => (
<div className="flex items-center">
<span className="truncate">{row.getValue('displayName')}</span>
</div>
),
enableSorting: true,
},
{
accessorKey: 'description',
header: ({ column }) => <DataTableColumnHeader column={column} title="Description" />,
cell: ({ row }) => {
const description = row.getValue('description') as string | null | undefined;
return (
<div className="flex items-center max-w-md">
<span className="text-sm text-muted-foreground truncate">
{truncateText(description, 60)}
</span>
</div>
);
},
enableSorting: false,
},
{
accessorKey: 'grantedPermissions',
header: ({ column }) => <DataTableColumnHeader column={column} title="Permissions" />,
cell: ({ row }) => {
const permissions = row.original.grantedPermissions;
const count = permissions?.length || 0;
return <PermissionsCountBadge count={count} />;
},
enableSorting: false,
},
{
id: 'isStatic',
header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />,
cell: ({ row }) => {
// RoleDto doesn't have isStatic, but RoleEditDto does
// We'll determine if it's static by checking if it's a well-known role name
const isStatic = isSystemRole(row.original.name);
return <RoleStaticBadge isStatic={isStatic} />;
},
enableSorting: false,
filterFn: (row, id, value: string[]) => {
if (value.includes('all')) return true;
const isStatic = isSystemRole(row.original.name);
if (value.includes('static') && isStatic) return true;
if (value.includes('custom') && !isStatic) return true;
return false;
},
},
{
id: 'actions',
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => <RoleActionsDropdown role={row.original} />,
enableSorting: false,
enableHiding: false,
},
];
/**
* Determine if a role is a system role based on common patterns
* This is a heuristic until we have the actual isStatic field from the API
*/
function isSystemRole(roleName: string): boolean {
const systemRoles = ['Admin', 'Administrator', 'User', 'Guest', 'SuperAdmin'];
return systemRoles.includes(roleName);
}

View File

@@ -0,0 +1,42 @@
/**
* Roles Table Context
* Provides refetch and mutation success handlers to child components
*/
'use client';
import { createContext, useContext } from 'react';
interface RolesTableContextValue {
refetch: () => void;
}
const RolesTableContext = createContext<RolesTableContextValue | undefined>(undefined);
export function RolesTableProvider({
children,
value,
}: {
children: React.ReactNode;
value: RolesTableContextValue;
}) {
return (
<RolesTableContext.Provider value={value}>
{children}
</RolesTableContext.Provider>
);
}
/**
* Hook to access roles table context
* @throws Error if used outside RolesTableProvider
*/
export function useRolesTable() {
const context = useContext(RolesTableContext);
if (!context) {
throw new Error('useRolesTable must be used within RolesTableProvider');
}
return context;
}

View File

@@ -0,0 +1,74 @@
/**
* Roles Table Toolbar Component
* Search bar and filters for the roles table
*/
'use client';
import { Table } from '@tanstack/react-table';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DataTableFacetedFilter } from '@/components/ui/table/data-table-faceted-filter';
import { DataTableViewOptions } from '@/components/ui/table/data-table-view-options';
import type { RoleDto } from '@/api/types';
interface RolesTableToolbarProps {
table: Table<RoleDto>;
}
export function RolesTableToolbar({ table }: RolesTableToolbarProps) {
const isFiltered = table.getState().columnFilters.length > 0;
// Role type filter options (Static vs Custom)
const roleTypeOptions = [
{
label: 'System Roles',
value: 'static',
icon: undefined,
},
{
label: 'Custom Roles',
value: 'custom',
icon: undefined,
},
];
return (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-1 items-center space-x-2">
{/* Global Search */}
<Input
placeholder="Search roles..."
value={(table.getColumn('name')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('name')?.setFilterValue(event.target.value)}
className="h-9 w-[150px] lg:w-[250px]"
/>
{/* Role Type Filter */}
{table.getColumn('isStatic') && (
<DataTableFacetedFilter
column={table.getColumn('isStatic')}
title="Type"
options={roleTypeOptions}
/>
)}
{/* Reset Filters */}
{isFiltered && (
<Button
variant="ghost"
onClick={() => table.resetColumnFilters()}
className="h-9 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
{/* View Options */}
<DataTableViewOptions table={table} />
</div>
);
}

View File

@@ -0,0 +1,172 @@
/**
* Roles Table Component
* Main table component with TanStack Table integration
*/
'use client';
import { useState, useMemo } from 'react';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
ColumnFiltersState,
SortingState,
VisibilityState,
} from '@tanstack/react-table';
import { useGetApiServicesAppRoleGetall } from '@/api/hooks';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableSkeleton } from '@/components/ui/table/data-table-skeleton';
import { getRolesTableColumns } from './roles-table-columns';
import { RolesTableToolbar } from './roles-table-toolbar';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { RoleDto } from '@/api/types';
import { RolesTableProvider } from './roles-table-context';
/**
* API Error type with response structure
*/
interface ApiError {
response?: {
status: number;
data?: unknown;
};
message?: string;
}
/**
* Type guard to check if error is an API error
*/
function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'response' in error
);
}
export function RolesTable() {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const [sorting, setSorting] = useState<SortingState>([]);
// Fetch roles
const {
data: rolesData,
isLoading,
isError,
error,
refetch,
} = useGetApiServicesAppRoleGetall({
query: {
refetchOnWindowFocus: false,
},
});
const roles = useMemo(() => rolesData?.items || [], [rolesData]);
const columns = useMemo(() => getRolesTableColumns(), []);
const table = useReactTable({
data: roles,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
// Loading state
if (isLoading) {
return (
<div className="space-y-4">
<DataTableSkeleton
columnCount={7}
filterCount={1}
cellWidths={['auto', 'auto', 'auto', 'auto', 'auto', 'auto', '3rem']}
shrinkZero
/>
</div>
);
}
// Error state
if (isError) {
const statusCode = isApiError(error) ? error.response?.status : null;
const isPermissionError = statusCode === 401 || statusCode === 403;
const errorMessage = isPermissionError
? 'You do not have permission to view roles. Please contact your administrator to grant you the necessary permissions.'
: `Failed to load roles: ${error?.message || 'Unknown error'}`;
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="flex flex-col gap-3">
<span>{errorMessage}</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
>
<RefreshCw className="mr-2 h-4 w-4" />
Retry
</Button>
{isPermissionError && (
<Button
variant="outline"
size="sm"
onClick={() => window.location.href = '/dashboard/overview'}
>
Go to Dashboard
</Button>
)}
</div>
</AlertDescription>
</Alert>
);
}
// Empty state
if (roles.length === 0 && !isLoading) {
return (
<div className="rounded-md border border-dashed p-12 text-center">
<div className="flex flex-col items-center gap-2">
<h3 className="text-lg font-semibold">No roles found</h3>
<p className="text-sm text-muted-foreground">
Get started by creating your first role.
</p>
</div>
</div>
);
}
return (
<RolesTableProvider value={{ refetch }}>
<DataTable table={table}>
<RolesTableToolbar table={table} />
</DataTable>
</RolesTableProvider>
);
}

View File

@@ -0,0 +1,75 @@
/**
* Loading State for Roles Page
* Skeleton loader displayed while page is loading
*/
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { DataTableSkeleton } from '@/components/ui/table/data-table-skeleton';
import PageContainer from '@/components/layout/page-container';
export default function RolesLoadingPage() {
return (
<PageContainer>
<div className="flex flex-col gap-6">
{/* Header Skeleton */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-5 w-96" />
</div>
<Skeleton className="h-10 w-32" />
</div>
{/* Alert Skeleton */}
<Skeleton className="h-16 w-full" />
{/* Stats Cards Skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
<Skeleton className="h-8 w-16 mt-2" />
<Skeleton className="h-3 w-32 mt-2" />
</CardContent>
</Card>
))}
</div>
{/* Separator */}
<Skeleton className="h-px w-full" />
{/* Table Skeleton */}
<Card>
<CardContent className="p-6">
<DataTableSkeleton
columnCount={7}
filterCount={1}
cellWidths={['auto', 'auto', 'auto', 'auto', 'auto', 'auto', '3rem']}
shrinkZero
/>
</CardContent>
</Card>
{/* Help Section Skeleton */}
<Card>
<CardContent className="p-6">
<Skeleton className="h-6 w-48 mb-4" />
<div className="grid gap-4 md:grid-cols-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-12 w-full" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Roles Management Page
* Complete CRUD interface for role and permission management
*/
import { Metadata } from 'next';
import { Suspense } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { RolesTable } from './_components/roles-table';
import { CreateRoleButton } from './_components/create-role-button';
import { RolesStatsSection } from './_components/roles-stats-section';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { InfoIcon } from 'lucide-react';
import { DataTableSkeleton } from '@/components/ui/table/data-table-skeleton';
import PageContainer from '@/components/layout/page-container';
export const metadata: Metadata = {
title: 'Roles Management',
description: 'Manage system roles and permissions',
};
/**
* Table Section with Suspense
*/
function TableSection() {
return (
<Suspense
fallback={
<DataTableSkeleton
columnCount={7}
filterCount={1}
cellWidths={['auto', 'auto', 'auto', 'auto', 'auto', 'auto', '3rem']}
shrinkZero
/>
}
>
<RolesTable />
</Suspense>
);
}
export default function RolesPage() {
return (
<PageContainer>
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Roles Management</h1>
<p className="text-muted-foreground mt-2">
Define roles and assign granular permissions to control system access
</p>
</div>
<CreateRoleButton />
</div>
{/* Info Alert */}
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Roles group permissions together for easier user management. System roles are protected
and cannot be deleted. Each role can have multiple permissions assigned.
</AlertDescription>
</Alert>
{/* Statistics Cards */}
<RolesStatsSection />
<Separator />
{/* Roles Table */}
<Card>
<CardHeader>
<CardTitle>All Roles</CardTitle>
<CardDescription>
View and manage all roles and their assigned permissions
</CardDescription>
</CardHeader>
<CardContent>
<TableSection />
</CardContent>
</Card>
{/* Help Section */}
<Card>
<CardHeader>
<CardTitle>Quick Actions Guide</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
1
</span>
Create Role
</h3>
<p className="text-sm text-muted-foreground">
Click &quot;Create Role&quot; to define a new role. Set a name, description,
and select the permissions this role should have.
</p>
</div>
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
2
</span>
Edit Permissions
</h3>
<p className="text-sm text-muted-foreground">
Use the actions menu to modify role permissions. Permissions are grouped
by category for easier navigation and selection.
</p>
</div>
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
3
</span>
System Roles
</h3>
<p className="text-sm text-muted-foreground">
Roles marked as &quot;System Role&quot; are protected and cannot be deleted.
You can still modify their permissions if needed.
</p>
</div>
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
4
</span>
Assign to Users
</h3>
<p className="text-sm text-muted-foreground">
After creating roles, assign them to users in the Users Management page.
Users inherit all permissions from their assigned roles.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -78,6 +78,12 @@ export const navItems: NavItem[] = [
icon: 'user',
shortcut: ['u', 'u']
},
{
title: 'Roles',
url: '/dashboard/administration/roles',
icon: 'shield',
shortcut: ['r', 'r']
},
{
title: 'Tenants',
url: '/dashboard/administration/tenants',