Procházet zdrojové kódy

feat(dashboard): Role detail view

Michael Bromley před 9 měsíci
rodič
revize
bbf88ff1f7

+ 1 - 0
packages/dashboard/package.json

@@ -29,6 +29,7 @@
     "@hookform/resolvers": "^4.1.3",
     "@lingui/core": "^5.2.0",
     "@lingui/react": "^5.2.0",
+    "@radix-ui/react-accordion": "^1.2.3",
     "@radix-ui/react-alert-dialog": "^1.1.6",
     "@radix-ui/react-avatar": "^1.1.3",
     "@radix-ui/react-checkbox": "^1.1.4",

+ 4 - 3
packages/dashboard/src/components/layout/channel-switcher.tsx

@@ -14,6 +14,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/c
 import { Trans } from '@lingui/react/macro';
 import { useChannel } from '@/hooks/use-channel.js';
 import { Link } from '@tanstack/react-router';
+import { ChannelCodeLabel } from '@/components/shared/channel-code-label.js';
 
 export function ChannelSwitcher() {
     const { isMobile } = useSidebar();
@@ -37,9 +38,9 @@ export function ChannelSwitcher() {
                                 </span>
                             </div>
                             <div className="grid flex-1 text-left text-sm leading-tight">
-                                <span className="truncate font-semibold">{displayChannel?.code}</span>
+                                <span className="truncate font-semibold"><ChannelCodeLabel code={displayChannel?.code} /></span>
                                 <span className="truncate text-xs">
-                                    Default Language: {displayChannel?.defaultLanguageCode}
+                                    Default Language: {displayChannel?.defaultLanguageCode?.toUpperCase()}
                                 </span>
                             </div>
                             <ChevronsUpDown className="ml-auto" />
@@ -65,7 +66,7 @@ export function ChannelSwitcher() {
                                         {channel.defaultCurrencyCode}
                                     </span>
                                 </div>
-                                {channel.code}
+                                <ChannelCodeLabel code={channel.code} />
                                 <DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
                             </DropdownMenuItem>
                         ))}

+ 0 - 1
packages/dashboard/src/routes/_authenticated/_channels/components/channel-code-label.tsx → packages/dashboard/src/components/shared/channel-code-label.tsx

@@ -2,7 +2,6 @@ import { DEFAULT_CHANNEL_CODE } from '@/constants.js';
 import { Trans } from '@lingui/react/macro';
 
 export function ChannelCodeLabel({ code }: { code: string } | { code: undefined }) {
-    console.log('code', code);
     return code === DEFAULT_CHANNEL_CODE ? <Trans>Default channel</Trans> : code;
 }
 

+ 51 - 0
packages/dashboard/src/components/shared/channel-selector.tsx

@@ -0,0 +1,51 @@
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { useLingui } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+import { MultiSelect } from './multi-select.js';
+import { ChannelCodeLabel } from './channel-code-label.js';
+
+const channelsDocument = graphql(`
+    query channels($options: ChannelListOptions) {
+        channels(options: $options) {
+            items {
+                id
+                code
+            }
+        }
+    }
+`);
+
+export interface ChannelSelectorProps<T extends boolean> {    
+    value: T extends true ? string[] : string;
+    onChange: (value: T extends true ? string[] : string) => void;
+    multiple?: T;
+}
+
+export function ChannelSelector<T extends boolean>(props: ChannelSelectorProps<T>) {
+    const { value, onChange, multiple } = props;
+    const { i18n } = useLingui();
+
+    const { data: channelsData } = useQuery({
+        queryKey: ['channels'],
+        queryFn: () => api.query(channelsDocument, {}),
+        staleTime: 1000 * 60 * 5,
+    });
+
+    const items = (channelsData?.channels.items ?? []).map(channel => ({
+        value: channel.id,
+        label: channel.code,
+        display: <ChannelCodeLabel code={channel.code} />
+    }));
+
+    return (
+        <MultiSelect
+            value={value}
+            onChange={onChange}
+            multiple={multiple}
+            items={items}
+            placeholder={i18n.t('Select a channel')}
+            searchPlaceholder={i18n.t('Search channels...')}
+        />
+    );
+}

+ 15 - 119
packages/dashboard/src/components/shared/currency-selector.tsx

