diff --git a/src/app/(modules)/roles/page.tsx b/src/app/(modules)/roles/page.tsx new file mode 100644 index 0000000..00d1f08 --- /dev/null +++ b/src/app/(modules)/roles/page.tsx @@ -0,0 +1,508 @@ +'use client'; + +import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Edit, Loader2, Plus, RotateCcw, ShieldCheck } from 'lucide-react'; +import { toast } from 'sonner'; +import type { ColumnDef } from '@tanstack/react-table'; + +import { DataTable } from '@/components/data-table'; +import { PageHeader } from '@/components/page-header'; +import { PoweredBy } from '@/components/powered-by'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { cn } from '@/lib/utils'; +import { permissionService, roleService } from '@/services/api'; +import { useAppStore } from '@/store/app.store'; +import type { PermissionTreeItem, PermissionTreeNode, Role } from '@/types'; + +type StatusFilter = 'all' | 'active' | 'inactive'; + +interface RoleFormState { + id?: number; + name: string; + display_name: string; + description: string; + permission_ids: number[]; +} + +const emptyRoleForm: RoleFormState = { + name: '', + display_name: '', + description: '', + permission_ids: [], +}; + +function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] { + return nodes.map((node) => ({ + id: node.id, + key: node.slug || node.name, + label: node.display_name || node.name, + description: node.description, + type: node.type, + isGrantedByDefault: Boolean(node.is_granted_by_default), + children: mapPermissionTree(node.children || []), + })); +} + +function collectNodeIds(node: PermissionTreeItem): number[] { + return [node.id, ...node.children.flatMap(collectNodeIds)]; +} + +function collectDefaultIds(nodes: PermissionTreeItem[]): number[] { + return nodes.flatMap((node) => [ + ...(node.isGrantedByDefault ? [node.id] : []), + ...collectDefaultIds(node.children), + ]); +} + +function collectIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] { + const selectedKeys = new Set(keys); + return nodes.flatMap((node) => [ + ...(selectedKeys.has(node.key) ? [node.id] : []), + ...collectIdsByKeys(node.children, keys), + ]); +} + +function PermissionCheckbox({ + checked, + indeterminate, + onChange, +}: { + checked: boolean; + indeterminate: boolean; + onChange: (checked: boolean) => void; +}) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.indeterminate = indeterminate; + } + }, [indeterminate]); + + return ( + onChange(event.target.checked)} + className="size-4 rounded border-border accent-primary" + /> + ); +} + +function PermissionTreeNodeRow({ + node, + selectedIds, + onToggle, +}: { + node: PermissionTreeItem; + selectedIds: Set; + onToggle: (node: PermissionTreeItem, checked: boolean) => void; +}) { + const nodeIds = collectNodeIds(node); + const checkedCount = nodeIds.filter((id) => selectedIds.has(id)).length; + const checked = checkedCount === nodeIds.length; + const indeterminate = checkedCount > 0 && !checked; + + return ( +
  • +
    + onToggle(node, isChecked)} + /> +
    +
    + {node.label} + + {node.type} + +
    + {node.description ? ( +

    {node.description}

    + ) : null} +
    +
    + {node.children.length > 0 ? ( +
      + {node.children.map((child) => ( + + ))} +
    + ) : null} +
  • + ); +} + +function PermissionTree({ + items, + selectedIds, + onChange, +}: { + items: PermissionTreeItem[]; + selectedIds: number[]; + onChange: (ids: number[]) => void; +}) { + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + const handleToggle = (node: PermissionTreeItem, checked: boolean) => { + const next = new Set(selectedSet); + collectNodeIds(node).forEach((id) => { + if (checked) { + next.add(id); + } else { + next.delete(id); + } + }); + onChange(Array.from(next)); + }; + + if (items.length === 0) { + return ( +
    + No permissions available. +
    + ); + } + + return ( + + ); +} + +export default function RolesPage() { + const queryClient = useQueryClient(); + const organizationId = useAppStore((state) => state.user?.organization_id ?? null); + const [skip, setSkip] = useState(0); + const [limit] = useState(10); + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [form, setForm] = useState(emptyRoleForm); + + const rolesQuery = useQuery({ + queryKey: ['roles', { skip, limit, searchTerm, statusFilter }], + queryFn: () => + roleService.getRoles({ + skip, + limit, + search_term: searchTerm || undefined, + effective_status: + statusFilter === 'all' ? undefined : statusFilter === 'active' ? true : false, + sort_by: 'created_at', + sort_order: 'desc', + }), + }); + + const permissionsQuery = useQuery({ + queryKey: ['permissions', 'tree'], + queryFn: permissionService.getPermissions, + }); + + const permissionTree = useMemo( + () => mapPermissionTree(permissionsQuery.data?.tree || []), + [permissionsQuery.data?.tree], + ); + + const saveMutation = useMutation({ + mutationFn: () => + roleService.saveRole({ + id: form.id, + name: form.name.trim(), + display_name: form.display_name.trim(), + description: form.description.trim(), + organization_id: organizationId, + permission_ids: form.permission_ids, + is_default: false, + }), + onSuccess: () => { + toast.success(form.id ? 'Role updated' : 'Role created'); + setIsSheetOpen(false); + setForm(emptyRoleForm); + queryClient.invalidateQueries({ queryKey: ['roles'] }); + }, + onError: () => toast.error(form.id ? 'Failed to update role' : 'Failed to create role'), + }); + + const statusMutation = useMutation({ + mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status), + onSuccess: () => { + toast.success('Role status updated'); + queryClient.invalidateQueries({ queryKey: ['roles'] }); + }, + onError: () => toast.error('Failed to update role status'), + }); + + const openCreate = () => { + setForm({ ...emptyRoleForm, permission_ids: collectDefaultIds(permissionTree) }); + setIsSheetOpen(true); + }; + + const openEdit = (role: Role) => { + setForm({ + id: role.id, + name: role.name || '', + display_name: role.display_name || '', + description: role.description || '', + permission_ids: collectIdsByKeys(permissionTree, role.permissions || []), + }); + setIsSheetOpen(true); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!form.name.trim() || !form.display_name.trim()) { + toast.error('Role name and display name are required'); + return; + } + if (form.permission_ids.length === 0) { + toast.error('Select at least one permission'); + return; + } + saveMutation.mutate(); + }; + + const total = rolesQuery.data?.total ?? 0; + const roles = rolesQuery.data?.items ?? []; + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: 'display_name', + header: 'Display Name', + }, + { + accessorKey: 'description', + header: 'Description', + cell: ({ row }) => ( + + {row.original.description || '-'} + + ), + }, + { + accessorKey: 'user_count', + header: 'Users', + cell: ({ row }) => row.original.user_count ?? 0, + }, + { + accessorKey: 'effective_status', + header: 'Status', + cell: ({ row }) => ( + + {row.original.effective_status ? 'Active' : 'Inactive'} + + ), + }, + { + id: 'actions', + header: () =>
    Actions
    , + cell: ({ row }) => { + const role = row.original; + return ( +
    + + +
    + ); + }, + }, + ]; + const toolbar = ( +
    + { + setSearchTerm(event.target.value); + setSkip(0); + }} + placeholder="Search roles" + className="md:max-w-sm" + /> + +
    + ); + + return ( + <> +
    + + + Add Role + + } + /> + + undefined, + }} + /> + + +
    + + + + + {form.id ? 'Edit Role' : 'Create Role'} + + Assign the role details and permission access for this organization. + + +
    +
    +
    + + setForm((prev) => ({ ...prev, name: event.target.value }))} + required + /> +
    +
    + + + setForm((prev) => ({ ...prev, display_name: event.target.value })) + } + required + /> +
    +
    + +
    + +