Bläddra i källkod

feat(dashboard): Administrator detail view

Michael Bromley 10 månader sedan
förälder
incheckning
7719700b83

+ 1 - 0
packages/dashboard/package.json

@@ -43,6 +43,7 @@
     "@radix-ui/react-separator": "^1.1.2",
     "@radix-ui/react-slot": "^1.1.2",
     "@radix-ui/react-switch": "^1.1.3",
+    "@radix-ui/react-tabs": "^1.1.3",
     "@radix-ui/react-tooltip": "^1.1.8",
     "@tailwindcss/vite": "^4.0.7",
     "@tanstack/react-query": "^5.66.7",

+ 56 - 0
packages/dashboard/src/components/shared/role-selector.tsx

@@ -0,0 +1,56 @@
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { useQuery } from '@tanstack/react-query';
+import { MultiSelect } from './multi-select.js';
+import { useLingui } from '@lingui/react/macro';
+
+const rolesDocument = graphql(`
+    query Roles($options: RoleListOptions) {
+        roles(options: $options) {
+            items {
+                id
+                code
+                description
+            }
+        }
+    }
+`);
+
+export interface RoleSelectorProps<T extends boolean> {
+    value: T extends true ? string[] : string;
+    onChange: (value: T extends true ? string[] : string) => void;
+    multiple?: T;
+}
+
+export function RoleSelector<T extends boolean>(props: RoleSelectorProps<T>) {
+    const { value, onChange, multiple } = props;
+    const { i18n } = useLingui();
+
+    const { data } = useQuery({
+        queryKey: ['roles'],
+        queryFn: () =>
+            api.query(rolesDocument, {
+                options: {
+                    take: 100,
+                },
+            }),
+        select: data => data.roles.items,
+    });
+
+    const items = (data ?? []).map(role => ({
+        value: role.id,
+        label: role.code,
+        display: role.description ? role.description : role.code
+    }));
+
+    return (
+        <MultiSelect
+            value={value}
+            onChange={onChange}
+            multiple={multiple}
+            items={items}
+            placeholder={i18n.t('Select a role')}
+            searchPlaceholder={i18n.t('Search roles...')}
+        />
+    );
+}

+ 64 - 0
packages/dashboard/src/components/ui/tabs.tsx

