@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.
- package/dist/emulator/lego-box-shell-1.0.6.tgz +0 -0
- package/package.json +2 -3
- package/src/auth/auth-store.ts +33 -0
- package/src/auth/auth.ts +176 -0
- package/src/components/ProtectedPage.tsx +48 -0
- package/src/config/env.node.ts +38 -0
- package/src/config/env.ts +103 -0
- package/src/context/AbilityContext.tsx +213 -0
- package/src/context/PiralInstanceContext.tsx +17 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/useAuditLogs.ts +190 -0
- package/src/hooks/useDebounce.ts +34 -0
- package/src/hooks/usePermissionGuard.tsx +39 -0
- package/src/hooks/usePermissions.ts +190 -0
- package/src/hooks/useRoles.ts +233 -0
- package/src/hooks/useTickets.ts +214 -0
- package/src/hooks/useUserLogins.ts +39 -0
- package/src/hooks/useUsers.ts +252 -0
- package/src/index.html +16 -0
- package/src/index.tsx +296 -0
- package/src/layout.tsx +246 -0
- package/src/migrations/config.ts +62 -0
- package/src/migrations/dev-migrations.ts +75 -0
- package/src/migrations/index.ts +13 -0
- package/src/migrations/run-migrations.ts +187 -0
- package/src/migrations/runner.ts +925 -0
- package/src/migrations/types.ts +207 -0
- package/src/migrations/utils.ts +264 -0
- package/src/pages/AuditLogsPage.tsx +378 -0
- package/src/pages/ContactSupportPage.tsx +610 -0
- package/src/pages/LandingPage.tsx +221 -0
- package/src/pages/LoginPage.tsx +217 -0
- package/src/pages/MigrationsPage.tsx +1364 -0
- package/src/pages/ProfilePage.tsx +335 -0
- package/src/pages/SettingsPage.tsx +101 -0
- package/src/pages/SystemHealthCheckPage.tsx +144 -0
- package/src/pages/UserManagementPage.tsx +1010 -0
- package/src/piral/api.ts +39 -0
- package/src/piral/auth-casl.ts +56 -0
- package/src/piral/menu.ts +102 -0
- package/src/piral/piral.json +4 -0
- package/src/services/telemetry.ts +37 -0
- package/src/styles/globals.css +1351 -0
- package/src/utils/auditLogger.ts +68 -0
- 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
|
+
}
|