kempo 4.2.9 → 4.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,541 @@
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>User - 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/Toggle.js"></script>
126
+ <script type="module" src="/kempo-ui/components/Icon.js"></script>
127
+ <script type="module" src="/kempo-ui/components/Table.js"></script>
128
+ <script type="module" src="/kempo-ui/components/tableControls/FirstPage.js"></script>
129
+ <script type="module" src="/kempo-ui/components/tableControls/PrevPage.js"></script>
130
+ <script type="module" src="/kempo-ui/components/tableControls/NextPage.js"></script>
131
+ <script type="module" src="/kempo-ui/components/tableControls/LastPage.js"></script>
132
+ <script type="module" src="/kempo-ui/components/tableControls/PageSelect.js"></script>
133
+ <script type="module" src="/kempo-ui/components/tableControls/PageSize.js"></script>
134
+ <script type="module" src="/kempo-ui/components/tableControls/DeleteRecord.js"></script>
135
+ <script type="module" src="/kempo-ui/components/tableControls/DeleteSelected.js"></script>
136
+ <script type="module" src="/admin/components/GroupDeleteSelected.js"></script>
137
+ <script type="module" src="/admin/components/AddGroupControl.js"></script>
138
+ <script type="module" src="/admin/components/ClearExpiredSessionsControl.js"></script>
139
+ <script type="module" src="/admin/components/TableRowLink.js"></script>
140
+ <div class="d-f">
141
+ <div>
142
+ <a href="/admin/accounts/users" class="no-link mb">
143
+ <button class="mbh"><k-icon name="arrow" direction="left"></k-icon> Users</button>
144
+ </a>
145
+ </div>
146
+ <div class="flex"></div>
147
+ <div>
148
+ <button id="deleteUserBtn" class="danger mb mrh" style="display:none;">Delete User</button>
149
+ <button id="saveBtn" class="primary mb" disabled style="display:none;"><k-icon name="save"></k-icon> Save</button>
150
+ </div>
151
+ </div>
152
+ <h1 id="pageTitle">User</h1>
153
+
154
+ <div id="loading">Loading user...</div>
155
+ <div id="notFound" style="display: none;">
156
+ <p>User not found.</p>
157
+ <a href="/admin/accounts/users">Back to Users</a>
158
+ </div>
159
+ <div id="content" style="display: none;">
160
+
161
+ <!-- User Information -->
162
+ <div class="card">
163
+ <h2>Information</h2>
164
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
165
+ <label for="userName" style="min-width: 120px;"><strong>Name</strong></label>
166
+ <input type="text" id="userName" style="max-width: 400px;">
167
+ </div>
168
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
169
+ <label for="userEmail" style="min-width: 120px;"><strong>Email</strong></label>
170
+ <input type="email" id="userEmail" style="max-width: 400px;">
171
+ </div>
172
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
173
+ <label style="min-width: 120px;"><strong>Verified</strong></label>
174
+ <k-toggle id="emailVerified"></k-toggle>
175
+ </div>
176
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
177
+ <label style="min-width: 120px;"><strong>Created</strong></label>
178
+ <span id="userCreatedAt"></span>
179
+ </div>
180
+ </div>
181
+
182
+ <!-- Groups -->
183
+ <div class="card">
184
+ <h2>Groups</h2>
185
+ <k-table id="groupsTable" request-delete placeholder="Not a member of any groups.">
186
+ <admin-table-row-link slot="before" url-pattern="/admin/accounts/groups/{name}" field-name="name"></admin-table-row-link>
187
+ <k-tc-delete-record slot="after"></k-tc-delete-record>
188
+ <div slot="top" id="groupsTableTop" class="d-f full" style="display:none; align-items: center;">
189
+ <div><admin-group-delete-selected></admin-group-delete-selected></div>
190
+ <div class="flex"></div>
191
+ <div><admin-add-group-control id="addGroupBtn"></admin-add-group-control></div>
192
+ </div>
193
+ </k-table>
194
+ </div>
195
+
196
+ <!-- Sessions -->
197
+ <div class="card">
198
+ <h2>Sessions</h2>
199
+ <k-table id="sessionsTable" enable-pages page-size="10" page-size-options="[10,25,50]" placeholder="No active sessions.">
200
+ <k-tc-delete-record slot="after"></k-tc-delete-record>
201
+ <div slot="top" class="d-f full" style="align-items: center;">
202
+ <div><k-tc-delete-selected></k-tc-delete-selected></div>
203
+ <div class="flex"></div>
204
+ <div><admin-clear-expired-sessions id="clearExpiredBtn" style="display:none;"></admin-clear-expired-sessions></div>
205
+ </div>
206
+ <div class="d-f full" style="align-items: center;">
207
+ <div>
208
+ <k-tc-first-page></k-tc-first-page>
209
+ <k-tc-prev-page></k-tc-prev-page>
210
+ <k-tc-next-page></k-tc-next-page>
211
+ <k-tc-last-page></k-tc-last-page>
212
+ </div>
213
+ <div class="flex"></div>
214
+ <div><k-tc-page-select></k-tc-page-select></div>
215
+ <div class="flex"></div>
216
+ <div><k-tc-page-size></k-tc-page-size></div>
217
+ </div>
218
+ </k-table>
219
+ </div>
220
+
221
+ </div>
222
+
223
+ <script type="module">
224
+ import {
225
+ getSession, getUser, updateUser, deleteUsers,
226
+ getUserSessions, deleteUserSession, deleteExpiredUserSessions,
227
+ getUserGroups, addUserToGroup, removeUserFromGroup,
228
+ listGroups, getAllCurrentUserPermissions
229
+ } from '/kempo/sdk.js';
230
+ import { html } from '/kempo-ui/lit-all.min.js';
231
+ import Toast from '/kempo-ui/components/Toast.js';
232
+ import Dialog from '/kempo-ui/components/Dialog.js';
233
+
234
+ const userid = window.location.pathname.split('/admin/accounts/users/')[1]?.replace(/\/$/, '');
235
+
236
+ if(!userid){
237
+ document.getElementById('loading').style.display = 'none';
238
+ document.getElementById('notFound').style.display = '';
239
+ }
240
+
241
+ /*
242
+ Permission checks
243
+ */
244
+
245
+ const [[, permResult], [, sessionData]] = await Promise.all([
246
+ getAllCurrentUserPermissions(),
247
+ getSession()
248
+ ]);
249
+ const myPerms = new Set(permResult?.permissions ?? []);
250
+ const currentUserId = sessionData?.user?.id;
251
+ const canEdit = myPerms.has('system:user:update');
252
+ const canDelete = myPerms.has('system:user:delete');
253
+ const canEditGroups = myPerms.has('system:group:update');
254
+ const canReadGroups = myPerms.has('system:group:read');
255
+
256
+ /*
257
+ Load user
258
+ */
259
+
260
+ const [userError, userData] = await getUser(userid);
261
+
262
+ document.getElementById('loading').style.display = 'none';
263
+
264
+ if(userError){
265
+ document.getElementById('notFound').style.display = '';
266
+ } else {
267
+ document.getElementById('content').style.display = '';
268
+ document.getElementById('pageTitle').textContent = `User: ${userData.name}`;
269
+ document.title = `${userData.name} - Admin`;
270
+
271
+ await customElements.whenDefined('k-toggle');
272
+
273
+ const $userName = document.getElementById('userName');
274
+ const $userEmail = document.getElementById('userEmail');
275
+ const $emailVerified = document.getElementById('emailVerified');
276
+ const $userCreatedAt = document.getElementById('userCreatedAt');
277
+ const $deleteBtn = document.getElementById('deleteUserBtn');
278
+
279
+ $userName.value = userData.name;
280
+ $userEmail.value = userData.email;
281
+ $emailVerified.value = userData.emailVerified;
282
+ $userCreatedAt.textContent = new Date(userData.createdAt).toLocaleString();
283
+
284
+ const isSelf = userData.id === currentUserId;
285
+
286
+ if(!canEdit){
287
+ $userName.readOnly = true;
288
+ $userEmail.readOnly = true;
289
+ $emailVerified.setAttribute('disabled', '');
290
+ }
291
+
292
+ if(!canDelete || isSelf){
293
+ $deleteBtn.style.display = 'none';
294
+ } else {
295
+ $deleteBtn.style.display = '';
296
+ }
297
+
298
+ /*
299
+ Save user fields
300
+ */
301
+
302
+ if(canEdit){
303
+ const $saveBtn = document.getElementById('saveBtn');
304
+ $saveBtn.style.display = '';
305
+
306
+ const origName = userData.name;
307
+ const origEmail = userData.email;
308
+ const origVerified = userData.emailVerified;
309
+
310
+ const handleBeforeUnload = e => { e.preventDefault(); e.returnValue = ''; };
311
+
312
+ const checkDirty = () => {
313
+ const dirty = $userName.value !== origName
314
+ || $userEmail.value !== origEmail
315
+ || $emailVerified.value !== origVerified;
316
+ $saveBtn.disabled = !dirty;
317
+ if(dirty){
318
+ window.addEventListener('beforeunload', handleBeforeUnload);
319
+ } else {
320
+ window.removeEventListener('beforeunload', handleBeforeUnload);
321
+ }
322
+ };
323
+
324
+ $userName.addEventListener('input', checkDirty);
325
+ $userEmail.addEventListener('input', checkDirty);
326
+ $emailVerified.addEventListener('change', checkDirty);
327
+
328
+ $saveBtn.addEventListener('click', async () => {
329
+ const [error] = await updateUser({
330
+ id: userData.id,
331
+ name: $userName.value,
332
+ email: $userEmail.value,
333
+ emailVerified: $emailVerified.value
334
+ });
335
+ if(error){
336
+ Toast.error(error.msg || 'Failed to save');
337
+ return;
338
+ }
339
+ window.removeEventListener('beforeunload', handleBeforeUnload);
340
+ document.getElementById('pageTitle').textContent = `User: ${$userName.value}`;
341
+ document.title = `${$userName.value} - Admin`;
342
+ $saveBtn.disabled = true;
343
+ Toast.success('User saved');
344
+ });
345
+ }
346
+
347
+ /*
348
+ Delete user
349
+ */
350
+
351
+ $deleteBtn.addEventListener('click', () => {
352
+ if(isSelf) return;
353
+ Dialog.confirm('Are you sure you want to delete this user? This action cannot be undone.', async (confirmed) => {
354
+ if(!confirmed) return;
355
+ const [error] = await deleteUsers([userData.id]);
356
+
357
+ if(error){
358
+ Toast.error(error.msg || 'Failed to delete user');
359
+ return;
360
+ }
361
+
362
+ Toast.success('User deleted');
363
+ window.location.href = '/admin/accounts/users';
364
+ });
365
+ });
366
+
367
+ /*
368
+ Groups
369
+ */
370
+
371
+ await customElements.whenDefined('k-table');
372
+ const $groupsTable = document.getElementById('groupsTable');
373
+
374
+ if(canEditGroups){
375
+ $groupsTable.setAttribute('enable-selection', '');
376
+ document.getElementById('groupsTableTop').style.display = '';
377
+ }
378
+
379
+ $groupsTable.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
380
+ if(records[0].name === 'system:Users') { reject(); return; }
381
+ Dialog.confirm(`Remove from "${records[0].name}"?`, async (confirmed) => {
382
+ if(!confirmed){ reject(); return; }
383
+ const [error] = await removeUserFromGroup(userid, records[0].name);
384
+ if(error){
385
+ reject();
386
+ Toast.error(error.msg || 'Failed to remove from group');
387
+ return;
388
+ }
389
+ approve();
390
+ Toast.success(`Removed from ${records[0].name}`);
391
+ loadGroups();
392
+ });
393
+ });
394
+
395
+ $groupsTable.addEventListener('groupRemoveSelected', ({ detail: { records, systemUsersSkipped } }) => {
396
+ const count = records.length;
397
+ const note = systemUsersSkipped ? '<p><small><em>Note: The user cannot be removed from system:Users, but will be removed from the other selected groups.</em></small></p>' : '';
398
+ Dialog.confirm(`<div class="p"><p>Remove from ${count} group${count !== 1 ? 's' : ''}?</p>${note}</div>`, async (confirmed) => {
399
+ if(!confirmed) return;
400
+ const errors = [];
401
+ for(const record of records){
402
+ const [error] = await removeUserFromGroup(userid, record.name);
403
+ if(error) errors.push(error.msg);
404
+ }
405
+ if(errors.length){
406
+ Toast.error(errors[0] || 'Failed to remove from group');
407
+ return;
408
+ }
409
+ Toast.success(`Removed from ${count} group${count !== 1 ? 's' : ''}`);
410
+ loadGroups();
411
+ });
412
+ });
413
+
414
+ const loadGroups = async () => {
415
+ const [error, data] = await getUserGroups(userid);
416
+ if(error){
417
+ Toast.error(error.msg || 'Failed to load groups');
418
+ return;
419
+ }
420
+ $groupsTable.setData({
421
+ records: data.groups,
422
+ fields: [{ name: 'name', label: 'Name' }]
423
+ });
424
+ $groupsTable.setupFetchRecords(data.total, async (start, count) => {
425
+ const [err, d] = await getUserGroups(userid, { limit: count, offset: start });
426
+ if(err) return [];
427
+ return d.groups;
428
+ });
429
+ };
430
+
431
+ document.getElementById('addGroupBtn').addEventListener('click', async () => {
432
+ const [error, data] = await listGroups({ limit: 1000 });
433
+ if(error){ Toast.error(error.msg || 'Failed to load groups'); return; }
434
+
435
+ const userGroupNames = new Set($groupsTable.records.map(g => g.name));
436
+ const available = data.groups.filter(g => !userGroupNames.has(g.name));
437
+
438
+ const $dialog = Dialog.create(html`
439
+ <div class="p">
440
+ <select id="dlg-group">
441
+ <option value="">Select a group...</option>
442
+ ${available.map(g => html`<option value=${g.name}>${g.name}</option>`)}
443
+ </select>
444
+ </div>
445
+ `, {
446
+ title: 'Add to Group',
447
+ confirmText: 'Add',
448
+ confirmAction: async () => {
449
+ const groupName = $dialog.querySelector('#dlg-group').value;
450
+ if(!groupName) return;
451
+ const [err] = await addUserToGroup(userid, groupName);
452
+ if(err){ Toast.error(err.msg || 'Failed to add to group'); return; }
453
+ Toast.success(`Added to ${groupName}`);
454
+ loadGroups();
455
+ },
456
+ cancelText: 'Cancel'
457
+ });
458
+ });
459
+
460
+ loadGroups();
461
+
462
+ /*
463
+ Sessions
464
+ */
465
+
466
+ const $sessionsTable = document.getElementById('sessionsTable');
467
+
468
+ if(canEdit){
469
+ $sessionsTable.setAttribute('request-delete', '');
470
+ $sessionsTable.setAttribute('enable-selection', '');
471
+ const $clearExpiredBtn = document.getElementById('clearExpiredBtn');
472
+ $clearExpiredBtn.style.display = '';
473
+ $clearExpiredBtn.addEventListener('click', async () => {
474
+ const [error, result] = await deleteExpiredUserSessions(userid);
475
+ if(error){
476
+ Toast.error(error.msg || 'Failed to clear expired sessions');
477
+ return;
478
+ }
479
+ Toast.success(`Cleared ${result.deleted} expired session${result.deleted !== 1 ? 's' : ''}`);
480
+ loadSessions();
481
+ });
482
+ }
483
+
484
+ $sessionsTable.addEventListener('requestDelete', async ({ detail: { records, approve, reject } }) => {
485
+ const errors = [];
486
+ for(const record of records){
487
+ const [error] = await deleteUserSession(userid, record.token);
488
+ if(error) errors.push(error.msg);
489
+ }
490
+ if(errors.length){
491
+ reject();
492
+ Toast.error(errors[0] || 'Failed to revoke session');
493
+ return;
494
+ }
495
+ approve();
496
+ Toast.success(records.length === 1 ? 'Session revoked' : `${records.length} sessions revoked`);
497
+ });
498
+
499
+ const loadSessions = async () => {
500
+ const [error, data] = await getUserSessions(userid);
501
+ if(error){
502
+ Toast.error(error.msg || 'Failed to load sessions');
503
+ return;
504
+ }
505
+ const now = new Date();
506
+ $sessionsTable.setData({
507
+ records: data.sessions,
508
+ fields: [
509
+ {
510
+ name: 'createdAt',
511
+ label: 'Created',
512
+ formatter: v => new Date(v).toLocaleString()
513
+ },
514
+ {
515
+ name: 'expiresAt',
516
+ label: 'Expires',
517
+ formatter: v => {
518
+ const date = new Date(v);
519
+ return `${date.toLocaleString()}${date < now ? ' (expired)' : ''}`;
520
+ }
521
+ },
522
+ { name: 'ipAddress', label: 'IP' },
523
+ { name: 'userAgent', label: 'User Agent' }
524
+ ]
525
+ });
526
+ $sessionsTable.setupFetchRecords(data.total, async (start, count) => {
527
+ const [err, d] = await getUserSessions(userid, { limit: count, offset: start });
528
+ if(err) return [];
529
+ return d.sessions;
530
+ });
531
+ };
532
+
533
+ loadSessions();
534
+ }
535
+ </script>
536
+
537
+
538
+ </k-main>
539
+
540
+ </body>
541
+ </html>