changes; Roles Module created
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Create Role" 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 "System Role" 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user