@svadmin/ui 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ <script lang="ts">
2
+ import { t } from '@svadmin/core/i18n';
3
+ import { getAuthProvider, getResources } from '@svadmin/core';
4
+ import type { Role } from '@svadmin/core';
5
+ import PermissionMatrix, { type RoleInfo, type ResourceInfo, type ActionInfo } from './PermissionMatrix.svelte';
6
+ import { toast } from '@svadmin/core/toast';
7
+
8
+ const authProvider = getAuthProvider();
9
+ const rawResources = getResources();
10
+
11
+ let roles = $state<RoleInfo[]>([]);
12
+ let selectedRoleCode = $state<string>('');
13
+
14
+ // Matrix format: { "roleId": { "resourceName": ["action1", "action2"] } }
15
+ let permissionsCache = $state<Record<string, Record<string, string[]>>>({});
16
+
17
+ let loadingRoles = $state(true);
18
+ let loadingPermissions = $state(false);
19
+ let saving = $state(false);
20
+ let error = $state<string | null>(null);
21
+
22
+ // Convert system resources to MatrixResource format
23
+ let matrixResources = $derived<ResourceInfo[]>(
24
+ rawResources.map(r => ({
25
+ code: r.name,
26
+ name: r.label || r.name,
27
+ }))
28
+ );
29
+
30
+ let matrixActions = $state<ActionInfo[]>([
31
+ { code: 'create', name: t('common.create') ?? 'Create' },
32
+ { code: 'read', name: t('common.detail') ?? 'Read' },
33
+ { code: 'update', name: t('common.edit') ?? 'Update' },
34
+ { code: 'delete', name: t('common.delete') ?? 'Delete' },
35
+ ]);
36
+
37
+ async function loadRoles() {
38
+ if (!authProvider?.getRoles) {
39
+ error = t('settings.rbacNotSupported') ?? 'RBAC not supported by AuthProvider';
40
+ loadingRoles = false;
41
+ return;
42
+ }
43
+ try {
44
+ const fetchedRoles = await authProvider.getRoles();
45
+ roles = fetchedRoles.map(r => ({ code: r.id, name: r.name, ...r }));
46
+ if (roles.length > 0 && !selectedRoleCode) {
47
+ selectedRoleCode = roles[0].code;
48
+ }
49
+ } catch (e) {
50
+ error = (e as Error).message;
51
+ } finally {
52
+ loadingRoles = false;
53
+ }
54
+ }
55
+
56
+ async function loadPermissionsForRole(roleId: string) {
57
+ if (!authProvider?.getRolePermissions || permissionsCache[roleId]) return;
58
+
59
+ loadingPermissions = true;
60
+ try {
61
+ const perms = await authProvider.getRolePermissions(roleId);
62
+ permissionsCache[roleId] = perms;
63
+ } catch (e) {
64
+ toast.error((e as Error).message);
65
+ } finally {
66
+ loadingPermissions = false;
67
+ }
68
+ }
69
+
70
+ // Load permissions whenever the selected role changes
71
+ $effect(() => {
72
+ if (selectedRoleCode && !permissionsCache[selectedRoleCode] && !loadingPermissions) {
73
+ loadPermissionsForRole(selectedRoleCode);
74
+ }
75
+ });
76
+
77
+ $effect(() => {
78
+ loadRoles();
79
+ });
80
+
81
+ // isGranted logic
82
+ function checkGrant(roleCode: string, resourceCode: string, actionCode: string) {
83
+ const rolePerms = permissionsCache[roleCode];
84
+ if (!rolePerms) return false;
85
+ return rolePerms[resourceCode]?.includes(actionCode) ?? false;
86
+ }
87
+
88
+ async function handleToggle(roleCode: string, resourceCode: string, actionCode: string, grant: boolean) {
89
+ if (!authProvider?.updateRolePermissions) return;
90
+
91
+ // Copy state
92
+ const currentRolePerms = { ...(permissionsCache[roleCode] || {}) };
93
+ const currentResPerms = [...(currentRolePerms[resourceCode] || [])];
94
+
95
+ // Optimistic update
96
+ if (grant) {
97
+ if (!currentResPerms.includes(actionCode)) currentResPerms.push(actionCode);
98
+ } else {
99
+ const idx = currentResPerms.indexOf(actionCode);
100
+ if (idx > -1) currentResPerms.splice(idx, 1);
101
+ }
102
+
103
+ currentRolePerms[resourceCode] = currentResPerms;
104
+ permissionsCache[roleCode] = currentRolePerms;
105
+
106
+ saving = true;
107
+ try {
108
+ // Send the entire role's permissions back (as defined by our AuthProvider interface)
109
+ const result = await authProvider.updateRolePermissions(roleCode, currentRolePerms);
110
+ if (result.success) {
111
+ // Success silently
112
+ } else {
113
+ throw new Error(result.error?.message ?? t('common.operationFailed'));
114
+ }
115
+ } catch (e) {
116
+ toast.error((e as Error).message);
117
+ // Rollback on fail
118
+ delete permissionsCache[roleCode];
119
+ loadPermissionsForRole(roleCode);
120
+ } finally {
121
+ saving = false;
122
+ }
123
+ }
124
+ </script>
125
+
126
+ {#if error}
127
+ <div class="h-full flex items-center justify-center">
128
+ <div class="max-w-md p-6 border border-destructive/20 bg-destructive/10 text-destructive rounded-lg flex flex-col items-center gap-3 text-center">
129
+ <AlertCircle class="h-8 w-8" />
130
+ <h3 class="font-bold text-lg">{t('common.error') ?? 'Error'}</h3>
131
+ <p>{error}</p>
132
+ </div>
133
+ </div>
134
+ {:else}
135
+ <PermissionMatrix
136
+ {roles}
137
+ resources={matrixResources}
138
+ actions={matrixActions}
139
+ isGranted={checkGrant}
140
+ bind:selectedRole={selectedRoleCode}
141
+ loading={loadingRoles || loadingPermissions || saving}
142
+ onToggle={handleToggle}
143
+ />
144
+ {/if}
@@ -2,10 +2,12 @@
2
2
  import { t } from '@svadmin/core/i18n';
3
3
  import { navigate } from '@svadmin/core/router';
4
4
  import { currentPath } from '@svadmin/core/router';
5
- import { User, Palette, Info } from 'lucide-svelte';
5
+ import { User, Palette, Info, Shield, FileSearch } from 'lucide-svelte';
6
6
  import ProfilePage from './ProfilePage.svelte';
7
7
  import AppearanceSettings from './AppearanceSettings.svelte';
8
8
  import AboutSettings from './AboutSettings.svelte';
9
+ import RolesSettings from './RolesSettings.svelte';
10
+ import AuditLogViewer from './AuditLogViewer.svelte';
9
11
 
10
12
  const sections = [
11
13
  {
@@ -18,6 +20,8 @@
18
20
  {
19
21
  group: 'settings.system',
20
22
  items: [
23
+ { key: 'roles', path: '/settings/roles', icon: Shield, label: 'settings.rolesAndPermissions' },
24
+ { key: 'audit', path: '/settings/audit', icon: FileSearch, label: 'settings.auditLogs' },
21
25
  { key: 'about', path: '/settings/about', icon: Info, label: 'settings.about' },
22
26
  ],
23
27
  },
@@ -26,6 +30,8 @@
26
30
  let activeKey = $derived.by(() => {
27
31
  const path = currentPath();
28
32
  if (path.includes('/settings/appearance')) return 'appearance';
33
+ if (path.includes('/settings/roles')) return 'roles';
34
+ if (path.includes('/settings/audit')) return 'audit';
29
35
  if (path.includes('/settings/about')) return 'about';
30
36
  return 'profile';
31
37
  });
@@ -91,11 +97,15 @@
91
97
  </nav>
92
98
 
93
99
  <!-- Right content area -->
94
- <div class="flex-1 p-6 lg:p-8 max-w-3xl">
100
+ <div class="flex-1 p-6 lg:p-8 {activeKey === 'roles' || activeKey === 'audit' ? 'max-w-6xl' : 'max-w-3xl'}">
95
101
  {#if activeKey === 'profile'}
96
102
  <ProfilePage />
97
103
  {:else if activeKey === 'appearance'}
98
104
  <AppearanceSettings />
105
+ {:else if activeKey === 'roles'}
106
+ <RolesSettings />
107
+ {:else if activeKey === 'audit'}
108
+ <AuditLogViewer />
99
109
  {:else if activeKey === 'about'}
100
110
  <AboutSettings />
101
111
  {/if}
@@ -0,0 +1,170 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { Loader2, CheckCircle2, XCircle, Clock, Download, X, ListTodo } from 'lucide-svelte';
4
+ import { Button } from './ui/button/index.js';
5
+ import { Badge } from './ui/badge/index.js';
6
+
7
+ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed';
8
+
9
+ export interface BackgroundTask {
10
+ id: string;
11
+ title: string;
12
+ status: TaskStatus;
13
+ progress?: number; // 0-100
14
+ message?: string;
15
+ createdAt: string | Date;
16
+ downloadUrl?: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ let {
21
+ open = $bindable(false),
22
+ tasks = $bindable<BackgroundTask[]>([]),
23
+ onDismiss,
24
+ onRetry,
25
+ onDownload,
26
+ }: {
27
+ open?: boolean;
28
+ tasks?: BackgroundTask[];
29
+ onDismiss?: (taskId: string) => void;
30
+ onRetry?: (taskId: string) => void;
31
+ onDownload?: (taskId: string, url: string) => void;
32
+ } = $props();
33
+
34
+ let runningCount = $derived(tasks.filter(t => t.status === 'running' || t.status === 'pending').length);
35
+
36
+ const statusIcon: Record<TaskStatus, typeof Loader2> = {
37
+ pending: Clock,
38
+ running: Loader2,
39
+ completed: CheckCircle2,
40
+ failed: XCircle,
41
+ };
42
+
43
+ const statusColor: Record<TaskStatus, string> = {
44
+ pending: 'text-muted-foreground',
45
+ running: 'text-primary',
46
+ completed: 'text-green-500',
47
+ failed: 'text-destructive',
48
+ };
49
+
50
+ function formatTime(d: string | Date) {
51
+ return new Date(d).toLocaleTimeString();
52
+ }
53
+
54
+ function dismissTask(id: string) {
55
+ tasks = tasks.filter(t => t.id !== id);
56
+ onDismiss?.(id);
57
+ }
58
+ </script>
59
+
60
+ <!-- Trigger Button (to be placed in Header) -->
61
+ <Button
62
+ variant="ghost"
63
+ size="icon"
64
+ class="relative"
65
+ onclick={() => open = !open}
66
+ >
67
+ <ListTodo class="h-4 w-4" />
68
+ {#if runningCount > 0}
69
+ <span class="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
70
+ {runningCount}
71
+ </span>
72
+ {/if}
73
+ </Button>
74
+
75
+ <!-- Task Queue Drawer -->
76
+ {#if open}
77
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
78
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
79
+ <div class="fixed inset-0 z-[90] flex justify-end" onclick={() => open = false}>
80
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
81
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
82
+ <div
83
+ class="bg-card shadow-2xl w-full max-w-sm h-full flex flex-col border-l border-border animate-in slide-in-from-right duration-200"
84
+ onclick={(e) => e.stopPropagation()}
85
+ >
86
+ <!-- Header -->
87
+ <div class="p-4 border-b border-border flex items-center justify-between bg-muted/30">
88
+ <div class="flex items-center gap-2">
89
+ <ListTodo class="h-5 w-5 text-muted-foreground" />
90
+ <h2 class="text-sm font-semibold">Background Tasks</h2>
91
+ {#if runningCount > 0}
92
+ <Badge variant="secondary" class="text-xs">{runningCount} active</Badge>
93
+ {/if}
94
+ </div>
95
+ <Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => open = false}>
96
+ <X class="h-4 w-4" />
97
+ </Button>
98
+ </div>
99
+
100
+ <!-- Task List -->
101
+ <div class="flex-1 overflow-y-auto">
102
+ {#each tasks as task}
103
+ {@const Icon = statusIcon[task.status]}
104
+ <div class="p-4 border-b border-border hover:bg-muted/30 transition-colors">
105
+ <div class="flex items-start justify-between gap-2">
106
+ <div class="flex items-start gap-3 flex-1 min-w-0">
107
+ <div class="{statusColor[task.status]} mt-0.5 shrink-0">
108
+ <Icon class="h-4 w-4 {task.status === 'running' ? 'animate-spin' : ''}" />
109
+ </div>
110
+ <div class="flex-1 min-w-0">
111
+ <p class="text-sm font-medium truncate text-foreground">{task.title}</p>
112
+ {#if task.message}
113
+ <p class="text-xs text-muted-foreground mt-0.5">{task.message}</p>
114
+ {/if}
115
+ <p class="text-[10px] text-muted-foreground/60 mt-1 font-mono">{formatTime(task.createdAt)}</p>
116
+ </div>
117
+ </div>
118
+ <button
119
+ class="text-muted-foreground/40 hover:text-muted-foreground transition shrink-0"
120
+ onclick={() => dismissTask(task.id)}
121
+ >
122
+ <X class="h-3.5 w-3.5" />
123
+ </button>
124
+ </div>
125
+
126
+ <!-- Progress bar -->
127
+ {#if task.status === 'running' && task.progress != null}
128
+ <div class="mt-3 ml-7">
129
+ <div class="h-1.5 w-full bg-muted rounded-full overflow-hidden">
130
+ <div
131
+ class="h-full bg-primary rounded-full transition-all duration-500 ease-out"
132
+ style="width: {task.progress}%"
133
+ ></div>
134
+ </div>
135
+ <p class="text-[10px] text-muted-foreground mt-1">{task.progress}%</p>
136
+ </div>
137
+ {/if}
138
+
139
+ <!-- Actions -->
140
+ {#if task.status === 'completed' && task.downloadUrl}
141
+ <div class="mt-2 ml-7">
142
+ <Button
143
+ variant="outline" size="sm" class="h-7 text-xs"
144
+ onclick={() => onDownload?.(task.id, task.downloadUrl!)}
145
+ >
146
+ <Download class="h-3 w-3 mr-1" />
147
+ Download
148
+ </Button>
149
+ </div>
150
+ {:else if task.status === 'failed' && onRetry}
151
+ <div class="mt-2 ml-7">
152
+ <Button
153
+ variant="outline" size="sm" class="h-7 text-xs text-destructive"
154
+ onclick={() => onRetry?.(task.id)}
155
+ >
156
+ Retry
157
+ </Button>
158
+ </div>
159
+ {/if}
160
+ </div>
161
+ {:else}
162
+ <div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
163
+ <ListTodo class="h-8 w-8 mb-2 opacity-30" />
164
+ <p class="text-sm">No background tasks</p>
165
+ </div>
166
+ {/each}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ {/if}
@@ -0,0 +1,90 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { ChevronsUpDown, Building2, Check } from 'lucide-svelte';
4
+ import { Button } from './ui/button/index.js';
5
+ import * as DropdownMenu from './ui/dropdown-menu/index.js';
6
+
7
+ export interface Tenant {
8
+ id: string;
9
+ name: string;
10
+ logo?: string;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ let {
15
+ tenants = [],
16
+ currentTenantId = $bindable(''),
17
+ collapsed = false,
18
+ onSwitch,
19
+ headerSnippet,
20
+ }: {
21
+ tenants: Tenant[];
22
+ currentTenantId?: string;
23
+ collapsed?: boolean;
24
+ onSwitch?: (tenantId: string) => void;
25
+ headerSnippet?: Snippet;
26
+ } = $props();
27
+
28
+ let currentTenant = $derived(tenants.find(t => t.id === currentTenantId) ?? tenants[0]);
29
+
30
+ function selectTenant(id: string) {
31
+ currentTenantId = id;
32
+ onSwitch?.(id);
33
+ }
34
+ </script>
35
+
36
+ {#if tenants.length > 0}
37
+ <DropdownMenu.Root>
38
+ <DropdownMenu.Trigger>
39
+ {#snippet child({ props }: { props: Record<string, unknown> })}
40
+ <button
41
+ {...props}
42
+ class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/50
43
+ {collapsed ? 'justify-center' : ''}"
44
+ >
45
+ <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary text-sm font-bold">
46
+ {#if currentTenant?.logo}
47
+ <img src={currentTenant.logo} alt={currentTenant.name} class="h-full w-full rounded-lg object-cover" />
48
+ {:else}
49
+ <Building2 class="h-4 w-4" />
50
+ {/if}
51
+ </div>
52
+ {#if !collapsed}
53
+ <div class="flex-1 min-w-0">
54
+ <p class="truncate text-sm font-semibold text-sidebar-foreground">{currentTenant?.name ?? 'Select'}</p>
55
+ <p class="truncate text-[10px] text-sidebar-foreground/50">Workspace</p>
56
+ </div>
57
+ <ChevronsUpDown class="h-4 w-4 shrink-0 text-sidebar-foreground/40" />
58
+ {/if}
59
+ </button>
60
+ {/snippet}
61
+ </DropdownMenu.Trigger>
62
+ <DropdownMenu.Content class="w-56" align="start" side="right" sideOffset={8}>
63
+ {#if headerSnippet}
64
+ <div class="px-2 py-1.5">
65
+ {@render headerSnippet()}
66
+ </div>
67
+ <DropdownMenu.Separator />
68
+ {/if}
69
+ <DropdownMenu.Label class="text-xs text-muted-foreground">Workspaces</DropdownMenu.Label>
70
+ {#each tenants as tenant}
71
+ <DropdownMenu.Item
72
+ class="flex items-center gap-3 cursor-pointer"
73
+ onclick={() => selectTenant(tenant.id)}
74
+ >
75
+ <div class="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-primary/10 text-primary text-xs font-bold">
76
+ {#if tenant.logo}
77
+ <img src={tenant.logo} alt={tenant.name} class="h-full w-full rounded object-cover" />
78
+ {:else}
79
+ {tenant.name.charAt(0).toUpperCase()}
80
+ {/if}
81
+ </div>
82
+ <span class="flex-1 truncate">{tenant.name}</span>
83
+ {#if tenant.id === currentTenantId}
84
+ <Check class="h-4 w-4 text-primary" />
85
+ {/if}
86
+ </DropdownMenu.Item>
87
+ {/each}
88
+ </DropdownMenu.Content>
89
+ </DropdownMenu.Root>
90
+ {/if}
@@ -12,8 +12,8 @@
12
12
  }>();
13
13
 
14
14
  const nav = useNavigation();
15
- const can = $derived(accessControl?.enabled ? useCan(resource, 'create') : null);
16
- const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
15
+ const can = useCan(() => ({ resource, action: 'create', queryOptions: { enabled: accessControl?.enabled ?? true } }));
16
+ const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
17
17
  </script>
18
18
 
19
19
  {#if !hidden}
@@ -21,7 +21,7 @@
21
21
  variant="outline"
22
22
  size={hideText ? 'icon' : 'sm'}
23
23
  class={className}
24
- disabled={can ? !can.allowed : false}
24
+ disabled={!can.allowed}
25
25
  onclick={() => nav.clone(resource, recordItemId)}
26
26
  >
27
27
  <Copy class="h-4 w-4" />
@@ -11,8 +11,8 @@
11
11
  }>();
12
12
 
13
13
  const nav = useNavigation();
14
- const can = $derived(accessControl?.enabled ? useCan(resource, 'create') : null);
15
- const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
14
+ const can = useCan(() => ({ resource, action: 'create', queryOptions: { enabled: accessControl?.enabled ?? true } }));
15
+ const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
16
16
  </script>
17
17
 
18
18
  {#if !hidden}
@@ -20,7 +20,7 @@
20
20
  variant="default"
21
21
  size={hideText ? 'icon' : 'default'}
22
22
  class={className}
23
- disabled={can ? !can.allowed : false}
23
+ disabled={!can.allowed}
24
24
  onclick={() => nav.create(resource)}
25
25
  >
26
26
  <Plus class="h-4 w-4" />
@@ -17,8 +17,8 @@
17
17
  }>();
18
18
 
19
19
  const deleteMut = useDelete({ get resource() { return resource; }, get mutationMode() { return undoable ? 'undoable' as const : 'pessimistic' as const; } });
20
- const can = $derived(accessControl?.enabled ? useCan(resource, 'delete') : null);
21
- const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
20
+ const can = useCan(() => ({ resource, action: 'delete', queryOptions: { enabled: accessControl?.enabled ?? true } }));
21
+ const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
22
22
  let confirming = $state(false);
23
23
 
24
24
  async function handleDelete() {
@@ -49,7 +49,7 @@
49
49
  variant="ghost"
50
50
  size={hideText ? 'icon' : 'sm'}
51
51
  class="text-destructive hover:text-destructive {className}"
52
- disabled={can ? !can.allowed : false}
52
+ disabled={!can.allowed}
53
53
  onclick={handleDelete}
54
54
  >
55
55
  <Trash2 class="h-4 w-4" />
@@ -12,8 +12,8 @@
12
12
  }>();
13
13
 
14
14
  const nav = useNavigation();
15
- const can = $derived(accessControl?.enabled ? useCan(resource, 'edit') : null);
16
- const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
15
+ const can = useCan(() => ({ resource, action: 'edit', queryOptions: { enabled: accessControl?.enabled ?? true } }));
16
+ const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
17
17
  </script>
18
18
 
19
19
  {#if !hidden}
@@ -21,7 +21,7 @@
21
21
  variant="outline"
22
22
  size={hideText ? 'icon' : 'sm'}
23
23
  class={className}
24
- disabled={can ? !can.allowed : false}
24
+ disabled={!can.allowed}
25
25
  onclick={() => nav.edit(resource, recordItemId)}
26
26
  >
27
27
  <Pencil class="h-4 w-4" />
@@ -11,8 +11,8 @@
11
11
  }>();
12
12
 
13
13
  const { triggerExport, isLoading } = useExport({ get resource() { return resource; } });
14
- const can = $derived(accessControl?.enabled ? useCan(resource, 'export') : null);
15
- const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
14
+ const can = useCan(() => ({ resource, action: 'export', queryOptions: { enabled: accessControl?.enabled ?? true } }));
15
+ const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
16
16
  </script>
17
17
 
18
18
  {#if !hidden}
@@ -21,7 +21,7 @@
21
21
  variant="outline"
22
22
  size={hideText ? 'icon' : 'sm'}
23
23
  class={className}
24
- disabled={isLoading || (can ? !can.allowed : false)}
24
+ disabled={isLoading || !can.allowed}
25
25
  onclick={triggerExport}
26
26
  >
27
27
  <Download class="h-4 w-4" />
@@ -31,8 +31,8 @@
31
31
  }
32
32
  }
33
33
 
34
- const can = $derived(accessControl?.enabled ? useCan(resource, 'import') : null);
35
- const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
34
+ const can = useCan(() => ({ resource, action: 'import', queryOptions: { enabled: accessControl?.enabled ?? true } }));
35
+ const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
36
36
  </script>
37
37
 
38
38
  {#if !hidden}
@@ -47,7 +47,7 @@
47
47
  variant="outline"
48
48
  size={hideText ? 'icon' : 'sm'}
49
49
  class={className}
50
- disabled={importHook.isLoading || (can ? !can.allowed : false)}
50
+ disabled={importHook.isLoading || !can.allowed}
51
51
  onclick={triggerImport}
52
52
  >
53
53
  <Upload class="h-4 w-4" />
@@ -12,8 +12,8 @@
12
12
  }>();
13
13
 
14
14
  const nav = useNavigation();
15
- const can = $derived(accessControl?.enabled ? useCan(resource, 'show') : null);
16
- const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
15
+ const can = useCan(() => ({ resource, action: 'show', queryOptions: { enabled: accessControl?.enabled ?? true } }));
16
+ const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
17
17
  </script>
18
18
 
19
19
  {#if !hidden}
@@ -22,7 +22,7 @@
22
22
  variant="ghost"
23
23
  size={hideText ? 'icon' : 'sm'}
24
24
  class={className}
25
- disabled={can ? !can.allowed : false}
25
+ disabled={!can.allowed}
26
26
  onclick={() => nav.show(resource, recordItemId)}
27
27
  >
28
28
  <Eye class="h-4 w-4" />