@@ -0,0 +1,64 @@
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      className={cn("flex flex-col gap-2", className)}
+      {...props}
+    />
+  )
+}
+
+function TabsList({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.List>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      className={cn(
+        "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TabsTrigger({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+  return (
+    <TabsPrimitive.Trigger
+      data-slot="tabs-trigger"
+      className={cn(
+        "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TabsContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+  return (
+    <TabsPrimitive.Content
+      data-slot="tabs-content"
+      className={cn("flex-1 outline-none", className)}
+      {...props}
+    />
+  )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }

+ 54 - 0
packages/dashboard/src/hooks/use-grouped-permissions.ts

@@ -0,0 +1,54 @@
+import { useServerConfig } from '@/hooks/use-server-config.js';
+import { ServerConfig } from '@/providers/server-config.js';
+import { useMemo } from 'react';
+
+export function useGroupedPermissions() {
+    const serverConfig = useServerConfig();
+    const permissionDefinitions = serverConfig?.permissions ?? [];
+    const extractCrudDescription = (def: ServerConfig['permissions'][number]): string => {
+        return def.description.replace(/Grants permission to [\w]+/, 'Grants permissions on');
+    };
+
+    const groupedPermissions = useMemo(() => {
+        const crudGroups = new Map<string, ServerConfig['permissions']>();
+        const nonCrud: ServerConfig['permissions'] = [];
+        const crudRe = /^(Create|Read|Update|Delete)([a-zA-Z]+)$/;
+
+        for (const def of permissionDefinitions) {
+            const isCrud = crudRe.test(def.name);
+            if (isCrud) {
+                const groupName = def.name.match(crudRe)?.[2];
+                if (groupName) {
+                    const existing = crudGroups.get(groupName);
+                    if (existing) {
+                        existing.push(def);
+                    } else {
+                        crudGroups.set(groupName, [def]);
+                    }
+                }
+            } else if (def.assignable) {
+                nonCrud.push(def);
+            }
+        }
+
+        return [
+            ...nonCrud.map(d => ({
+                label: d.name,
+                description: d.description,
+                permissions: [d],
+            })),
+            ...Array.from(crudGroups.entries()).map(([label, defs]) => ({
+                label,
+                description: extractCrudDescription(defs[0]),
+                permissions: defs.sort((a, b) => a.name.localeCompare(b.name)),
+            })),
+        ]
+            .sort((a, b) => a.label.localeCompare(b.label))
+            .map(d => ({
+                ...d,
+                id: `section-${d.label.toLowerCase().replace(/ /g, '-')}`,
+            }));
+    }, [permissionDefinitions]);
+
+    return groupedPermissions;
+}

+ 33 - 1
packages/dashboard/src/routes/_authenticated/_administrators/administrators.graphql.ts

@@ -18,12 +18,16 @@ export const administratorItemFragment = graphql(`
                 updatedAt
                 code
                 description
+                channels {
+                    id
+                    code
+                }
             }
         }
     }
 `);
 
-export const administratorListQuery = graphql(
+export const administratorListDocument = graphql(
     `
         query AdministratorList {
             administrators {
@@ -36,3 +40,31 @@ export const administratorListQuery = graphql(
     `,
     [administratorItemFragment],
 );
+
+export const administratorDetailDocument = graphql(
+    `
+        query AdministratorDetail($id: ID!) {
+            administrator(id: $id) {
+                ...AdministratorItem
+                customFields
+            }
+        }
+    `,
+    [administratorItemFragment],
+);
+
+export const createAdministratorDocument = graphql(`
+    mutation CreateAdministrator($input: CreateAdministratorInput!) {
+        createAdministrator(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateAdministratorDocument = graphql(`
+    mutation UpdateAdministrator($input: UpdateAdministratorInput!) {
+        updateAdministrator(input: $input) {
+            id
+        }
+    }
+`);

+ 14 - 3
packages/dashboard/src/routes/_authenticated/_administrators/administrators.tsx

@@ -1,7 +1,7 @@
 import { ListPage } from '@/framework/page/list-page.js';
 import { Link, createFileRoute } from '@tanstack/react-router';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { administratorListQuery } from './administrators.graphql.js';
+import { administratorListDocument } from './administrators.graphql.js';
 import { Trans } from '@lingui/react/macro';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PlusIcon } from 'lucide-react';
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { Badge } from '@/components/ui/badge.js';
+import { RoleCodeLabel } from '@/components/shared/role-code-label.js';
 export const Route = createFileRoute('/_authenticated/_administrators/administrators')({
     component: AdministratorListPage,
     loader: () => ({ breadcrumb: () => <Trans>Administrators</Trans> }),
@@ -18,7 +19,7 @@ function AdministratorListPage() {
     return (
         <ListPage
             title="Administrators"
-            listQuery={addCustomFields(administratorListQuery)}
+            listQuery={addCustomFields(administratorListDocument)}
             route={Route}
             onSearchTermChange={searchTerm => {
                 return {
@@ -45,7 +46,7 @@ function AdministratorListPage() {
                                 {row.original.user.roles.map(role => {
                                     return (
                                         <Badge variant="secondary" key={role.id}>
-                                            {role.code}
+                                            <RoleCodeLabel code={role.code} />
                                         </Badge>
                                     );
                                 })}
@@ -54,9 +55,19 @@ function AdministratorListPage() {
                     },
                 },
             }}
+            customizeColumns={{
+                emailAddress: {
+                    id: 'Identifier',
+                    header: () => <Trans>Identifier</Trans>,
+                    cell: ({ row }) => {
+                        return <div>{row.original.emailAddress}</div>;
+                    },
+                },
+            }}
             defaultVisibility={{
                 emailAddress: true,
             }}
+            defaultColumnOrder={['name', 'emailAddress', 'roles']}
         >
             <PageActionBar>
                 <PermissionGuard requires={['CreateAdministrator']}>

+ 214 - 0
packages/dashboard/src/routes/_authenticated/_administrators/administrators_.$id.tsx

@@ -0,0 +1,214 @@
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { NEW_ENTITY_PATH } from '@/constants.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import {
+    CustomFieldsPageBlock,
+    Page,
+    PageActionBar,
+    PageBlock,
+    PageLayout,
+    PageTitle,
+} from '@/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { toast } from 'sonner';
+import {
+    administratorDetailDocument,
+    createAdministratorDocument,
+    updateAdministratorDocument,
+} from './administrators.graphql.js';
+import { RoleSelector } from '@/components/shared/role-selector.js';
+import { RolePermissionsDisplay } from './components/role-permissions-display.js';
+
+export const Route = createFileRoute('/_authenticated/_administrators/administrators_/$id')({
+    component: AdministratorDetailPage,
+    loader: async ({ context, params }) => {
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(administratorDetailDocument, { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.administrator) {
+            throw new Error(`Administrator with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/administrators', label: 'Administrators' },
+                isNew ? <Trans>New administrator</Trans> : result.administrator.firstName,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function AdministratorDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending } = useDetailPage({
+        queryDocument: addCustomFields(administratorDetailDocument),
+        entityField: 'administrator',
+        createDocument: createAdministratorDocument,
+        updateDocument: updateAdministratorDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                firstName: entity.firstName,
+                lastName: entity.lastName,
+                emailAddress: entity.emailAddress,
+                password: '',
+                customFields: entity.customFields,
+                roleIds: entity.user.roles.map(role => role.id),
+            };
+        },
+        transformUpdateInput: input => {
+            return {
+                ...input,
+                password: input.password || undefined,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(i18n.t('Successfully updated administrator'), {
+                position: 'top-right',
+            });
+            form.reset(form.getValues());
+            if (creatingNewEntity) {
+                await navigate({ to: `../${data?.id}`, from: Route.id });
+            }
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update administrator'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    const name = `${entity?.firstName} ${entity?.lastName}`;
+    const roleIds = form.watch('roleIds');
+
+    return (
+        <Page>
+            <PageTitle>{creatingNewEntity ? <Trans>New administrator</Trans> : name}</PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <div></div>
+                        <PermissionGuard requires={['UpdateAdministrator']}>
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                            >
+                                <Trans>Update</Trans>
+                            </Button>
+                        </PermissionGuard>
+                    </PageActionBar>
+                    <PageLayout>
+                        <PageBlock column="main">
+                            <div className="md:grid md:grid-cols-2 gap-4">
+                                <FormField
+                                    control={form.control}
+                                    name="firstName"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>First name</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="lastName"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Last name</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="emailAddress"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Email Address or identifier</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+
+                                <FormField
+                                    control={form.control}
+                                    name="password"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Password</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" type="password" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                        </PageBlock>
+                        <CustomFieldsPageBlock
+                            column="main"
+                            entityType="Administrator"
+                            control={form.control}
+                        />
+                        <PageBlock column="main" title={<Trans>Roles</Trans>}>
+                            <FormField
+                                control={form.control}
+                                name="roleIds"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormControl>
+                                            <RoleSelector
+                                                value={field.value ?? []}
+                                                onChange={field.onChange}
+                                                multiple={true}
+                                            />
+                                        </FormControl>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                            <RolePermissionsDisplay
+                                channels={entity?.user?.roles.flatMap(role => role.channels) ?? []}
+                                value={roleIds ?? []}
+                            />
+                        </PageBlock>
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}

+ 133 - 0
packages/dashboard/src/routes/_authenticated/_administrators/components/role-permissions-display.tsx

@@ -0,0 +1,133 @@
+import { ChannelCodeLabel } from '@/components/shared/channel-code-label.js';
+import { Checkbox } from '@/components/ui/checkbox.js';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.js';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip.js';
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { useGroupedPermissions } from '@/hooks/use-grouped-permissions.js';
+import { useLingui } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+
+const rolesByIdDocument = graphql(`
+    query RolesById($options: RoleListOptions) {
+        roles(options: $options) {
+            items {
+                id
+                code
+                permissions
+                channels {
+                    id
+                    code
+                }
+            }
+        }
+    }
+`);
+
+interface RolePermissionsDisplayProps {
+    value: string[];
+}
+
+export function RolePermissionsDisplay({ value = [] }: RolePermissionsDisplayProps) {
+    const { i18n } = useLingui();
+    const groupedPermissions = useGroupedPermissions();
+
+    const { data } = useQuery({
+        queryKey: ['rolesById', value],
+        queryFn: () =>
+            api.query(rolesByIdDocument, {
+                options: {
+                    filter: {
+                        id: { in: value },
+                    },
+                },
+            }),
+    });
+
+    const roles = data?.roles.items ?? [];
+
+    const allChannels = roles.flatMap(role => role.channels).filter((channel, index, self) =>
+        index === self.findIndex((t) => t.code === channel.code),
+    );
+
+    const isPermissionEnabled = (permissionName: string, channelCode: string) => {
+        // Check if any role has this permission for this channel
+        return roles.some(role => {
+            const hasPermission = role.permissions.includes(permissionName as any);
+            const isChannelSpecificRole = role.channels.length > 0;
+
+            if (!hasPermission) return false;
+
+            if (!isChannelSpecificRole) {
+                // If role is not channel-specific, permission applies to all channels
+                return true;
+            }
+
+            // Check if the role applies to this specific channel
+            return role.channels.some(channel => channel.code === channelCode);
+        });
+    };
+
+    if (!allChannels.length) return null;
+
+    return (
+        <Tabs defaultValue={allChannels[0].code} className="w-full mt-4">
+            <TabsList>
+                {allChannels.map(channel => (
+                    <TabsTrigger key={channel.code} value={channel.code}>
+                        <ChannelCodeLabel code={channel.code} />
+                    </TabsTrigger>
+                ))}
+            </TabsList>
+            {allChannels.map(channel => (
+                <TabsContent key={channel.code} value={channel.code} className="mt-0">
+                    <div className="rounded-md border">
+                        <table className="w-full">
+                            <tbody>
+                                {groupedPermissions.map((group, idx) => (
+                                    <tr
+                                        key={group.label}
+                                        className={idx !== groupedPermissions.length - 1 ? 'border-b' : undefined}
+                                    >
+                                        <td className="p-4">
+                                            <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+                                                {group.permissions.map(permission => (
+                                                    <div
+                                                        key={permission.name}
+                                                        className="flex items-center space-x-2"
+                                                    >
+                                                        <Checkbox
+                                                            checked={isPermissionEnabled(
+                                                                permission.name,
+                                                                channel.code,
+                                                            )}
+                                                            disabled={true}
+                                                        />
+                                                        <TooltipProvider>
+                                                            <Tooltip>
+                                                                <TooltipTrigger asChild>
+                                                                    <label
+                                                                        className="text-sm cursor-default"
+                                                                    >
+                                                                        {i18n.t(permission.name)}
+                                                                    </label>
+                                                                </TooltipTrigger>
+                                                                <TooltipContent>
+                                                                    <p>{i18n.t(permission.description)}</p>
+                                                                </TooltipContent>
+                                                            </Tooltip>
+                                                        </TooltipProvider>
+                                                    </div>
+                                                ))}
+                                            </div>
+                                        </td>
+                                    </tr>
+                                ))}
+                            </tbody>
+                        </table>
+                    </div>
+                </TabsContent>
+            ))}
+        </Tabs>
+    );
+}

+ 0 - 1
packages/dashboard/src/routes/_authenticated/_roles/components/expandable-permissions.tsx

@@ -11,7 +11,6 @@ import {
 import { ScrollArea } from '@/components/ui/scroll-area.js';
 import { ResultOf } from 'gql.tada';
 import { PlusIcon } from 'lucide-react';
-import { useState } from 'react';
 import { roleItemFragment } from '../roles.graphql.js';
 
 export function ExpandablePermissions({ role }: { role: ResultOf<typeof roleItemFragment> }) {

+ 10 - 63
packages/dashboard/src/routes/_authenticated/_roles/components/permissions-grid.tsx

@@ -1,23 +1,16 @@
-import { Button } from '@/components/ui/button.js';
-import { Switch } from '@/components/ui/switch.js';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip.js';
-import { useServerConfig } from '@/hooks/use-server-config.js';
-import { ServerConfig } from '@/providers/server-config.js';
-import { Trans, useLingui } from '@lingui/react/macro';
-import { useMemo, useState } from 'react';
 import {
     Accordion,
     AccordionContent,
     AccordionItem,
     AccordionTrigger,
 } from "@/components/ui/accordion.js";
-
-interface PermissionGridRow {
-    id: string;
-    label: string;
-    description: string;
-    permissions: ServerConfig['permissions'];
-}
+import { Button } from '@/components/ui/button.js';
+import { Switch } from '@/components/ui/switch.js';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip.js';
+import { useGroupedPermissions } from '@/hooks/use-grouped-permissions.js';
+import { ServerConfig } from '@/providers/server-config.js';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { useState } from 'react';
 
 interface PermissionsGridProps {
     value: string[];
@@ -27,9 +20,7 @@ interface PermissionsGridProps {
 
 export function PermissionsGrid({ value, onChange, readonly = false }: PermissionsGridProps) {
     const { i18n } = useLingui();
-    const serverConfig = useServerConfig();
-
-    const permissionDefinitions = serverConfig?.permissions ?? [];
+    const groupedPermissions = useGroupedPermissions();
 
     const setPermission = (permission: string, checked: boolean) => {
         if (readonly) return;
@@ -48,52 +39,8 @@ export function PermissionsGrid({ value, onChange, readonly = false }: Permissio
         onChange(newPermissions);
     };
 
-    const extractCrudDescription = (def: ServerConfig['permissions'][number]): string => {
-        return def.description.replace(/Grants permission to [\w]+/, 'Grants permissions on');
-    };
-
-    const gridData = useMemo(() => {
-        const crudGroups = new Map<string, ServerConfig['permissions']>();
-        const nonCrud: ServerConfig['permissions'] = [];
-        const crudRe = /^(Create|Read|Update|Delete)([a-zA-Z]+)$/;
-
-        for (const def of permissionDefinitions) {
-            const isCrud = crudRe.test(def.name);
-            if (isCrud) {
-                const groupName = def.name.match(crudRe)?.[2];
-                if (groupName) {
-                    const existing = crudGroups.get(groupName);
-                    if (existing) {
-                        existing.push(def);
-                    } else {
-                        crudGroups.set(groupName, [def]);
-                    }
-                }
-            } else if (def.assignable) {
-                nonCrud.push(def);
-            }
-        }
-
-        return [
-            ...nonCrud.map(d => ({
-                label: d.name,
-                description: d.description,
-                permissions: [d],
-            })),
-            ...Array.from(crudGroups.entries()).map(([label, defs]) => ({
-                label,
-                description: extractCrudDescription(defs[0]),
-                permissions: defs,
-            })),
-        ].map(d => ({
-            ...d,
-            id: `section-${d.label.toLowerCase().replace(/ /g, '-')}`,
-        }));
-    }, [permissionDefinitions]);
-
-
     // Get default expanded sections based on which ones have active permissions
-    const defaultExpandedSections = gridData
+    const defaultExpandedSections = groupedPermissions
         .map((section) => ({
             section,
             hasActivePermissions: section.permissions.some(permission => value.includes(permission.name)),
@@ -106,7 +53,7 @@ export function PermissionsGrid({ value, onChange, readonly = false }: Permissio
     return (
         <div className="w-full">
             <Accordion type="multiple" value={accordionValue.length ? accordionValue : defaultExpandedSections} onValueChange={setAccordionValue} className="space-y-4">
-                {gridData.map((section, index) => (
+                {groupedPermissions.map((section, index) => (
                     <AccordionItem
                         key={index}
                         value={section.id}