kempo 4.2.10 → 4.2.12

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.
@@ -4,14 +4,11 @@
4
4
  "/kempo-ui/icons/**": "../node_modules/kempo-ui/icons/**",
5
5
  "/kempo-ui/**": "../node_modules/kempo-ui/dist/**",
6
6
  "/monaco-editor/**": "../node_modules/monaco-editor/min/**",
7
- "/kempo/**": "../node_modules/kempo/dist/kempo/**",
8
- "/admin": "../node_modules/kempo/dist/admin/",
9
- "/admin/**": "../node_modules/kempo/dist/admin/**"
7
+ "/kempo/**": "../node_modules/kempo/dist/kempo/**"
10
8
  },
11
9
  "middleware": {
12
10
  "custom": [
13
- "../middleware/kempo-auth.js",
14
- "../middleware/extension-scope-router.js"
11
+ "../node_modules/kempo/middleware/kempo.js"
15
12
  ]
16
13
  },
17
14
  "templating": {
package/bin/cli.js CHANGED
@@ -117,13 +117,13 @@ if(existsSync(join(projectDir, 'public'))){
117
117
  const { confirm } = await inquirer.prompt([{
118
118
  type: 'confirm',
119
119
  name: 'confirm',
120
- message: 'This will delete public/, server/, templates/, extensions/, .env, drizzle.config.js, and docker-compose.yml. Are you sure?',
120
+ message: 'This will delete public/, server/, templates/, .env, drizzle.config.js, and docker-compose.yml. Are you sure?',
121
121
  default: false
122
122
  }]);
123
123
 
124
124
  if(!confirm) process.exit(0);
125
125
 
126
- for(const item of ['public', 'server', 'templates', 'extensions', '.env', 'docker-compose.yml', 'drizzle.config.js']){
126
+ for(const item of ['public', 'server', 'templates', '.env', 'docker-compose.yml', 'drizzle.config.js']){
127
127
  const p = join(projectDir, item);
128
128
  if(existsSync(p)) rmSync(p, { recursive: true, force: true });
129
129
  }
@@ -180,17 +180,44 @@ if(existingContainers.length > 0 && !useDefaults){
180
180
  }
181
181
  }
182
182
 
183
+ let dbName = 'kempo';
184
+ let dbPassword = generatePassword();
185
+
183
186
  if(containerMode === 'new' && !useDefaults){
184
187
  const { name } = await inquirer.prompt([{
185
188
  type: 'input',
186
189
  name: 'name',
187
- message: 'Container name:',
188
- default: 'kempo-postgres'
190
+ message: 'Database name (used for container and database):',
191
+ default: projectName
189
192
  }]);
193
+ dbName = name;
190
194
  containerName = name;
195
+
196
+ const { passwordChoice } = await inquirer.prompt([{
197
+ type: 'list',
198
+ name: 'passwordChoice',
199
+ message: 'Database password:',
200
+ choices: [
201
+ { name: 'Generate a random password', value: 'generate' },
202
+ { name: 'Enter a password manually', value: 'manual' }
203
+ ]
204
+ }]);
205
+
206
+ if(passwordChoice === 'manual'){
207
+ const { manualPassword } = await inquirer.prompt([{
208
+ type: 'password',
209
+ name: 'manualPassword',
210
+ message: 'Enter database password:',
211
+ mask: '*'
212
+ }]);
213
+ dbPassword = manualPassword;
214
+ }
215
+ } else if(containerMode === 'new' && useDefaults){
216
+ dbName = projectName;
217
+ containerName = projectName;
191
218
  }
192
219
 
193
- const databaseUrl = customDatabaseUrl || `postgresql://kempo:kempo_dev_password@localhost:5433/kempo`;
220
+ const databaseUrl = customDatabaseUrl || `postgresql://kempo:${dbPassword}@localhost:5433/${dbName}`;
194
221
 
195
222
  const generatedAdminPassword = generatePassword();
196
223
  let adminName, adminEmail, adminPassword;
@@ -312,12 +339,6 @@ if(!existsSync(templatesDir) && existsSync(templatesSource)){
312
339
  console.log('Scaffolded templates/');
313
340
  }
314
341
 
315
- const extensionsDir = join(projectDir, 'extensions');
316
- if(!existsSync(extensionsDir)){
317
- mkdirSync(extensionsDir);
318
- console.log('Created extensions/');
319
- }
320
-
321
342
  const drizzleConfigPath = join(projectDir, 'drizzle.config.js');
322
343
  const scaffoldDrizzle = join(moduleRoot, 'app-drizzle.config.js');
323
344
  if(!existsSync(drizzleConfigPath) && existsSync(scaffoldDrizzle)){
@@ -340,8 +361,8 @@ if(containerMode === 'new'){
340
361
  restart: unless-stopped
341
362
  environment:
342
363
  POSTGRES_USER: kempo
343
- POSTGRES_PASSWORD: kempo_dev_password
344
- POSTGRES_DB: kempo
364
+ POSTGRES_PASSWORD: ${dbPassword}
365
+ POSTGRES_DB: ${dbName}
345
366
  ports:
346
367
  - "5433:5432"
347
368
  volumes:
@@ -352,7 +373,7 @@ volumes:
352
373
  );
353
374
  console.log('Created docker-compose.yml');
354
375
  }
355
- }
376
+
356
377
 
357
378
  writeFileSync(join(projectDir, '.env'),
358
379
  `# Database
@@ -0,0 +1,518 @@
1
+ <!--
2
+ owner: system
3
+ locked: true
4
+ name:
5
+ author:
6
+ created:
7
+ updated:
8
+ -->
9
+ <!DOCTYPE html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
+ <link rel="icon" type="image/png" sizes="48x48" href="/media/icon48.png">
15
+ <link rel="manifest" href="/manifest.json">
16
+ <title>Group - Admin</title>
17
+ <link rel="stylesheet" href="/kempo-css/kempo.min.css">
18
+ <script src="/admin/init.js"></script>
19
+
20
+ </head>
21
+ <body>
22
+ <!--
23
+ locked: true
24
+ -->
25
+
26
+ <k-aside
27
+ main="push"
28
+ state="expanded"
29
+ style="--aside_padding: 0;"
30
+ persistent-id="admin-nav"
31
+ >
32
+ <k-aside-toggle>
33
+ <h3 class="m0">Kempo Admin</h3>
34
+ </k-aside-toggle>
35
+ <k-aside-item
36
+ icon="preview"
37
+ href="/"
38
+ no-expand
39
+ >Site</k-aside-item>
40
+ <k-aside-item
41
+ icon="cards"
42
+ href="/admin"
43
+ no-expand
44
+ >Dashboard</k-aside-item>
45
+ <k-aside-menu
46
+ icon="manage-accounts"
47
+ label="Accounts"
48
+ >
49
+ <k-aside-item
50
+ icon="person"
51
+ href="/admin/accounts/users"
52
+ no-expand
53
+ >Users</k-aside-item>
54
+ <k-aside-item
55
+ icon="group"
56
+ href="/admin/accounts/groups"
57
+ no-expand
58
+ >Groups</k-aside-item>
59
+ <k-aside-item
60
+ icon="lock"
61
+ href="/admin/accounts/permissions"
62
+ no-expand
63
+ >Permissions</k-aside-item>
64
+ </k-aside-menu>
65
+ <k-aside-menu
66
+ icon="cards_stack"
67
+ label="Content"
68
+ >
69
+ <k-aside-item
70
+ icon="browse"
71
+ href="/admin/content/templates"
72
+ >Templates</k-aside-item>
73
+ <k-aside-item
74
+ icon="docs"
75
+ href="/admin/content/pages"
76
+ >Pages</k-aside-item>
77
+ <k-aside-item
78
+ icon="content"
79
+ href="/admin/content/fragments"
80
+ >Fragments</k-aside-item>
81
+ <k-aside-item
82
+ icon="globe"
83
+ href="/admin/content/globals"
84
+ >Global Content</k-aside-item>
85
+ </k-aside-menu>
86
+ <k-aside-item
87
+ icon="settings"
88
+ href="/admin/settings"
89
+ no-expand
90
+ >Settings</k-aside-item>
91
+ <k-aside-item
92
+ icon="extension"
93
+ href="/admin/extensions"
94
+ no-expand
95
+ >Extensions</k-aside-item>
96
+ <hr>
97
+ <k-aside-spacer></k-aside-spacer>
98
+ <k-theme-switcher
99
+ labels
100
+ style="--padding:var(--spacer_h);margin:var(--spacer_h) auto"
101
+ ></k-theme-switcher>
102
+ </k-aside>
103
+ <script type="module">
104
+ import '/kempo-ui/components/ThemeSwitcher.js';
105
+ const items = document.querySelectorAll('k-aside-item');
106
+ const currentPath = window.location.pathname;
107
+
108
+ items.forEach(item => {
109
+ const href = item.getAttribute('href');
110
+ if (!href) return;
111
+ const isExact = href === '/admin' || href === '/';
112
+ const match = isExact
113
+ ? (currentPath === href || currentPath === href + '/')
114
+ : (currentPath === href || currentPath.startsWith(href + '/'));
115
+ if (match) {
116
+ item.setAttribute('active', '');
117
+ const menu = item.closest('k-aside-menu');
118
+ if (menu) menu.setAttribute('open', '');
119
+ }
120
+ });
121
+ </script>
122
+
123
+ <k-main>
124
+
125
+ <script type="module" src="/kempo-ui/components/Icon.js"></script>
126
+ <script type="module" src="/kempo-ui/components/Table.js"></script>
127
+ <script type="module" src="/kempo-ui/components/tableControls/FirstPage.js"></script>
128
+ <script type="module" src="/kempo-ui/components/tableControls/PrevPage.js"></script>
129
+ <script type="module" src="/kempo-ui/components/tableControls/NextPage.js"></script>
130
+ <script type="module" src="/kempo-ui/components/tableControls/LastPage.js"></script>
131
+ <script type="module" src="/kempo-ui/components/tableControls/PageSelect.js"></script>
132
+ <script type="module" src="/kempo-ui/components/tableControls/PageSize.js"></script>
133
+ <script type="module" src="/kempo-ui/components/tableControls/DeleteRecord.js"></script>
134
+ <script type="module" src="/kempo-ui/components/tableControls/DeleteSelected.js"></script>
135
+ <script type="module" src="/admin/components/tableControls/AddMember.js"></script>
136
+ <script type="module" src="/admin/components/tableControls/AddPermission.js"></script>
137
+ <script type="module" src="/admin/components/TableRowLink.js"></script>
138
+
139
+ <div class="mb d-f" style="align-items: flex-start; justify-content: space-between;">
140
+ <div>
141
+ <a href="/admin/accounts/groups" class="no-link">
142
+ <button class="mbh"><k-icon name="arrow" direction="left"></k-icon> Groups</button>
143
+ </a>
144
+ <h1 class="mb0" id="pageTitle">Group</h1>
145
+ </div>
146
+ <button id="saveBtn" class="primary" disabled style="display:none;"><k-icon name="save"></k-icon> Save</button>
147
+ </div>
148
+
149
+ <div id="loading">Loading group...</div>
150
+ <div id="notFound" style="display: none;">
151
+ <p>Group not found.</p>
152
+ <a href="/admin/accounts/groups">Back to Groups</a>
153
+ </div>
154
+ <div id="content" style="display: none;">
155
+
156
+ <!-- Group Information -->
157
+ <div class="card">
158
+ <h2>Information</h2>
159
+ <div class="d-f mb">
160
+ <label style="min-width: 120px;"><strong>Owner</strong></label>
161
+ <span id="groupOwner"></span>
162
+ </div>
163
+ <div class="d-f mb">
164
+ <label for="groupNameInput" style="min-width: 120px;"><strong>Name</strong></label>
165
+ <input type="text" id="groupNameInput" style="max-width: 400px;">
166
+ </div>
167
+ <div class="d-f mb">
168
+ <label for="groupDescription" style="min-width: 120px;"><strong>Description</strong></label>
169
+ <input type="text" id="groupDescription" style="max-width: 400px;">
170
+ </div>
171
+ <div class="d-f mb">
172
+ <label style="min-width: 120px;"><strong>Created</strong></label>
173
+ <span id="groupCreatedAt"></span>
174
+ </div>
175
+ <div class="d-f mb">
176
+ <label style="min-width: 120px;"><strong>Updated</strong></label>
177
+ <span id="groupUpdatedAt"></span>
178
+ </div>
179
+ </div>
180
+
181
+ <!-- Members -->
182
+ <div class="card">
183
+ <h2>Members</h2>
184
+ <p id="usersMembersMsg" style="display:none">All users are automatically members of this group.</p>
185
+ <k-table id="membersTable" enable-pages page-size="10" page-size-options="[10,25,50]" placeholder="This group has no members.">
186
+ <admin-table-row-link slot="before" url-pattern="/admin/accounts/users/{id}" field-name="id"></admin-table-row-link>
187
+ <k-tc-delete-selected id="tcDeleteSelected" slot="top" style="display:none;"></k-tc-delete-selected>
188
+ <admin-add-member id="tcAddMember" slot="top" style="display:none;"></admin-add-member>
189
+ <div class="d-f full" style="align-items: center;">
190
+ <div>
191
+ <k-tc-first-page></k-tc-first-page>
192
+ <k-tc-prev-page></k-tc-prev-page>
193
+ <k-tc-next-page></k-tc-next-page>
194
+ <k-tc-last-page></k-tc-last-page>
195
+ </div>
196
+ <div class="flex"></div>
197
+ <div><k-tc-page-select></k-tc-page-select></div>
198
+ <div class="flex"></div>
199
+ <div><k-tc-page-size></k-tc-page-size></div>
200
+ </div>
201
+ </k-table>
202
+ </div>
203
+
204
+ <!-- Permissions -->
205
+ <div class="card">
206
+ <h2>Permissions</h2>
207
+ <p id="adminPermsMsg" style="display:none">Members of this group automatically have all permissions.</p>
208
+ <k-table id="permissionsTable" enable-pages page-size="10" page-size-options="[10,25,50]" placeholder="No permissions assigned to this group.">
209
+ <k-tc-delete-selected id="tcDeleteSelectedPerm" slot="top" style="display:none;"></k-tc-delete-selected>
210
+ <admin-add-permission id="tcAddPermission" slot="top" style="display:none;"></admin-add-permission>
211
+ <div class="d-f full" style="align-items: center;">
212
+ <div>
213
+ <k-tc-first-page></k-tc-first-page>
214
+ <k-tc-prev-page></k-tc-prev-page>
215
+ <k-tc-next-page></k-tc-next-page>
216
+ <k-tc-last-page></k-tc-last-page>
217
+ </div>
218
+ <div class="flex"></div>
219
+ <div><k-tc-page-select></k-tc-page-select></div>
220
+ <div class="flex"></div>
221
+ <div><k-tc-page-size></k-tc-page-size></div>
222
+ </div>
223
+ </k-table>
224
+ </div>
225
+
226
+ </div>
227
+
228
+ <script type="module">
229
+ import {
230
+ getGroup, updateGroup, deleteGroups, getGroupMembers,
231
+ removeMemberFromGroup, getGroupPermissions, removePermissionFromGroup,
232
+ getAllCurrentUserPermissions
233
+ } from '/kempo/sdk.js';
234
+ import Toast from '/kempo-ui/components/Toast.js';
235
+ import Dialog from '/kempo-ui/components/Dialog.js';
236
+
237
+ const groupName = decodeURIComponent(
238
+ window.location.pathname.split('/admin/accounts/groups/')[1]?.replace(/\/$/, '') || ''
239
+ );
240
+
241
+ if(!groupName){
242
+ document.getElementById('loading').style.display = 'none';
243
+ document.getElementById('notFound').style.display = '';
244
+ }
245
+
246
+ /*
247
+ Permission checks
248
+ */
249
+
250
+ const [, permResult] = await getAllCurrentUserPermissions();
251
+ const myPerms = new Set(permResult?.permissions ?? []);
252
+ const canEdit = myPerms.has('system:group:update');
253
+ const canDelete = myPerms.has('system:group:delete');
254
+ const canReadPerms = myPerms.has('system:permissions:read');
255
+
256
+ /*
257
+ Load group
258
+ */
259
+
260
+ const [groupError, groupData] = await getGroup(groupName);
261
+
262
+ document.getElementById('loading').style.display = 'none';
263
+
264
+ if(groupError){
265
+ document.getElementById('notFound').style.display = '';
266
+ } else {
267
+ document.getElementById('content').style.display = '';
268
+ const displayName = groupName.includes(':') ? groupName.split(':').slice(1).join(':') : groupName;
269
+ document.getElementById('pageTitle').textContent = `Group: ${displayName}`;
270
+ document.title = `${displayName} - Admin`;
271
+
272
+ const owner = groupName.includes(':') ? groupName.split(':')[0] : '';
273
+ const isAdmin = owner === 'admin' || !owner;
274
+
275
+ await customElements.whenDefined('k-table');
276
+
277
+ /*
278
+ Group Information
279
+ */
280
+
281
+ const isCustom = owner === 'custom';
282
+ const $saveBtn = document.getElementById('saveBtn');
283
+ const $groupOwner = document.getElementById('groupOwner');
284
+ const $groupNameInput = document.getElementById('groupNameInput');
285
+ const $groupDescription = document.getElementById('groupDescription');
286
+
287
+ $groupOwner.textContent = owner || '(none)';
288
+ $groupNameInput.value = displayName;
289
+ $groupDescription.value = groupData.description || '';
290
+ document.getElementById('groupCreatedAt').textContent = new Date(groupData.createdAt).toLocaleString();
291
+ document.getElementById('groupUpdatedAt').textContent = groupData.updatedAt ? new Date(groupData.updatedAt).toLocaleString() : 'Never';
292
+ if(!canEdit || !isCustom){
293
+ $groupNameInput.readOnly = true;
294
+ $groupDescription.readOnly = true;
295
+ } else {
296
+ $saveBtn.style.display = '';
297
+
298
+ const origName = displayName;
299
+ const origDescription = groupData.description || '';
300
+
301
+ const handleBeforeUnload = e => { e.preventDefault(); e.returnValue = ''; };
302
+
303
+ const checkDirty = () => {
304
+ const dirty = $groupNameInput.value !== origName || $groupDescription.value !== origDescription;
305
+ $saveBtn.disabled = !dirty;
306
+ if(dirty){
307
+ window.addEventListener('beforeunload', handleBeforeUnload);
308
+ } else {
309
+ window.removeEventListener('beforeunload', handleBeforeUnload);
310
+ }
311
+ };
312
+
313
+ $groupNameInput.addEventListener('input', checkDirty);
314
+ $groupDescription.addEventListener('input', checkDirty);
315
+
316
+ $saveBtn.addEventListener('click', async () => {
317
+ const newDisplayName = $groupNameInput.value.trim();
318
+ if(!newDisplayName){
319
+ Toast.error('Name is required');
320
+ return;
321
+ }
322
+ const newFullName = `${owner}:${newDisplayName}`;
323
+ const patch = { name: groupName, description: $groupDescription.value };
324
+ if(newFullName !== groupName) patch.newName = newFullName;
325
+ const [error] = await updateGroup(patch);
326
+ if(error){
327
+ Toast.error(error.msg || 'Failed to save group');
328
+ return;
329
+ }
330
+ window.removeEventListener('beforeunload', handleBeforeUnload);
331
+ Toast.success('Group saved');
332
+ if(newFullName !== groupName){
333
+ window.location.href = `/admin/accounts/groups/${encodeURIComponent(newFullName)}`;
334
+ } else {
335
+ $saveBtn.disabled = true;
336
+ document.getElementById('groupUpdatedAt').textContent = new Date().toLocaleString();
337
+ }
338
+ });
339
+ }
340
+
341
+ /*
342
+ Members
343
+ */
344
+
345
+ const $membersTable = document.getElementById('membersTable');
346
+
347
+ if(groupName === 'system:Users'){
348
+ document.getElementById('usersMembersMsg').style.display = '';
349
+ $membersTable.style.display = 'none';
350
+ } else {
351
+ const $tcDeleteSelected = document.getElementById('tcDeleteSelected');
352
+ const $tcAddMember = document.getElementById('tcAddMember');
353
+ $tcAddMember.groupName = groupName;
354
+
355
+ if(canEdit){
356
+ $membersTable.setAttribute('request-delete', '');
357
+ $membersTable.setAttribute('enable-selection', '');
358
+ $tcDeleteSelected.style.display = '';
359
+ $tcAddMember.style.display = '';
360
+
361
+ const del = document.createElement('k-tc-delete-record');
362
+ del.setAttribute('slot', 'after');
363
+ $membersTable.appendChild(del);
364
+ }
365
+
366
+ $membersTable.addEventListener('memberAdded', e => loadMembers());
367
+
368
+ $membersTable.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
369
+ const count = records.length;
370
+ const msg = count === 1
371
+ ? `<div class="p"><p>Remove "${records[0].name}" from this group?</p></div>`
372
+ : `<div class="p"><p>Remove ${count} members from this group?</p></div>`;
373
+ Dialog.confirm(msg, async (confirmed) => {
374
+ if(!confirmed){ reject(); return; }
375
+ const errors = [];
376
+ for(const record of records){
377
+ const [error] = await removeMemberFromGroup(groupName, record.id);
378
+ if(error) errors.push(error.msg);
379
+ }
380
+ if(errors.length){
381
+ reject();
382
+ Toast.error(errors[0] || 'Failed to remove member');
383
+ return;
384
+ }
385
+ approve();
386
+ Toast.success(count === 1 ? 'Member removed' : `${count} members removed`);
387
+ loadMembers();
388
+ });
389
+ });
390
+
391
+ const loadMembers = async () => {
392
+ const [error, data] = await getGroupMembers(groupName);
393
+ if(error){
394
+ Toast.error(error.msg || 'Failed to load members');
395
+ return;
396
+ }
397
+ $membersTable.setData({
398
+ records: data.members,
399
+ fields: [
400
+ { name: 'name', label: 'Name' },
401
+ { name: 'email', label: 'Email' },
402
+ {
403
+ name: 'createdAt',
404
+ label: 'Joined',
405
+ formatter: v => new Date(v).toLocaleString()
406
+ }
407
+ ]
408
+ });
409
+ $membersTable.setupFetchRecords(data.total, async (start, count) => {
410
+ const [err, d] = await getGroupMembers(groupName, { limit: count, offset: start });
411
+ if(err) return [];
412
+ return d.members;
413
+ });
414
+ };
415
+
416
+ loadMembers();
417
+ }
418
+
419
+ /*
420
+ Permissions
421
+ */
422
+
423
+ const $permissionsTable = document.getElementById('permissionsTable');
424
+
425
+ if(groupName === 'system:Administrators'){
426
+ document.getElementById('adminPermsMsg').style.display = '';
427
+ $permissionsTable.style.display = 'none';
428
+ } else if(canReadPerms){
429
+ if(canEdit){
430
+ $permissionsTable.setAttribute('request-delete', '');
431
+ $permissionsTable.setAttribute('enable-selection', '');
432
+
433
+ const $tcDeleteSelectedPerm = document.getElementById('tcDeleteSelectedPerm');
434
+ $tcDeleteSelectedPerm.style.display = '';
435
+
436
+ const $tcAddPermission = document.getElementById('tcAddPermission');
437
+ $tcAddPermission.groupName = groupName;
438
+ $tcAddPermission.style.display = '';
439
+
440
+ const delPerm = document.createElement('k-tc-delete-record');
441
+ delPerm.setAttribute('slot', 'after');
442
+ $permissionsTable.appendChild(delPerm);
443
+
444
+ $permissionsTable.addEventListener('permissionAdded', e => loadPermissions());
445
+
446
+ $permissionsTable.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
447
+ const count = records.length;
448
+ const msg = count === 1
449
+ ? `<div class="p"><p>Remove permission "${records[0].name}" from this group?</p></div>`
450
+ : `<div class="p"><p>Remove ${count} permissions from this group?</p></div>`;
451
+ Dialog.confirm(msg, async (confirmed) => {
452
+ if(!confirmed){ reject(); return; }
453
+ const errors = [];
454
+ for(const record of records){
455
+ const [error] = await removePermissionFromGroup(groupName, record.name);
456
+ if(error) errors.push(error.msg);
457
+ }
458
+ if(errors.length){
459
+ reject();
460
+ Toast.error(errors[0] || 'Failed to remove permission');
461
+ return;
462
+ }
463
+ approve();
464
+ Toast.success(count === 1 ? 'Permission removed' : `${count} permissions removed`);
465
+ loadPermissions();
466
+ });
467
+ });
468
+ }
469
+
470
+ const loadPermissions = async () => {
471
+ const [error, data] = await getGroupPermissions(groupName);
472
+ if(error){
473
+ Toast.error(error.msg || 'Failed to load permissions');
474
+ return;
475
+ }
476
+ $permissionsTable.setData({
477
+ records: data.permissions.map(name => ({ name })),
478
+ fields: [
479
+ {
480
+ name: 'owner',
481
+ label: 'Owner',
482
+ size: 120,
483
+ calculator: r => r.name.includes(':') ? r.name.split(':')[0] : ''
484
+ },
485
+ {
486
+ name: 'resource',
487
+ label: 'Resource',
488
+ size: 180,
489
+ calculator: r => {
490
+ const parts = r.name.split(':');
491
+ return parts.length >= 3 ? parts[1] : parts.length === 2 ? parts[1] : '';
492
+ }
493
+ },
494
+ {
495
+ name: 'action',
496
+ label: 'Action',
497
+ size: 140,
498
+ calculator: r => {
499
+ const parts = r.name.split(':');
500
+ return parts.length >= 3 ? parts.slice(2).join(':') : '';
501
+ }
502
+ }
503
+ ]
504
+ });
505
+ };
506
+
507
+ loadPermissions();
508
+ } else {
509
+ $permissionsTable.closest('.card').style.display = 'none';
510
+ }
511
+ }
512
+ </script>
513
+
514
+
515
+ </k-main>
516
+
517
+ </body>
518
+ </html>