@lego-box/shell 1.0.5 → 1.0.6

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.
Files changed (45) hide show
  1. package/dist/emulator/lego-box-shell-1.0.6.tgz +0 -0
  2. package/package.json +2 -3
  3. package/src/auth/auth-store.ts +33 -0
  4. package/src/auth/auth.ts +176 -0
  5. package/src/components/ProtectedPage.tsx +48 -0
  6. package/src/config/env.node.ts +38 -0
  7. package/src/config/env.ts +103 -0
  8. package/src/context/AbilityContext.tsx +213 -0
  9. package/src/context/PiralInstanceContext.tsx +17 -0
  10. package/src/hooks/index.ts +11 -0
  11. package/src/hooks/useAuditLogs.ts +190 -0
  12. package/src/hooks/useDebounce.ts +34 -0
  13. package/src/hooks/usePermissionGuard.tsx +39 -0
  14. package/src/hooks/usePermissions.ts +190 -0
  15. package/src/hooks/useRoles.ts +233 -0
  16. package/src/hooks/useTickets.ts +214 -0
  17. package/src/hooks/useUserLogins.ts +39 -0
  18. package/src/hooks/useUsers.ts +252 -0
  19. package/src/index.html +16 -0
  20. package/src/index.tsx +296 -0
  21. package/src/layout.tsx +246 -0
  22. package/src/migrations/config.ts +62 -0
  23. package/src/migrations/dev-migrations.ts +75 -0
  24. package/src/migrations/index.ts +13 -0
  25. package/src/migrations/run-migrations.ts +187 -0
  26. package/src/migrations/runner.ts +925 -0
  27. package/src/migrations/types.ts +207 -0
  28. package/src/migrations/utils.ts +264 -0
  29. package/src/pages/AuditLogsPage.tsx +378 -0
  30. package/src/pages/ContactSupportPage.tsx +610 -0
  31. package/src/pages/LandingPage.tsx +221 -0
  32. package/src/pages/LoginPage.tsx +217 -0
  33. package/src/pages/MigrationsPage.tsx +1364 -0
  34. package/src/pages/ProfilePage.tsx +335 -0
  35. package/src/pages/SettingsPage.tsx +101 -0
  36. package/src/pages/SystemHealthCheckPage.tsx +144 -0
  37. package/src/pages/UserManagementPage.tsx +1010 -0
  38. package/src/piral/api.ts +39 -0
  39. package/src/piral/auth-casl.ts +56 -0
  40. package/src/piral/menu.ts +102 -0
  41. package/src/piral/piral.json +4 -0
  42. package/src/services/telemetry.ts +37 -0
  43. package/src/styles/globals.css +1351 -0
  44. package/src/utils/auditLogger.ts +68 -0
  45. package/dist/emulator/lego-box-shell-1.0.5.tgz +0 -0