@@ -1,14 +1,7 @@
-import { api } from '@/graphql/api.js';
-import { graphql } from '@/graphql/graphql.js';
-import { useQuery } from '@tanstack/react-query';
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
 import { useLocalFormat } from '@/hooks/use-local-format.js';
-import { Badge } from '@/components/ui/badge.js';
-import { X, ChevronDown } from 'lucide-react';
-import { cn } from '@/lib/utils.js';
-import { Button } from '@/components/ui/button.js';
 import { CurrencyCode } from '@/constants.js';
-import { useState } from 'react';
+import { MultiSelect } from './multi-select.js';
+import { useLingui } from '@lingui/react/macro';
 
 export interface CurrencySelectorProps<T extends boolean> {    
     value: T extends true ? string[] : string;
@@ -20,118 +13,21 @@ export interface CurrencySelectorProps<T extends boolean> {
 export function CurrencySelector<T extends boolean>(props: CurrencySelectorProps<T>) {
     const { formatCurrencyName } = useLocalFormat();
     const { value, onChange, multiple, availableCurrencyCodes } = props;
-    const [search, setSearch] = useState("");
+    const { i18n } = useLingui();
 
-    const filteredCurrencies = availableCurrencyCodes ?? Object.values(CurrencyCode).filter(currencyCode =>
-        formatCurrencyName(currencyCode).toLowerCase().includes(search.toLowerCase())
-    );
-
-    const handleSelect = (selectedValue: string) => {
-        if (multiple) {
-            const currentValue = value as string[];
-            const newValue = currentValue.includes(selectedValue)
-                ? currentValue.filter(v => v !== selectedValue)
-                : [...currentValue, selectedValue];
-            onChange(newValue as T extends true ? string[] : string);
-        } else {
-            onChange(selectedValue as T extends true ? string[] : string);
-        }
-    };
-
-    const handleRemove = (languageToRemove: string) => {
-        if (multiple) {
-            const currentValue = value as string[];
-            onChange(currentValue.filter(v => v !== languageToRemove) as T extends true ? string[] : string);
-        }
-    };
-
-    const renderTrigger = () => {
-        if (multiple) {
-            const selectedCurrencies = value as string[];
-            return (
-                <Button
-                    variant="outline"
-                    role="combobox"
-                    className={cn(
-                        "w-full justify-between",
-                        "min-h-[2.5rem] h-auto",
-                        "flex flex-wrap gap-1 p-1"
-                    )}
-                >
-                    <div className="flex flex-wrap gap-1">
-                        {selectedCurrencies.length > 0 ? (
-                            selectedCurrencies.map(currencyCode => (
-                                <Badge
-                                    key={currencyCode}
-                                    variant="secondary"
-                                    className="flex items-center gap-1"
-                                >
-                                    {formatCurrencyName(currencyCode)}
-                                    <button
-                                        onClick={(e) => {
-                                            e.stopPropagation();
-                                            handleRemove(currencyCode);
-                                        }}
-                                        className="ml-1 hover:text-destructive"
-                                    >
-                                        <X className="h-3 w-3" />
-                                    </button>
-                                </Badge>
-                            ))
-                        ) : (
-                            <span className="text-muted-foreground">Select currencies</span>
-                        )}
-                    </div>
-                    <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
-                </Button>
-            );
-        }
-        return (
-            <Button
-                variant="outline"
-                role="combobox"
-                className="w-full justify-between"
-            >
-                {value ? formatCurrencyName(value as string) : "Select a currency"}
-                <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
-            </Button>
-        );
-    };
+    const items = (availableCurrencyCodes ?? Object.values(CurrencyCode)).map(currencyCode => ({
+        value: currencyCode,
+        label: formatCurrencyName(currencyCode)
+    }));
 
     return (
-        <Popover>
-            <PopoverTrigger asChild>
-                {renderTrigger()}
-            </PopoverTrigger>
-            <PopoverContent 
-                className="w-[200px] p-0" 
-                side="bottom" 
-                align="start"
-            >
-                <div className="p-2">
-                    <input
-                        type="text"
-                        placeholder="Search currencies..."
-                        value={search}
-                        onChange={(e) => setSearch(e.target.value)}
-                        className="w-full px-2 py-1 text-sm border rounded"
-                    />
-                </div>
-                <div className="max-h-[300px] overflow-auto">
-                    {filteredCurrencies.map(currencyCode => (
-                        <button
-                            key={currencyCode}
-                            onClick={() => handleSelect(currencyCode)}
-                            className={cn(
-                                "w-full px-2 py-1.5 text-sm text-left hover:bg-accent",
-                                multiple && (value as string[]).includes(currencyCode) && "bg-accent"
-                            )}
-                        >
-                            {formatCurrencyName(currencyCode)}
-                        </button>
-                    ))}
-                </div>
-            </PopoverContent>
-        </Popover>
+        <MultiSelect
+            value={value}
+            onChange={onChange}
+            multiple={multiple}
+            items={items}
+            placeholder={i18n.t('Select a currency')}
+            searchPlaceholder={i18n.t('Search currencies...')}
+        />
     );
 }

+ 11 - 3
packages/dashboard/src/components/shared/detail-page-button.tsx

@@ -2,12 +2,20 @@ import { Link } from '@tanstack/react-router';
 import { Button } from '../ui/button.js';
 import { SquareArrowOutUpRightIcon } from 'lucide-react';
 
-export function DetailPageButton({ id, label }: { id: string; label: string | React.ReactNode }) {
+export function DetailPageButton({
+    id,
+    label,
+    disabled,
+}: {
+    id: string;
+    label: string | React.ReactNode;
+    disabled?: boolean;
+}) {
     return (
-        <Button asChild variant="ghost">
+        <Button asChild variant="ghost" disabled={disabled}>
             <Link to={`./${id}`}>
                 {label}
-                <SquareArrowOutUpRightIcon className="h-3 w-3 text-muted-foreground" />
+                {!disabled && <SquareArrowOutUpRightIcon className="h-3 w-3 text-muted-foreground" />}
             </Link>
         </Button>
     );

+ 15 - 87
packages/dashboard/src/components/shared/language-selector.tsx

@@ -1,12 +1,9 @@
 import { api } from '@/graphql/api.js';
 import { graphql } from '@/graphql/graphql.js';
 import { useQuery } from '@tanstack/react-query';
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
 import { useLocalFormat } from '@/hooks/use-local-format.js';
-import { Badge } from '@/components/ui/badge.js';
-import { X, ChevronDown } from 'lucide-react';
-import { cn } from '@/lib/utils.js';
-import { Button } from '@/components/ui/button.js';
+import { MultiSelect } from './multi-select.js';
+import { useLingui } from '@lingui/react/macro';
 
 const availableGlobalLanguages = graphql(`
     query AvailableGlobalLanguages {
@@ -31,90 +28,21 @@ export function LanguageSelector<T extends boolean>(props: LanguageSelectorProps
     });
     const { formatLanguageName } = useLocalFormat();
     const { value, onChange, multiple, availableLanguageCodes } = props;
+    const { i18n } = useLingui();
 
-    const handleSelect = (selectedValue: string) => {
-        if (multiple) {
-            const currentValue = value as string[];
-            const newValue = currentValue.includes(selectedValue)
-                ? currentValue.filter(v => v !== selectedValue)
-                : [...currentValue, selectedValue];
-            onChange(newValue as T extends true ? string[] : string);
-        } else {
-            onChange(selectedValue as T extends true ? string[] : string);
-        }
-    };
-
-    const handleRemove = (languageToRemove: string) => {
-        if (multiple) {
-            const currentValue = value as string[];
-            onChange(currentValue.filter(v => v !== languageToRemove) as T extends true ? string[] : string);
-        }
-    };
-
-    const renderTrigger = () => {
-        if (multiple) {
-            const selectedLanguages = value as string[];
-            return (
-                <Button
-                    variant="outline"
-                    role="combobox"
-                    className={cn(
-                        'w-full justify-between',
-                        'min-h-[2.5rem] h-auto',
-                        'flex flex-wrap gap-1 p-1',
-                    )}
-                >
-                    <div className="flex flex-wrap gap-1">
-                        {selectedLanguages.length > 0 ? (
-                            selectedLanguages.map(language => (
-                                <Badge key={language} variant="secondary" className="flex items-center gap-1">
-                                    {formatLanguageName(language)}
-                                    <button
-                                        onClick={e => {
-                                            e.stopPropagation();
-                                            handleRemove(language);
-                                        }}
-                                        className="ml-1 hover:text-destructive"
-                                    >
-                                        <X className="h-3 w-3" />
-                                    </button>
-                                </Badge>
-                            ))
-                        ) : (
-                            <span className="text-muted-foreground">Select languages</span>
-                        )}
-                    </div>
-                    <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
-                </Button>
-            );
-        }
-        return (
-            <Button variant="outline" role="combobox" className="w-full justify-between">
-                {value ? formatLanguageName(value as string) : 'Select a language'}
-                <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
-            </Button>
-        );
-    };
+    const items = (availableLanguageCodes ?? data?.globalSettings.availableLanguages ?? []).map(language => ({
+        value: language,
+        label: formatLanguageName(language)
+    }));
 
     return (
-        <Popover>
-            <PopoverTrigger asChild>{renderTrigger()}</PopoverTrigger>
-            <PopoverContent className="w-[200px] p-0">
-                <div className="max-h-[300px] overflow-auto">
-                    {(availableLanguageCodes ?? data?.globalSettings.availableLanguages)?.map(language => (
-                        <button
-                            key={language}
-                            onClick={() => handleSelect(language)}
-                            className={cn(
-                                'w-full px-2 py-1.5 text-sm text-left hover:bg-accent',
-                                multiple && (value as string[]).includes(language) && 'bg-accent',
-                            )}
-                        >
-                            {formatLanguageName(language)}
-                        </button>
-                    ))}
-                </div>
-            </PopoverContent>
-        </Popover>
+        <MultiSelect
+            value={value}
+            onChange={onChange}
+            multiple={multiple}
+            items={items}
+            placeholder={i18n.t('Select a language')}
+            searchPlaceholder={i18n.t('Search languages...')}
+        />
     );
 }

+ 159 - 0
packages/dashboard/src/components/shared/multi-select.tsx

@@ -0,0 +1,159 @@
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { Badge } from '@/components/ui/badge.js';
+import { X, ChevronDown } from 'lucide-react';
+import { cn } from '@/lib/utils.js';
+import { Button } from '@/components/ui/button.js';
+import { useState } from 'react';
+import { Input } from '../ui/input.js';
+
+export interface MultiSelectProps<T extends boolean> {
+    value: T extends true ? string[] : string;
+    onChange: (value: T extends true ? string[] : string) => void;
+    multiple?: T;
+    items: Array<{
+        value: string;
+        label: string;
+        /**
+         * The display value to use for the item.
+         * If not provided, the label will be used.
+         * This is useful for displaying a more complex value in
+         * a React component.
+         */
+        display?: string | React.ReactNode;
+    }>;
+    placeholder?: string;
+    searchPlaceholder?: string;
+    showSearch?: boolean;
+    className?: string;
+}
+
+export function MultiSelect<T extends boolean>(props: MultiSelectProps<T>) {
+    const {
+        value,
+        onChange,
+        multiple,
+        items,
+        placeholder = 'Select items',
+        searchPlaceholder = 'Search...',
+        showSearch,
+        className,
+    } = props;
+    const [search, setSearch] = useState('');
+
+    const filteredItems = items.filter(item => item.label.toLowerCase().includes(search.toLowerCase()));
+
+    const handleSelect = (selectedValue: string) => {
+        if (multiple) {
+            const currentValue = value as string[];
+            const newValue = currentValue.includes(selectedValue)
+                ? currentValue.filter(v => v !== selectedValue)
+                : [...currentValue, selectedValue];
+            onChange(newValue as T extends true ? string[] : string);
+        } else {
+            onChange(selectedValue as T extends true ? string[] : string);
+        }
+    };
+
+    const handleRemove = (valueToRemove: string) => {
+        if (multiple) {
+            const currentValue = value as string[];
+            onChange(currentValue.filter(v => v !== valueToRemove) as T extends true ? string[] : string);
+        }
+    };
+
+    const renderTrigger = () => {
+        if (multiple) {
+            const selectedValues = value as string[];
+            return (
+                <Button
+                    variant="outline"
+                    role="combobox"
+                    className={cn(
+                        'w-full justify-between',
+                        'min-h-[2.5rem] h-auto',
+                        'flex flex-wrap gap-1 p-1',
+                        className,
+                    )}
+                >
+                    <div className="flex flex-wrap gap-1">
+                        {selectedValues.length > 0 ? (
+                            selectedValues.map(selectedValue => {
+                                const item = items.find(i => i.value === selectedValue);
+                                return (
+                                    <Badge
+                                        key={selectedValue}
+                                        variant="secondary"
+                                        className="flex items-center gap-1"
+                                    >
+                                        {item?.display ?? item?.label ?? selectedValue}
+                                        <div
+                                            onClick={e => {
+                                                e.stopPropagation();
+                                                handleRemove(selectedValue);
+                                            }}
+                                            onKeyDown={e => {
+                                                if (e.key === 'Enter' || e.key === ' ') {
+                                                    e.preventDefault();
+                                                    handleRemove(selectedValue);
+                                                }
+                                            }}
+                                            role="button"
+                                            tabIndex={0}
+                                            className="ml-1 hover:text-destructive cursor-pointer"
+                                            aria-label={`Remove ${item?.label ?? selectedValue}`}
+                                        >
+                                            <X className="h-3 w-3" />
+                                        </div>
+                                    </Badge>
+                                );
+                            })
+                        ) : (
+                            <span className="text-muted-foreground">{placeholder}</span>
+                        )}
+                    </div>
+                    <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
+                </Button>
+            );
+        }
+        const selectedItem = items.find(i => i.value === value);
+        return (
+            <Button variant="outline" role="combobox" className={cn('w-full justify-between', className)}>
+                {selectedItem ? (selectedItem.display ?? selectedItem.label) : placeholder}
+                <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
+            </Button>
+        );
+    };
+
+    return (
+        <Popover>
+            <PopoverTrigger asChild>{renderTrigger()}</PopoverTrigger>
+            <PopoverContent className="w-[200px] p-0" side="bottom" align="start">
+                {(showSearch === true || items.length > 10) && (
+                    <div className="p-2">
+                        <Input
+                            type="text"
+                            placeholder={searchPlaceholder}
+                            value={search}
+                            onChange={e => setSearch(e.target.value)}
+                            className="w-full px-2 py-1 text-sm border rounded"
+                        />
+                    </div>
+                )}
+                <div className="max-h-[300px] overflow-auto">
+                    {filteredItems.map(item => (
+                        <button
+                            key={item.value}
+                            onClick={() => handleSelect(item.value)}
+                            className={cn(
+                                'w-full px-2 py-1.5 text-sm text-left hover:bg-accent',
+                                multiple && (value as string[]).includes(item.value) && 'bg-accent',
+                            )}
+                        >
+                            {item.display ?? item.label}
+                        </button>
+                    ))}
+                </div>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 8 - 0
packages/dashboard/src/components/shared/role-code-label.tsx

@@ -0,0 +1,8 @@
+import { CUSTOMER_ROLE_CODE, SUPER_ADMIN_ROLE_CODE } from '@/constants.js';
+import { Trans } from '@lingui/react/macro';
+
+export function RoleCodeLabel({ code }: { code: string } | { code: undefined }) {
+    return code === SUPER_ADMIN_ROLE_CODE ? <Trans>Super Admin</Trans> : 
+    code === CUSTOMER_ROLE_CODE ? <Trans>Customer</Trans> : code;
+}
+

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

@@ -0,0 +1,64 @@
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Accordion({
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
+  return <AccordionPrimitive.Root data-slot="accordion" {...props} />
+}
+
+function AccordionItem({
+  className,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
+  return (
+    <AccordionPrimitive.Item
+      data-slot="accordion-item"
+      className={cn("border-b last:border-b-0", className)}
+      {...props}
+    />
+  )
+}
+
+function AccordionTrigger({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
+  return (
+    <AccordionPrimitive.Header className="flex">
+      <AccordionPrimitive.Trigger
+        data-slot="accordion-trigger"
+        className={cn(
+          "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
+          className
+        )}
+        {...props}
+      >
+        {children}
+        <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
+      </AccordionPrimitive.Trigger>
+    </AccordionPrimitive.Header>
+  )
+}
+
+function AccordionContent({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
+  return (
+    <AccordionPrimitive.Content
+      data-slot="accordion-content"
+      className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
+      {...props}
+    >
+      <div className={cn("pt-0 pb-4", className)}>{children}</div>
+    </AccordionPrimitive.Content>
+  )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

+ 2 - 0
packages/dashboard/src/constants.ts

@@ -1,5 +1,7 @@
 export const NEW_ENTITY_PATH = 'new';
 export const DEFAULT_CHANNEL_CODE = '__default_channel__';
+export const SUPER_ADMIN_ROLE_CODE = '__super_admin_role__';
+export const CUSTOMER_ROLE_CODE = '__customer_role__';
 /**
  * This is copied from the generated types from @vendure/common/lib/generated-types.d.ts
  * It is used to provide a list of available currency codes for the user to select from.

+ 1 - 1
packages/dashboard/src/framework/layout-engine/page-layout.tsx

@@ -8,7 +8,7 @@ import { Control } from 'react-hook-form';
 export type PageBlockProps = {
     children: React.ReactNode;
     /** Which column this block should appear in */
-    column: 'main' | 'side';
+    column: 'main' | 'side' ;
     title?: React.ReactNode | string;
     description?: React.ReactNode | string;
     className?: string;

+ 1 - 1
packages/dashboard/src/routes/_authenticated/_channels/channels.tsx

@@ -8,7 +8,7 @@ import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { PlusIcon } from 'lucide-react';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { Trans } from '@lingui/react/macro';
-import { ChannelCodeLabel } from './components/channel-code-label.js';
+import { ChannelCodeLabel } from '../../../components/shared/channel-code-label.js';
 
 export const Route = createFileRoute('/_authenticated/_channels/channels')({
     component: ChannelListPage,

+ 1 - 1
packages/dashboard/src/routes/_authenticated/_channels/channels_.$id.tsx

@@ -33,7 +33,7 @@ import { CurrencySelector } from '@/components/shared/currency-selector.js';
 import { ZoneSelector } from '@/components/shared/zone-selector.js';
 import { Badge } from '@/components/ui/badge.js';
 import { DEFAULT_CHANNEL_CODE } from '@/constants.js';
-import { ChannelCodeLabel } from './components/channel-code-label.js';
+import { ChannelCodeLabel } from '../../../components/shared/channel-code-label.js';
 
 export const Route = createFileRoute('/_authenticated/_channels/channels_/$id')({
     component: ChannelDetailPage,

+ 169 - 0
packages/dashboard/src/routes/_authenticated/_roles/components/permissions-grid.tsx

@@ -0,0 +1,169 @@
+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'];
+}
+
+interface PermissionsGridProps {
+    value: string[];
+    onChange: (permissions: string[]) => void;
+    readonly?: boolean;
+}
+
+export function PermissionsGrid({ value, onChange, readonly = false }: PermissionsGridProps) {
+    const { i18n } = useLingui();
+    const serverConfig = useServerConfig();
+
+    const permissionDefinitions = serverConfig?.permissions ?? [];
+
+    const setPermission = (permission: string, checked: boolean) => {
+        if (readonly) return;
+
+        const newPermissions = checked ? [...value, permission] : value.filter(p => p !== permission);
+        onChange(newPermissions);
+    };
+
+    const toggleAll = (defs: ServerConfig['permissions']) => {
+        if (readonly) return;
+
+        const shouldEnable = defs.some(d => !value.includes(d.name));
+        const newPermissions = shouldEnable
+            ? [...new Set([...value, ...defs.map(d => d.name)])]
+            : value.filter(p => !defs.some(d => d.name === p));
+        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
+        .map((section) => ({
+            section,
+            hasActivePermissions: section.permissions.some(permission => value.includes(permission.name)),
+        }))
+        .filter(({ hasActivePermissions }) => hasActivePermissions)
+        .map(({ section }) => section.id);
+    
+    const [accordionValue, setAccordionValue] = useState<string[]>(defaultExpandedSections);
+
+    return (
+        <div className="w-full">
+            <Accordion type="multiple" value={accordionValue.length ? accordionValue : defaultExpandedSections} onValueChange={setAccordionValue} className="space-y-4">
+                {gridData.map((section, index) => (
+                    <AccordionItem
+                        key={index}
+                        value={section.id}
+                        className="border rounded-lg px-6"
+                    >
+                        <AccordionTrigger className="hover:no-underline">
+                            <div className="flex flex-col items-start gap-1 text-sm py-2">
+                                <div>{i18n.t(section.label)}</div>
+                                <div className="text-muted-foreground text-sm font-normal">
+                                    {i18n.t(section.description)}
+                                </div>
+                            </div>
+                        </AccordionTrigger>
+                        <AccordionContent>
+                            <div className="pb-4 space-y-4">
+                                {section.permissions.length > 1 && !readonly && (
+                                    <Button
+                                        variant="outline"
+                                        type="button"
+                                        size="sm"
+                                        onClick={() => toggleAll(section.permissions)}
+                                        className="w-fit"
+                                    >
+                                        <Trans>Toggle all</Trans>
+                                    </Button>
+                                )}
+                                <div className="md:grid md:grid-cols-4 md:gap-2 space-y-2">
+                                    {section.permissions.map(permission => (
+                                        <div key={permission.name} className="flex items-center space-x-2">
+                                            <Switch
+                                                id={permission.name}
+                                                checked={value.includes(permission.name)}
+                                                onCheckedChange={checked =>
+                                                    setPermission(permission.name, checked)
+                                                }
+                                                disabled={readonly}
+                                            />
+                                            <TooltipProvider>
+                                                <Tooltip>
+                                                    <TooltipTrigger asChild>
+                                                        <label htmlFor={permission.name} className="text-sm whitespace-nowrap">
+                                                            {i18n.t(permission.name)}
+                                                        </label>
+                                                    </TooltipTrigger>
+                                                    <TooltipContent align="end">
+                                                        <p>{i18n.t(permission.description)}</p>
+                                                    </TooltipContent>
+                                                </Tooltip>
+                                            </TooltipProvider>
+                                        </div>
+                                    ))}
+                                </div>
+                            </div>
+                        </AccordionContent>
+                    </AccordionItem>
+                ))}
+            </Accordion>
+        </div>
+    );
+}

+ 38 - 2
packages/dashboard/src/routes/_authenticated/_roles/roles.graphql.ts

@@ -18,8 +18,8 @@ export const roleItemFragment = graphql(`
 
 export const roleListQuery = graphql(
     `
-        query RoleList {
-            roles {
+        query RoleList($options: RoleListOptions) {
+            roles(options: $options) {
                 items {
                     ...RoleItem
                 }
@@ -29,3 +29,39 @@ export const roleListQuery = graphql(
     `,
     [roleItemFragment],
 );
+
+export const roleDetailDocument = graphql(
+    `
+        query RoleDetail($id: ID!) {
+            role(id: $id) {
+                ...RoleItem
+            }
+        }
+    `,
+    [roleItemFragment],
+);
+
+export const createRoleDocument = graphql(`
+    mutation RoleCreate($input: CreateRoleInput!) {
+        createRole(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateRoleDocument = graphql(`
+    mutation UpdateRole($input: UpdateRoleInput!) {
+        updateRole(input: $input) {
+            id
+        }
+    }
+`);
+
+export const deleteRoleDocument = graphql(`
+    mutation DeleteRole($id: ID!) {
+        deleteRole(id: $id) {
+            result
+            message
+        }
+    }
+`);

+ 24 - 11
packages/dashboard/src/routes/_authenticated/_roles/roles.tsx

@@ -1,21 +1,24 @@
-import { createFileRoute } from '@tanstack/react-router';
-import { Trans } from '@lingui/react/macro';
+import { DetailPageButton } from '@/components/shared/detail-page-button.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { RoleCodeLabel } from '@/components/shared/role-code-label.js';
+import { Badge } from '@/components/ui/badge.js';
+import { Button } from '@/components/ui/button.js';
+import { CUSTOMER_ROLE_CODE, SUPER_ADMIN_ROLE_CODE } from '@/constants.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { roleListQuery } from './roles.graphql.js';
+import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
-import { ExpandablePermissions } from './components/expandable-permissions.js';
-import { Badge } from '@/components/ui/badge.js';
+import { Trans } from '@lingui/react/macro';
+import { createFileRoute, Link } from '@tanstack/react-router';
 import { LayersIcon, PlusIcon } from 'lucide-react';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { Button } from '@/components/ui/button.js';
-import { Link } from '@tanstack/react-router';
+import { ChannelCodeLabel } from '../../../components/shared/channel-code-label.js';
+import { ExpandablePermissions } from './components/expandable-permissions.js';
+import { roleListQuery } from './roles.graphql.js';
 export const Route = createFileRoute('/_authenticated/_roles/roles')({
     component: RoleListPage,
     loader: () => ({ breadcrumb: () => <Trans>Roles</Trans> }),
 });
 
-const SYSTEM_ROLES = ['__super_admin_role__', '__customer_role__'];
+const SYSTEM_ROLES = [SUPER_ADMIN_ROLE_CODE, CUSTOMER_ROLE_CODE];
 
 function RoleListPage() {
     return (
@@ -30,6 +33,16 @@ function RoleListPage() {
                 permissions: true,
             }}
             customizeColumns={{
+                code: {
+                    header: 'Code',
+                    cell: ({ row }) => {
+                        return <DetailPageButton
+                                id={row.original.id}
+                                label={<RoleCodeLabel code={row.original.code} />}
+                                disabled={SYSTEM_ROLES.includes(row.original.code)}
+                            />  
+                    },
+                },
                 permissions: {
                     header: 'Permissions',
                     cell: ({ row }) => {
@@ -55,7 +68,7 @@ function RoleListPage() {
                             <div className="flex flex-wrap gap-2">
                                 {row.original.channels.map(channel => (
                                     <Badge variant="secondary" key={channel.code}>
-                                        <LayersIcon /> {channel.code}
+                                        <LayersIcon /> <ChannelCodeLabel code={channel.code} />
                                     </Badge>
                                 ))}
                             </div>

+ 197 - 0
packages/dashboard/src/routes/_authenticated/_roles/roles_.$id.tsx

@@ -0,0 +1,197 @@
+import { ChannelSelector } from '@/components/shared/channel-selector.js';
+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,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { NEW_ENTITY_PATH } from '@/constants.js';
+import {
+    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 { PermissionsGrid } from './components/permissions-grid.js';
+import { createRoleDocument, roleDetailDocument, updateRoleDocument } from './roles.graphql.js';
+
+export const Route = createFileRoute('/_authenticated/_roles/roles_/$id')({
+    component: RoleDetailPage,
+    loader: async ({ context, params }) => {
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(roleDetailDocument, { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.role) {
+            throw new Error(`Role with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/roles', label: 'Roles' },
+                isNew ? <Trans>New role</Trans> : result.role.description,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function RoleDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending } = useDetailPage({
+        queryDocument: roleDetailDocument,
+        entityField: 'role',
+        createDocument: createRoleDocument,
+        updateDocument: updateRoleDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                code: entity.code,
+                description: entity.description,
+                permissions: entity.permissions,
+                channelIds: entity.channels.map(channel => channel.id),
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(i18n.t('Successfully updated role'), {
+                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 role'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page>
+            <PageTitle>
+                {creatingNewEntity ? <Trans>New role</Trans> : (entity?.description ?? '')}
+            </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="description"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Description</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="code"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Code</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                        </PageBlock>
+                        <PageBlock column="main">
+                            <div className="space-y-8">
+                                <div className="md:grid md:grid-cols-2 gap-4">
+                                    <FormField
+                                        control={form.control}
+                                        name="channelIds"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>Channels</Trans>
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <ChannelSelector
+                                                        multiple={true}
+                                                        value={field.value ?? []}
+                                                        onChange={value => field.onChange(value)}
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                                <FormDescription>
+                                                    <Trans>
+                                                        The selected permissions will be applied to the these
+                                                        channels.
+                                                    </Trans>
+                                                </FormDescription>
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                                <FormField
+                                    control={form.control}
+                                    name="permissions"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Permissions</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <PermissionsGrid
+                                                    value={field.value ?? []}
+                                                    onChange={value => field.onChange(value)}
+                                                />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                        </PageBlock>
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}

+ 1 - 1
packages/dashboard/src/styles.css

@@ -1,7 +1,7 @@
+@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap');
 @import 'tailwindcss';
 @import 'tw-animate-css';
 
-@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap');
 
 @custom-variant dark (&:is(.dark *));