@@ -0,0 +1,1010 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Badge,
4
+ Tabs,
5
+ TabsList,
6
+ TabsTrigger,
7
+ DataTable,
8
+ DataTableColumn,
9
+ GroupedTable,
10
+ SearchInput,
11
+ FilterChip,
12
+ Button,
13
+ UserFormDialog,
14
+ UserFormData,
15
+ RoleFormDialog,
16
+ RoleFormData,
17
+ AssignUsersToRoleDialog,
18
+ PermissionFormDialog,
19
+ PermissionFormData,
20
+ DeleteConfirmationDialog,
21
+ ChangePasswordDialog,
22
+ BulkActionToolbar,
23
+ BulkConfirmationDialog,
24
+ Pagination,
25
+ DialogContent,
26
+ DialogFooter,
27
+ CanDisable,
28
+ } from '@lego-box/ui-kit';
29
+ import { useUsers, useUserCounts } from '../hooks/useUsers';
30
+ import { useRoles, usePermissions, useDebounce, useCan } from '../hooks';
31
+ import { usePiralInstance } from '../context/PiralInstanceContext';
32
+ import { RBACUser, Permission, Role } from '../types';
33
+ import { createAuditLog } from '../utils/auditLogger';
34
+ import { SYSTEM_DEFAULT_PERMISSIONS, SYSTEM_MANDATORY_PERMISSIONS_FOR_ROLES } from '../migrations/config';
35
+ import { toast } from 'sonner';
36
+ import { Pencil, Trash2, Search, Download, Users, Shield, KeyRound, Key, UserPlus } from 'lucide-react';
37
+
38
+ interface User {
39
+ id: string;
40
+ name: string;
41
+ email: string;
42
+ avatar?: string;
43
+ role: string;
44
+ status: 'Active' | 'Inactive';
45
+ lastActive: string;
46
+ roleId?: string;
47
+ is_superuser?: boolean;
48
+ created?: string;
49
+ updated?: string;
50
+ }
51
+
52
+ function getStatusBadgeVariant(status: User['status']) {
53
+ switch (status) {
54
+ case 'Active':
55
+ return 'success';
56
+ case 'Inactive':
57
+ return 'destructive';
58
+ default:
59
+ return 'default';
60
+ }
61
+ }
62
+
63
+ function getRoleBadgeVariant(role: string) {
64
+ const roleLower = role.toLowerCase();
65
+ if (roleLower.includes('admin') || roleLower.includes('superuser')) return 'default';
66
+ if (roleLower.includes('editor') || roleLower.includes('manager')) return 'info';
67
+ if (roleLower.includes('viewer') || roleLower.includes('guest')) return 'muted';
68
+ return 'success';
69
+ }
70
+
71
+ function getActionBadgeVariant(action: string): 'success' | 'info' | 'warning' | 'destructive' | 'default' {
72
+ switch (action) {
73
+ case 'create': return 'success';
74
+ case 'read': return 'info';
75
+ case 'update': return 'warning';
76
+ case 'delete': return 'destructive';
77
+ default: return 'default';
78
+ }
79
+ }
80
+
81
+ function mapPocketBaseUserToUser(record: RBACUser): User {
82
+ const status: 'Active' | 'Inactive' = record.isActive === false ? 'Inactive' : 'Active';
83
+ const lastActive = record.updated ? formatLastActive(record.updated) : 'Never';
84
+
85
+ return {
86
+ id: record.id,
87
+ name: record.name || (record.email ? record.email.split('@')[0] : 'Unknown'),
88
+ email: record.email ?? '',
89
+ avatar: record.avatar,
90
+ role: record.roleName || (record.is_superuser ? 'System' : 'Unknown'),
91
+ status,
92
+ lastActive,
93
+ roleId: record.role,
94
+ is_superuser: record.is_superuser,
95
+ created: record.created,
96
+ updated: record.updated,
97
+ };
98
+ }
99
+
100
+ function getInitials(name: string, email: string): string {
101
+ if (name) {
102
+ return name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2);
103
+ }
104
+ return email ? email[0].toUpperCase() : 'U';
105
+ }
106
+
107
+ function formatLastActive(timestamp: string): string {
108
+ const now = new Date();
109
+ const updated = new Date(timestamp);
110
+ const diffMs = now.getTime() - updated.getTime();
111
+ const diffMins = Math.floor(diffMs / 60000);
112
+ const diffHours = Math.floor(diffMs / 3600000);
113
+ const diffDays = Math.floor(diffMs / 86400000);
114
+
115
+ if (diffMins < 1) return 'Just now';
116
+ if (diffMins < 60) return `${diffMins} min ago`;
117
+ if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
118
+ if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
119
+ return updated.toLocaleDateString();
120
+ }
121
+
122
+ export function UserManagementPage() {
123
+ const instance = usePiralInstance();
124
+ const can = useCan();
125
+ const pb = React.useMemo(() => (instance as any)?.root?.pocketbase, [instance]);
126
+ const isCurrentUserSuperuser = React.useMemo(() => pb?.authStore?.model?.is_superuser === true, [pb]);
127
+
128
+ const [activeTab, setActiveTab] = React.useState('users');
129
+ const [activeFilter, setActiveFilter] = React.useState<'all' | 'active' | 'inactive'>('all');
130
+ const [searchQuery, setSearchQuery] = React.useState('');
131
+ const [selectedRows, setSelectedRows] = React.useState<Set<string>>(new Set());
132
+ const [roleFilter, setRoleFilter] = React.useState<string>('all');
133
+ const [exporting, setExporting] = React.useState(false);
134
+
135
+ // Dialog states
136
+ const [userFormOpen, setUserFormOpen] = React.useState(false);
137
+ const [deleteUserDialogOpen, setDeleteUserDialogOpen] = React.useState(false);
138
+ const [changePasswordDialogOpen, setChangePasswordDialogOpen] = React.useState(false);
139
+ const [selectedUser, setSelectedUser] = React.useState<User | null>(null);
140
+ const [userFormLoading, setUserFormLoading] = React.useState(false);
141
+ const [userForEditLoading, setUserForEditLoading] = React.useState(false);
142
+ const [changePasswordLoading, setChangePasswordLoading] = React.useState(false);
143
+
144
+ const [roleFormOpen, setRoleFormOpen] = React.useState(false);
145
+ const [assignUsersDialogOpen, setAssignUsersDialogOpen] = React.useState(false);
146
+ const [selectedRoleForAssign, setSelectedRoleForAssign] = React.useState<Role | null>(null);
147
+ const [deleteRoleDialogOpen, setDeleteRoleDialogOpen] = React.useState(false);
148
+ const [selectedRole, setSelectedRole] = React.useState<Role | null>(null);
149
+ const [roleFormLoading, setRoleFormLoading] = React.useState(false);
150
+ const [deleteRoleError, setDeleteRoleError] = React.useState<string>('');
151
+
152
+ const [permissionFormOpen, setPermissionFormOpen] = React.useState(false);
153
+ const [selectedPermission, setSelectedPermission] = React.useState<Permission | null>(null);
154
+ const [permissionFormLoading, setPermissionFormLoading] = React.useState(false);
155
+ const [deletePermissionDialogOpen, setDeletePermissionDialogOpen] = React.useState(false);
156
+ const [deletePermissionError, setDeletePermissionError] = React.useState<string>('');
157
+
158
+ // Pagination states
159
+ const [usersPage, setUsersPage] = React.useState(1);
160
+ const [usersPerPage, setUsersPerPage] = React.useState(10);
161
+ const [rolesPage, setRolesPage] = React.useState(1);
162
+ const [rolesPerPage, setRolesPerPage] = React.useState(10);
163
+ const [permissionsPage, setPermissionsPage] = React.useState(1);
164
+ const [permissionsPerPage, setPermissionsPerPage] = React.useState(10);
165
+ const [permissionsCollectionFilter, setPermissionsCollectionFilter] = React.useState<string>('all');
166
+ const debouncedSearchQuery = useDebounce(searchQuery, 500);
167
+
168
+ React.useEffect(() => {
169
+ setUsersPage(1);
170
+ setRolesPage(1);
171
+ setPermissionsPage(1);
172
+ }, [debouncedSearchQuery, activeFilter, roleFilter, permissionsCollectionFilter]);
173
+
174
+ // Unique collections for permissions filter - fetch when permissions tab is active
175
+ const [permissionCollections, setPermissionCollections] = React.useState<string[]>([]);
176
+ React.useEffect(() => {
177
+ if (activeTab !== 'permissions' || !pb) return;
178
+ let cancelled = false;
179
+ pb.collection('permissions')
180
+ .getFullList({ fields: 'collection', $autoCancel: false })
181
+ .then((records: any[]) => {
182
+ if (cancelled) return;
183
+ const cols = [...new Set(records.map((r) => r.collection).filter(Boolean))].sort();
184
+ setPermissionCollections(cols);
185
+ })
186
+ .catch(() => {});
187
+ return () => { cancelled = true; };
188
+ }, [activeTab, pb]);
189
+
190
+ // Data Fetching
191
+ const { users, loading: usersLoading, error: usersError, refetch: refetchUsers, pagination: usersPagination } = useUsers({
192
+ page: usersPage,
193
+ perPage: usersPerPage,
194
+ searchQuery: debouncedSearchQuery,
195
+ statusFilter: activeFilter,
196
+ roleFilter: roleFilter !== 'all' ? roleFilter : undefined,
197
+ enabled: activeTab === 'users'
198
+ });
199
+
200
+ const userCounts = useUserCounts(activeTab === 'users');
201
+
202
+ const { roles, loading: rolesLoading, error: rolesError, refetch: refetchRoles, pagination: rolesPagination } = useRoles({
203
+ page: rolesPage,
204
+ perPage: rolesPerPage,
205
+ searchQuery: debouncedSearchQuery,
206
+ enabled: activeTab === 'roles' || activeTab === 'users' || userFormOpen || roleFormOpen,
207
+ });
208
+
209
+ const { permissions, loading: permissionsLoading, error: permissionsError, refetch: refetchPermissions, pagination: permissionsPagination } = usePermissions({
210
+ page: permissionsPage,
211
+ perPage: permissionsPerPage,
212
+ searchQuery: debouncedSearchQuery,
213
+ collectionFilter: permissionsCollectionFilter !== 'all' ? permissionsCollectionFilter : undefined,
214
+ enabled: activeTab === 'permissions'
215
+ });
216
+
217
+ // Fetch ALL permissions when role form is open (no pagination - getFullList for complete list)
218
+ const { permissions: allPermissionsForRoleForm, loading: roleFormPermissionsLoading } = usePermissions({
219
+ fetchAll: true,
220
+ enabled: roleFormOpen
221
+ });
222
+
223
+ // Fetch ALL users when assign-users dialog is open
224
+ const { users: allUsersForAssignDialog, loading: assignDialogUsersLoading } = useUsers({
225
+ perPage: 500,
226
+ enabled: assignUsersDialogOpen
227
+ });
228
+
229
+ const loading = usersLoading || rolesLoading || permissionsLoading;
230
+ const error = usersError || rolesError || permissionsError;
231
+ const refetch = activeTab === 'users' ? refetchUsers : activeTab === 'roles' ? refetchRoles : refetchPermissions;
232
+
233
+ // Mapped Data & Stats
234
+ const filteredUsers = React.useMemo(() => users.map(mapPocketBaseUserToUser), [users]);
235
+ const filteredRoles = React.useMemo(() => roles, [roles]);
236
+ const roleStats = React.useMemo(() => ({ total: rolesPagination?.totalItems || 0 }), [rolesPagination]);
237
+ // Exclude migrations permissions - those collections are superuser-only, not shown in RBAC UI
238
+ const MIGRATIONS_COLLECTIONS = ['migrations', 'migrations_history'];
239
+ const filteredPermissions = React.useMemo(
240
+ () => permissions.filter((p) => !MIGRATIONS_COLLECTIONS.includes(p.collection)),
241
+ [permissions]
242
+ );
243
+
244
+ // CRUD Handlers
245
+ const handleCreateUser = async (formData: UserFormData) => {
246
+ if (!pb) throw new Error('PocketBase not available');
247
+ setUserFormLoading(true);
248
+ try {
249
+ const newUser = await pb.collection('users').create({
250
+ email: formData.email,
251
+ name: formData.name,
252
+ role: formData.role,
253
+ isActive: formData.status === 'Active',
254
+ password: formData.password,
255
+ passwordConfirm: formData.passwordConfirm,
256
+ emailVisibility: true, // Allow admins and users with read:users to see email
257
+ });
258
+ await createAuditLog(pb, 'create', 'users', newUser.id, newUser.email, { name: newUser.name, email: newUser.email, role: newUser.role });
259
+ setUserFormOpen(false);
260
+ toast.success('Successfully created user');
261
+ } finally {
262
+ setUserFormLoading(false);
263
+ }
264
+ };
265
+
266
+ const handleUpdateUser = async (formData: UserFormData) => {
267
+ if (!pb || !selectedUser) throw new Error('No user selected');
268
+ setUserFormLoading(true);
269
+ try {
270
+ const updatedUser = await pb.collection('users').update(selectedUser.id, {
271
+ email: formData.email,
272
+ name: formData.name,
273
+ role: formData.role,
274
+ isActive: formData.status === 'Active',
275
+ emailVisibility: true, // Ensure email is visible to admins and users with read:users
276
+ });
277
+ await createAuditLog(pb, 'update', 'users', updatedUser.id, updatedUser.email, { name: updatedUser.name, email: updatedUser.email, role: updatedUser.role });
278
+ setUserFormOpen(false);
279
+ setSelectedUser(null);
280
+ toast.success('Successfully updated user');
281
+ } finally {
282
+ setUserFormLoading(false);
283
+ }
284
+ };
285
+
286
+ const handleDeleteUser = async () => {
287
+ if (!pb || !selectedUser) throw new Error('No user selected');
288
+ if (selectedUser.is_superuser) {
289
+ toast.error('Superusers cannot be deleted');
290
+ setDeleteUserDialogOpen(false);
291
+ setSelectedUser(null);
292
+ return;
293
+ }
294
+ setUserFormLoading(true);
295
+ try {
296
+ await pb.collection('users').delete(selectedUser.id);
297
+ await createAuditLog(pb, 'delete', 'users', selectedUser.id, selectedUser.email, { name: selectedUser.name });
298
+ setDeleteUserDialogOpen(false);
299
+ setSelectedUser(null);
300
+ toast.success('Successfully deleted user');
301
+ } finally {
302
+ setUserFormLoading(false);
303
+ }
304
+ };
305
+
306
+ const handleChangePassword = async (password: string, passwordConfirm: string) => {
307
+ if (!pb || !selectedUser) throw new Error('No user selected');
308
+ if (selectedUser.is_superuser) {
309
+ toast.error('Superusers cannot have their password changed');
310
+ return;
311
+ }
312
+ setChangePasswordLoading(true);
313
+ try {
314
+ await pb.collection('users').update(selectedUser.id, {
315
+ password,
316
+ passwordConfirm,
317
+ });
318
+ await createAuditLog(pb, 'update', 'users', selectedUser.id, selectedUser.email, { passwordChanged: true });
319
+ setChangePasswordDialogOpen(false);
320
+ setSelectedUser(null);
321
+ refetchUsers();
322
+ toast.success('Password changed successfully');
323
+ } finally {
324
+ setChangePasswordLoading(false);
325
+ }
326
+ };
327
+
328
+ const handleCreateRole = async (formData: RoleFormData) => {
329
+ if (!pb) throw new Error('PocketBase not available');
330
+ setRoleFormLoading(true);
331
+ try {
332
+ // API rules expect permission names (e.g. "read:users"), not IDs - convert before saving
333
+ const permissionNames = formData.permissions
334
+ .map((id) => allPermissionsForRoleForm.find((p) => p.id === id)?.name)
335
+ .filter((n): n is string => !!n);
336
+ const payload = { ...formData, permissions: permissionNames };
337
+ const newRole = await pb.collection('roles').create(payload);
338
+ await createAuditLog(pb, 'create', 'roles', newRole.id, newRole.name, payload);
339
+ setRoleFormOpen(false);
340
+ toast.success('Successfully created role');
341
+ } finally {
342
+ setRoleFormLoading(false);
343
+ }
344
+ };
345
+
346
+ const handleUpdateRole = async (formData: RoleFormData) => {
347
+ if (!pb || !selectedRole) throw new Error('No role selected');
348
+ setRoleFormLoading(true);
349
+ try {
350
+ // API rules expect permission names (e.g. "read:users"), not IDs - convert before saving
351
+ const permissionNames = formData.permissions
352
+ .map((id) => allPermissionsForRoleForm.find((p) => p.id === id)?.name)
353
+ .filter((n): n is string => !!n);
354
+ const payload = { ...formData, permissions: permissionNames };
355
+ const updatedRole = await pb.collection('roles').update(selectedRole.id, payload);
356
+ await createAuditLog(pb, 'update', 'roles', updatedRole.id, updatedRole.name, payload);
357
+ setRoleFormOpen(false);
358
+ setSelectedRole(null);
359
+ toast.success('Successfully updated role');
360
+ } finally {
361
+ setRoleFormLoading(false);
362
+ }
363
+ };
364
+
365
+ const handleDeleteRole = async () => {
366
+ if (!pb || !selectedRole) throw new Error('No role selected');
367
+ setRoleFormLoading(true);
368
+ try {
369
+ await pb.collection('roles').delete(selectedRole.id);
370
+ await createAuditLog(pb, 'delete', 'roles', selectedRole.id, selectedRole.name, {});
371
+ setDeleteRoleDialogOpen(false);
372
+ setSelectedRole(null);
373
+ toast.success('Successfully deleted role');
374
+ } finally {
375
+ setRoleFormLoading(false);
376
+ }
377
+ };
378
+
379
+ const handleAssignUserToRole = async (userId: string) => {
380
+ if (!pb || !selectedRoleForAssign) throw new Error('No role selected');
381
+ const userBefore = allUsersForAssignDialog.find((u) => u.id === userId);
382
+ const previousRoleName = userBefore?.roleName ?? null;
383
+ await pb.collection('users').update(userId, { role: selectedRoleForAssign.id });
384
+ await createAuditLog(pb, 'update', 'users', userId, userBefore?.email ?? userId, {
385
+ role: selectedRoleForAssign.name,
386
+ roleReassignment: true,
387
+ previousRole: previousRoleName,
388
+ });
389
+ toast.success(`User assigned to ${selectedRoleForAssign.name}`);
390
+ refetchUsers();
391
+ refetchRoles();
392
+ };
393
+
394
+ const handleCreatePermission = async (formData: PermissionFormData) => {
395
+ if (!pb) throw new Error('PocketBase not available');
396
+ // Check if permission with same action:collection already exists
397
+ const name = formData.name || `${formData.action.trim().toLowerCase()}:${formData.collection.trim().toLowerCase()}`;
398
+ const existing = await pb.collection('permissions').getList(1, 1, { filter: `name = "${name}"` });
399
+ if (existing.totalItems > 0) {
400
+ const { toast } = await import('sonner');
401
+ toast.error('Permission already exists', { description: `A permission with "${name}" already exists.` });
402
+ return;
403
+ }
404
+ setPermissionFormLoading(true);
405
+ try {
406
+ const newPermission = await pb.collection('permissions').create(formData);
407
+ await createAuditLog(pb, 'create', 'permissions', newPermission.id, newPermission.name, formData);
408
+ setPermissionFormOpen(false);
409
+ toast.success('Successfully created permission');
410
+ } finally {
411
+ setPermissionFormLoading(false);
412
+ }
413
+ };
414
+
415
+ const handleUpdatePermission = async (formData: PermissionFormData) => {
416
+ if (!pb || !selectedPermission) throw new Error('No permission selected');
417
+ setPermissionFormLoading(true);
418
+ try {
419
+ const updatedPermission = await pb.collection('permissions').update(selectedPermission.id, formData);
420
+ await createAuditLog(pb, 'update', 'permissions', updatedPermission.id, updatedPermission.name, formData);
421
+ setPermissionFormOpen(false);
422
+ setSelectedPermission(null);
423
+ toast.success('Successfully updated permission');
424
+ } finally {
425
+ setPermissionFormLoading(false);
426
+ }
427
+ };
428
+
429
+ const handleDeletePermission = async () => {
430
+ if (!pb || !selectedPermission) throw new Error('No permission selected');
431
+ setPermissionFormLoading(true);
432
+ try {
433
+ await pb.collection('permissions').delete(selectedPermission.id);
434
+ await createAuditLog(pb, 'delete', 'permissions', selectedPermission.id, selectedPermission.name, {});
435
+ setDeletePermissionDialogOpen(false);
436
+ setSelectedPermission(null);
437
+ toast.success('Successfully deleted permission');
438
+ } finally {
439
+ setPermissionFormLoading(false);
440
+ }
441
+ };
442
+
443
+ // Export handler - fetches all users with current filters and exports as CSV
444
+ const handleExportUsers = React.useCallback(async () => {
445
+ if (!pb) return;
446
+ setExporting(true);
447
+ try {
448
+ const filters: string[] = [];
449
+ if (activeFilter !== 'all') {
450
+ switch (activeFilter) {
451
+ case 'active': filters.push('isActive = true'); break;
452
+ case 'inactive': filters.push('isActive = false'); break;
453
+ }
454
+ }
455
+ if (roleFilter !== 'all') filters.push(`role = "${roleFilter}"`);
456
+ if (debouncedSearchQuery) filters.push(`(name ~ "${debouncedSearchQuery}" || email ~ "${debouncedSearchQuery}")`);
457
+ const filterString = filters.length > 0 ? filters.join(' && ') : '';
458
+
459
+ const rolesRes = await pb.collection('roles').getFullList({ fields: 'id,name', $autoCancel: false });
460
+ const rolesMap = new Map(rolesRes.map((r: any) => [r.id, r.name]));
461
+
462
+ const records = await pb.collection('users').getList(1, 5000, {
463
+ filter: filterString || undefined,
464
+ sort: '-created',
465
+ $autoCancel: false,
466
+ });
467
+
468
+ const usersToExport = records.items.map((record: any) => {
469
+ const roleId = Array.isArray(record.role) ? record.role[0] : record.role;
470
+ const roleName = roleId ? rolesMap.get(roleId) || 'Unknown' : 'Unknown';
471
+ const status: string = record.isActive === false ? 'Inactive' : 'Active';
472
+ const lastActive = record.updated ? formatLastActive(record.updated) : 'Never';
473
+ return [
474
+ record.name || record.email.split('@')[0],
475
+ record.email,
476
+ roleName,
477
+ status,
478
+ lastActive,
479
+ ];
480
+ });
481
+
482
+ const headers = ['Name', 'Email', 'Role', 'Status', 'Last Active'];
483
+ const csv = [headers.join(','), ...usersToExport.map((r: string[]) => r.map((c: string) => `"${String(c).replace(/"/g, '""')}"`).join(','))].join('\n');
484
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
485
+ const link = document.createElement('a');
486
+ link.href = URL.createObjectURL(blob);
487
+ link.download = `users-export-${new Date().toISOString().slice(0, 10)}.csv`;
488
+ link.click();
489
+ URL.revokeObjectURL(link.href);
490
+ } finally {
491
+ setExporting(false);
492
+ }
493
+ }, [pb, activeFilter, roleFilter, debouncedSearchQuery]);
494
+
495
+ const handleSelectAllUsers = (selected: boolean) => {
496
+ if (selected) {
497
+ setSelectedRows(new Set(filteredUsers.map((u) => u.id)));
498
+ } else {
499
+ setSelectedRows(new Set());
500
+ }
501
+ };
502
+
503
+ const handleSelectRow = (id: string, selected: boolean) => {
504
+ setSelectedRows((prev) => {
505
+ const next = new Set(prev);
506
+ if (selected) next.add(id);
507
+ else next.delete(id);
508
+ return next;
509
+ });
510
+ };
511
+
512
+ // Fetch full user record when opening edit (ensures email is available for form)
513
+ const handleOpenEditUser = React.useCallback(async (user: User) => {
514
+ if (!pb) return;
515
+ setUserForEditLoading(true);
516
+ try {
517
+ const record = await pb.collection('users').getOne(user.id, { expand: 'role', $autoCancel: false });
518
+ const roleId = Array.isArray(record.role) ? record.role[0] : record.role;
519
+ const expandedRole = record.expand?.role;
520
+ const roleName = (Array.isArray(expandedRole) ? expandedRole[0]?.name : expandedRole?.name) ?? roles.find((r) => r.id === roleId)?.name;
521
+ const fetchedUser: User = mapPocketBaseUserToUser({
522
+ id: record.id,
523
+ email: record.email ?? '',
524
+ name: record.name,
525
+ avatar: record.avatar,
526
+ role: roleId,
527
+ roleName: roleName,
528
+ is_superuser: record.is_superuser ?? false,
529
+ isActive: record.isActive ?? true,
530
+ verified: record.verified ?? false,
531
+ permissions: [],
532
+ created: record.created,
533
+ updated: record.updated,
534
+ });
535
+ setSelectedUser(fetchedUser);
536
+ setUserFormOpen(true);
537
+ } catch (err: any) {
538
+ toast.error('Failed to load user details', { description: err?.message });
539
+ } finally {
540
+ setUserForEditLoading(false);
541
+ }
542
+ }, [pb, roles]);
543
+
544
+ // Columns
545
+ const userColumns: DataTableColumn<User>[] = [
546
+ {
547
+ key: 'user',
548
+ header: 'User',
549
+ cell: (user) => (
550
+ <div className="flex items-center gap-3">
551
+ <div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
552
+ {getInitials(user.name, user.email)}
553
+ </div>
554
+ <div>
555
+ <p className="font-medium">{user.name}</p>
556
+ <p className="text-xs text-muted-foreground">{user.email || '—'}</p>
557
+ </div>
558
+ </div>
559
+ ),
560
+ },
561
+ { key: 'role', header: 'Role', cell: (user) => <Badge variant={getRoleBadgeVariant(user.role)}>{user.role}</Badge> },
562
+ { key: 'status', header: 'Status', cell: (user) => <Badge variant={getStatusBadgeVariant(user.status)}>{user.status}</Badge> },
563
+ { key: 'is_superuser', header: 'Superuser', cell: (user) => user.is_superuser ? <Badge variant="default">Yes</Badge> : <span className="text-muted-foreground">—</span> },
564
+ { key: 'lastActive', header: 'Last Active', cell: (user) => user.lastActive },
565
+ {
566
+ key: 'actions',
567
+ header: '',
568
+ cell: (user) => {
569
+ const isSuperuser = user.is_superuser === true;
570
+ const canUpdate = can('update', 'users');
571
+ const canDelete = can('delete', 'users');
572
+ const editDisabled = isSuperuser || userForEditLoading;
573
+ const passwordDisabled = isSuperuser;
574
+ const deleteDisabled = isSuperuser;
575
+ return (
576
+ <div className="flex gap-1">
577
+ <CanDisable allowed={canUpdate} disabledTooltip={isSuperuser ? 'Superusers cannot be modified' : 'You do not have access'}>
578
+ <Button
579
+ variant="ghost"
580
+ size="icon"
581
+ onClick={() => handleOpenEditUser(user)}
582
+ aria-label="Edit"
583
+ disabled={editDisabled}
584
+ title={isSuperuser ? 'Superusers cannot be modified' : undefined}
585
+ >
586
+ <Pencil className="h-4 w-4" />
587
+ </Button>
588
+ </CanDisable>
589
+ <CanDisable allowed={canUpdate} disabledTooltip={isSuperuser ? 'Superusers cannot have password changed' : 'You do not have access'}>
590
+ <Button
591
+ variant="ghost"
592
+ size="icon"
593
+ onClick={() => { setSelectedUser(user); setChangePasswordDialogOpen(true); }}
594
+ aria-label="Change password"
595
+ disabled={passwordDisabled}
596
+ title={isSuperuser ? 'Superusers cannot have password changed' : 'Change password'}
597
+ >
598
+ <Key className="h-4 w-4" />
599
+ </Button>
600
+ </CanDisable>
601
+ <CanDisable allowed={canDelete} disabledTooltip={isSuperuser ? 'Superusers cannot be deleted' : 'You do not have access'}>
602
+ <Button
603
+ variant="ghost"
604
+ size="icon"
605
+ onClick={() => { setSelectedUser(user); setDeleteUserDialogOpen(true); }}
606
+ aria-label="Delete"
607
+ disabled={deleteDisabled}
608
+ title={isSuperuser ? 'Superusers cannot be deleted' : undefined}
609
+ >
610
+ <Trash2 className="h-4 w-4" />
611
+ </Button>
612
+ </CanDisable>
613
+ </div>
614
+ );
615
+ },
616
+ },
617
+ ];
618
+
619
+ const roleColumns: DataTableColumn<Role>[] = [
620
+ { key: 'name', header: 'Role', cell: (role) => <div><p>{role.name}</p><p className="text-xs text-muted-foreground">{role.description}</p></div> },
621
+ { key: 'permissions', header: 'Permissions', cell: (role) => `${role.permissionCount || 0} permissions` },
622
+ { key: 'users', header: 'Users', cell: (role) => `${role.userCount || 0} users` },
623
+ {
624
+ key: 'actions',
625
+ header: '',
626
+ cell: (role) => (
627
+ <div className="flex gap-1">
628
+ <CanDisable allowed={can('update', 'roles')}>
629
+ <Button variant="ghost" size="icon" onClick={() => { setSelectedRoleForAssign(role); setAssignUsersDialogOpen(true); }} aria-label="Assign users" title="Assign or reassign users to this role"><UserPlus className="h-4 w-4" /></Button>
630
+ </CanDisable>
631
+ <CanDisable allowed={can('update', 'roles')}>
632
+ <Button variant="ghost" size="icon" onClick={() => { setSelectedRole(role); setRoleFormOpen(true); }} aria-label="Edit"><Pencil className="h-4 w-4" /></Button>
633
+ </CanDisable>
634
+ <CanDisable allowed={can('delete', 'roles')}>
635
+ <Button variant="ghost" size="icon" onClick={() => { setSelectedRole(role); setDeleteRoleDialogOpen(true); }} aria-label="Delete"><Trash2 className="h-4 w-4" /></Button>
636
+ </CanDisable>
637
+ </div>
638
+ ),
639
+ },
640
+ ];
641
+
642
+ return (
643
+ <div className="space-y-6">
644
+ <div className="page-header">
645
+ <div className="page-header-left">
646
+ <h1 className="text-2xl font-bold text-foreground">User Management</h1>
647
+ <p className="text-sm text-muted-foreground mt-1">Manage users, roles, and permissions</p>
648
+ </div>
649
+ <div className="page-header-right">
650
+ <Button variant="outline" onClick={refetch} disabled={loading}>Refresh</Button>
651
+ {activeTab === 'users' && (
652
+ <CanDisable allowed={can('create', 'users')}>
653
+ <Button onClick={() => { setSelectedUser(null); setUserFormOpen(true); }}>Add User</Button>
654
+ </CanDisable>
655
+ )}
656
+ {activeTab === 'roles' && (
657
+ <CanDisable allowed={can('create', 'roles')}>
658
+ <Button onClick={() => { setSelectedRole(null); setRoleFormOpen(true); }}>Add Role</Button>
659
+ </CanDisable>
660
+ )}
661
+ {activeTab === 'permissions' && (
662
+ <CanDisable allowed={can('create', 'permissions')}>
663
+ <Button onClick={() => { setSelectedPermission(null); setPermissionFormOpen(true); }}>Add Permission</Button>
664
+ </CanDisable>
665
+ )}
666
+ </div>
667
+ </div>
668
+
669
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
670
+ <TabsList>
671
+ <CanDisable allowed={can('read', 'users')}>
672
+ <TabsTrigger value="users" badge={userCounts.all}>
673
+ <Users className="w-4 h-4 shrink-0" />
674
+ Users
675
+ </TabsTrigger>
676
+ </CanDisable>
677
+ <CanDisable allowed={can('read', 'roles')}>
678
+ <TabsTrigger value="roles" badge={rolesPagination?.totalItems ?? 0}>
679
+ <Shield className="w-4 h-4 shrink-0" />
680
+ Roles
681
+ </TabsTrigger>
682
+ </CanDisable>
683
+ <CanDisable allowed={can('read', 'permissions')}>
684
+ <TabsTrigger value="permissions" badge={permissionsPagination?.totalItems ?? 0}>
685
+ <KeyRound className="w-4 h-4 shrink-0" />
686
+ Permissions
687
+ </TabsTrigger>
688
+ </CanDisable>
689
+ </TabsList>
690
+ </Tabs>
691
+
692
+ {activeTab === 'users' && (
693
+ <div className="space-y-4">
694
+ {/* Top bar: Search full width, Export on the right */}
695
+ <div className="flex flex-col sm:flex-row gap-4 sm:items-center">
696
+ <div className="flex-1 w-full min-w-0">
697
+ <SearchInput
698
+ placeholder="Search users..."
699
+ value={searchQuery}
700
+ onChange={(e) => setSearchQuery(e.target.value)}
701
+ icon={<Search className="h-4 w-4" />}
702
+ />
703
+ </div>
704
+ <div className="flex items-center gap-2 flex-shrink-0">
705
+ <Button variant="outline" size="sm" onClick={handleExportUsers} disabled={exporting} className="gap-2">
706
+ <Download className="h-4 w-4" /> {exporting ? 'Exporting...' : 'Export'}
707
+ </Button>
708
+ </div>
709
+ </div>
710
+
711
+ {/* Filter tabs */}
712
+ <div className="flex flex-wrap gap-2">
713
+ <FilterChip active={activeFilter === 'all'} count={userCounts.all} onClick={() => setActiveFilter('all')}>
714
+ All Users
715
+ </FilterChip>
716
+ <FilterChip active={activeFilter === 'active'} count={userCounts.active} onClick={() => setActiveFilter('active')}>
717
+ Active
718
+ </FilterChip>
719
+ <FilterChip active={activeFilter === 'inactive'} count={userCounts.inactive} onClick={() => setActiveFilter('inactive')}>
720
+ Inactive
721
+ </FilterChip>
722
+ </div>
723
+
724
+ {/* Table and pagination */}
725
+ {loading && <p>Loading...</p>}
726
+ {error && <p className="text-destructive">Error: {error.message}</p>}
727
+ {!loading && !error && (
728
+ <>
729
+ <DataTable
730
+ columns={userColumns}
731
+ data={filteredUsers}
732
+ keyExtractor={(item) => item.id}
733
+ selectable
734
+ selectedRows={selectedRows}
735
+ onSelectRow={handleSelectRow}
736
+ onSelectAll={handleSelectAllUsers}
737
+ />
738
+ <Pagination
739
+ currentPage={usersPagination?.page ?? 1}
740
+ totalPages={usersPagination?.totalPages ?? 1}
741
+ totalItems={usersPagination?.totalItems ?? 0}
742
+ perPage={usersPerPage}
743
+ onPageChange={setUsersPage}
744
+ onPerPageChange={(size) => { setUsersPerPage(size); setUsersPage(1); }}
745
+ showPageSizeSelector
746
+ itemsLabel="users"
747
+ />
748
+ </>
749
+ )}
750
+ </div>
751
+ )}
752
+
753
+ {activeTab === 'roles' && (
754
+ <div className="space-y-4">
755
+ <div className="flex flex-col sm:flex-row gap-4 sm:items-center">
756
+ <div className="flex-1 w-full min-w-0">
757
+ <SearchInput
758
+ placeholder="Search roles..."
759
+ value={searchQuery}
760
+ onChange={(e) => setSearchQuery(e.target.value)}
761
+ icon={<Search className="h-4 w-4" />}
762
+ />
763
+ </div>
764
+ <div className="flex items-center gap-2 flex-shrink-0">
765
+ <Button variant="outline" size="sm" onClick={() => {
766
+ const headers = ['Name', 'Description', 'Permissions', 'Users'];
767
+ const rows = filteredRoles.map((r) => [r.name, r.description || '', String(r.permissionCount || 0), String(r.userCount || 0)]);
768
+ const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(','))].join('\n');
769
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
770
+ const link = document.createElement('a');
771
+ link.href = URL.createObjectURL(blob);
772
+ link.download = `roles-export-${new Date().toISOString().slice(0, 10)}.csv`;
773
+ link.click();
774
+ URL.revokeObjectURL(link.href);
775
+ }} className="gap-2">
776
+ <Download className="h-4 w-4" /> Export
777
+ </Button>
778
+ </div>
779
+ </div>
780
+ <div className="flex flex-wrap gap-2">
781
+ <FilterChip active count={roleStats.total} onClick={() => {}}>All Roles</FilterChip>
782
+ </div>
783
+ {loading && <p>Loading...</p>}
784
+ {error && <p className="text-destructive">Error: {error.message}</p>}
785
+ {!loading && !error && (
786
+ <>
787
+ <DataTable columns={roleColumns} data={filteredRoles} keyExtractor={(item) => item.id} variant="card" />
788
+ <Pagination
789
+ currentPage={rolesPagination?.page ?? 1}
790
+ totalPages={rolesPagination?.totalPages ?? 1}
791
+ totalItems={rolesPagination?.totalItems ?? 0}
792
+ perPage={rolesPerPage}
793
+ onPageChange={setRolesPage}
794
+ onPerPageChange={(size) => { setRolesPerPage(size); setRolesPage(1); }}
795
+ showPageSizeSelector
796
+ itemsLabel="roles"
797
+ />
798
+ </>
799
+ )}
800
+ </div>
801
+ )}
802
+
803
+ {activeTab === 'permissions' && (
804
+ <div className="space-y-4">
805
+ <div className="flex flex-col sm:flex-row gap-4 sm:items-center">
806
+ <div className="flex-1 w-full min-w-0">
807
+ <SearchInput
808
+ placeholder="Search permissions..."
809
+ value={searchQuery}
810
+ onChange={(e) => setSearchQuery(e.target.value)}
811
+ icon={<Search className="h-4 w-4" />}
812
+ />
813
+ </div>
814
+ <div className="flex items-center gap-2 flex-shrink-0">
815
+ <Button variant="outline" size="sm" onClick={() => {
816
+ const headers = ['Name', 'Collection', 'Action', 'Description'];
817
+ const rows = filteredPermissions.map((p) => [p.name, p.collection, p.action, p.description || '']);
818
+ const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(','))].join('\n');
819
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
820
+ const link = document.createElement('a');
821
+ link.href = URL.createObjectURL(blob);
822
+ link.download = `permissions-export-${new Date().toISOString().slice(0, 10)}.csv`;
823
+ link.click();
824
+ URL.revokeObjectURL(link.href);
825
+ }} className="gap-2">
826
+ <Download className="h-4 w-4" /> Export
827
+ </Button>
828
+ </div>
829
+ </div>
830
+ <div className="flex flex-wrap gap-2">
831
+ <FilterChip active={permissionsCollectionFilter === 'all'} count={permissionsPagination?.totalItems ?? 0} onClick={() => setPermissionsCollectionFilter('all')}>
832
+ All Permissions
833
+ </FilterChip>
834
+ {permissionCollections.slice(0, 5).map((c) => (
835
+ <FilterChip
836
+ key={c}
837
+ active={permissionsCollectionFilter === c}
838
+ onClick={() => setPermissionsCollectionFilter(c)}
839
+ >
840
+ {c}
841
+ </FilterChip>
842
+ ))}
843
+ </div>
844
+ {loading && <p>Loading...</p>}
845
+ {error && <p className="text-destructive">Error: {error.message}</p>}
846
+ {!loading && !error && (
847
+ <>
848
+ <GroupedTable<Permission>
849
+ data={filteredPermissions}
850
+ groupBy={(perm) => perm.collection || '_unknown'}
851
+ keyExtractor={(perm) => perm.id}
852
+ columns={[
853
+ { key: 'name', header: 'Permission', cell: (perm) => <span className="text-sm font-mono text-foreground">{perm.name}</span> },
854
+ { key: 'action', header: 'Action', width: 'w-32', cell: (perm) => <Badge variant={getActionBadgeVariant(perm.action)}>{perm.action}</Badge> },
855
+ { key: 'description', header: 'Description', cell: (perm) => <span className="text-sm text-muted-foreground">{perm.description ?? '—'}</span> },
856
+ { key: 'systemDefault', header: 'System default', width: 'w-32', cell: (perm) => SYSTEM_DEFAULT_PERMISSIONS.includes(perm.name) ? <Badge variant="default">Yes</Badge> : <Badge variant="outline">No</Badge> },
857
+ {
858
+ key: 'actions',
859
+ header: '',
860
+ width: 'w-32',
861
+ cell: (perm) => {
862
+ const isSystemDefault = SYSTEM_DEFAULT_PERMISSIONS.includes(perm.name);
863
+ const canUpdate = can('update', 'permissions');
864
+ const canDelete = can('delete', 'permissions');
865
+ const editDisabled = isSystemDefault;
866
+ const deleteDisabled = isSystemDefault;
867
+ return (
868
+ <div className="flex gap-1">
869
+ <CanDisable allowed={canUpdate} disabledTooltip={isSystemDefault ? 'System default permissions cannot be edited' : 'You do not have access'}>
870
+ <Button
871
+ variant="ghost"
872
+ size="icon"
873
+ onClick={() => { setSelectedPermission(perm); setPermissionFormOpen(true); }}
874
+ aria-label="Edit"
875
+ disabled={editDisabled}
876
+ title={isSystemDefault ? 'System default permissions cannot be edited' : undefined}
877
+ >
878
+ <Pencil className="h-4 w-4" />
879
+ </Button>
880
+ </CanDisable>
881
+ <CanDisable allowed={canDelete} disabledTooltip={isSystemDefault ? 'System default permissions cannot be deleted' : 'You do not have access'}>
882
+ <Button
883
+ variant="ghost"
884
+ size="icon"
885
+ onClick={() => { setSelectedPermission(perm); setDeletePermissionDialogOpen(true); }}
886
+ aria-label="Delete"
887
+ disabled={deleteDisabled}
888
+ title={isSystemDefault ? 'System default permissions cannot be deleted' : undefined}
889
+ >
890
+ <Trash2 className="h-4 w-4" />
891
+ </Button>
892
+ </CanDisable>
893
+ </div>
894
+ );
895
+ },
896
+ },
897
+ ]}
898
+ emptyMessage="No permissions found"
899
+ groupLabel={(key, count) => `${key} (${count} permission${count !== 1 ? 's' : ''})`}
900
+ />
901
+ <Pagination
902
+ currentPage={permissionsPagination?.page ?? 1}
903
+ totalPages={permissionsPagination?.totalPages ?? 1}
904
+ totalItems={permissionsPagination?.totalItems ?? 0}
905
+ perPage={permissionsPerPage}
906
+ onPageChange={setPermissionsPage}
907
+ onPerPageChange={(size) => { setPermissionsPerPage(size); setPermissionsPage(1); }}
908
+ showPageSizeSelector
909
+ itemsLabel="permissions"
910
+ />
911
+ </>
912
+ )}
913
+ </div>
914
+ )}
915
+
916
+ {/* Dialogs */}
917
+ <DeleteConfirmationDialog
918
+ isOpen={deleteUserDialogOpen}
919
+ onClose={() => { setDeleteUserDialogOpen(false); setSelectedUser(null); }}
920
+ onConfirm={handleDeleteUser}
921
+ title="Delete User"
922
+ itemName={selectedUser?.name || selectedUser?.email}
923
+ loading={userFormLoading}
924
+ />
925
+ <ChangePasswordDialog
926
+ isOpen={changePasswordDialogOpen}
927
+ onClose={() => { setChangePasswordDialogOpen(false); setSelectedUser(null); }}
928
+ onSubmit={handleChangePassword}
929
+ userName={selectedUser?.name || selectedUser?.email}
930
+ loading={changePasswordLoading}
931
+ />
932
+ <UserFormDialog
933
+ isOpen={userFormOpen}
934
+ onClose={() => setUserFormOpen(false)}
935
+ onSubmit={selectedUser ? handleUpdateUser : handleCreateUser}
936
+ user={selectedUser ? {
937
+ id: selectedUser.id,
938
+ name: selectedUser.name,
939
+ email: selectedUser.email,
940
+ roleId: selectedUser.roleId,
941
+ status: selectedUser.status,
942
+ } : null}
943
+ roles={roles.map(r => ({id: r.id, name: r.name}))}
944
+ loading={userFormLoading}
945
+ />
946
+ <RoleFormDialog
947
+ isOpen={roleFormOpen}
948
+ onClose={() => setRoleFormOpen(false)}
949
+ onSubmit={selectedRole ? handleUpdateRole : handleCreateRole}
950
+ role={selectedRole ? {
951
+ id: selectedRole.id,
952
+ name: selectedRole.name,
953
+ description: selectedRole.description,
954
+ // DB stores permission names; map to IDs for form (supports legacy IDs for backward compat)
955
+ permissions: (() => {
956
+ const perms = selectedRole.permissions;
957
+ if (!Array.isArray(perms)) return [];
958
+ const permValues = perms.map((p) => (typeof p === 'string' ? p : (p as Permission).id));
959
+ return allPermissionsForRoleForm
960
+ .filter((p) => permValues.includes(p.name) || permValues.includes(p.id))
961
+ .map((p) => p.id);
962
+ })(),
963
+ } : null}
964
+ permissions={allPermissionsForRoleForm}
965
+ mandatoryPermissionNames={SYSTEM_MANDATORY_PERMISSIONS_FOR_ROLES}
966
+ loading={roleFormLoading}
967
+ permissionsLoading={roleFormOpen ? roleFormPermissionsLoading : false}
968
+ />
969
+ <PermissionFormDialog
970
+ isOpen={permissionFormOpen}
971
+ onClose={() => setPermissionFormOpen(false)}
972
+ onSubmit={selectedPermission ? handleUpdatePermission : handleCreatePermission}
973
+ permission={selectedPermission}
974
+ loading={permissionFormLoading}
975
+ existingCollections={permissionCollections}
976
+ />
977
+ <AssignUsersToRoleDialog
978
+ isOpen={assignUsersDialogOpen}
979
+ onClose={() => { setAssignUsersDialogOpen(false); setSelectedRoleForAssign(null); }}
980
+ role={selectedRoleForAssign ? { id: selectedRoleForAssign.id, name: selectedRoleForAssign.name } : null}
981
+ users={allUsersForAssignDialog.map((u) => ({
982
+ id: u.id,
983
+ name: u.name || u.email?.split('@')[0] || '—',
984
+ email: u.email ?? '—',
985
+ roleId: u.role,
986
+ roleName: u.roleName,
987
+ is_superuser: u.is_superuser,
988
+ }))}
989
+ onAssign={handleAssignUserToRole}
990
+ usersLoading={assignDialogUsersLoading}
991
+ />
992
+ <DeleteConfirmationDialog
993
+ isOpen={deleteRoleDialogOpen}
994
+ onClose={() => setDeleteRoleDialogOpen(false)}
995
+ onConfirm={handleDeleteRole}
996
+ title="Delete Role"
997
+ itemName={selectedRole?.name}
998
+ loading={roleFormLoading}
999
+ />
1000
+ <DeleteConfirmationDialog
1001
+ isOpen={deletePermissionDialogOpen}
1002
+ onClose={() => setDeletePermissionDialogOpen(false)}
1003
+ onConfirm={handleDeletePermission}
1004
+ title="Delete Permission"
1005
+ itemName={selectedPermission?.name}
1006
+ loading={permissionFormLoading}
1007
+ />
1008
+ </div>
1009
+ );
1010
+ }