keepup-http 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. keepup_http/__init__.py +38 -0
  2. keepup_http/api_docs.py +76 -0
  3. keepup_http/body_limit.py +156 -0
  4. keepup_http/events_api.py +237 -0
  5. keepup_http/factory.py +562 -0
  6. keepup_http/lock_routes.py +155 -0
  7. keepup_http/plugin.py +53 -0
  8. keepup_http/routes.py +549 -0
  9. keepup_http/static/css/aos.css +1 -0
  10. keepup_http/static/css/main_nebula.css +232 -0
  11. keepup_http/static/css/main_new.css +869 -0
  12. keepup_http/static/css/tailwind.css +1 -0
  13. keepup_http/static/index_nebula.html +282 -0
  14. keepup_http/static/index_new.html +275 -0
  15. keepup_http/static/js/aos.js +1 -0
  16. keepup_http/static/js/feather-icons.js +13 -0
  17. keepup_http/static/js/layout_bootstrap.js +29 -0
  18. keepup_http/static/js/main_new.js +2076 -0
  19. keepup_http/static/js/tailwind.js +83 -0
  20. keepup_http/static/modules/css/background_tasks.css +233 -0
  21. keepup_http/static/modules/css/cluster.css +16 -0
  22. keepup_http/static/modules/css/event_manager.css +386 -0
  23. keepup_http/static/modules/css/integration_logs.css +33 -0
  24. keepup_http/static/modules/css/metrics.css +115 -0
  25. keepup_http/static/modules/css/modules.css +189 -0
  26. keepup_http/static/modules/css/themes.css +563 -0
  27. keepup_http/static/modules/css/users.css +278 -0
  28. keepup_http/static/modules/js/background_tasks.js +695 -0
  29. keepup_http/static/modules/js/chart.js +14 -0
  30. keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  31. keepup_http/static/modules/js/cluster.js +374 -0
  32. keepup_http/static/modules/js/event_manager.js +1007 -0
  33. keepup_http/static/modules/js/integration_logs.js +795 -0
  34. keepup_http/static/modules/js/metrics.js +929 -0
  35. keepup_http/static/modules/js/modules.js +1128 -0
  36. keepup_http/static/modules/js/themes.js +684 -0
  37. keepup_http/static/modules/js/users.js +898 -0
  38. keepup_http/web.py +214 -0
  39. keepup_http-0.5.0.dist-info/METADATA +29 -0
  40. keepup_http-0.5.0.dist-info/RECORD +43 -0
  41. keepup_http-0.5.0.dist-info/WHEEL +5 -0
  42. keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
  43. keepup_http-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,898 @@
1
+ // Actions on a user row that app modules add.
2
+ // The framework does not know what these actions are: a "message on Telegram"
3
+ // button with its own request used to sit here directly, and the framework's
4
+ // users section ended up being part of one application's feature (keepup-27).
5
+ //
6
+ // A module puts an object { prepare?, render } here: prepare gets the whole
7
+ // list of users and can fetch its own data once; render gets a user and
8
+ // returns the button markup or an empty string.
9
+ window.KeepupUserActions = window.KeepupUserActions || [];
10
+
11
+ // Columns that modules of applications add to the users table. Same idea as
12
+ // the actions above and for the same reason, one step further: until
13
+ // keepup-28 this section carried a subscription model -- a plan column, a plan
14
+ // dialog, two hundred lines of one application's business shipped inside the
15
+ // framework to everyone who installs it.
16
+ //
17
+ // A module puts an object here: { heading, prepare?, render, afterRender? }.
18
+ // prepare receives the whole list of users and answers whether the column is
19
+ // to be shown at all -- which is how a capability that is not running in this
20
+ // deployment leaves no empty column behind and makes no request per row.
21
+ window.KeepupUserColumns = window.KeepupUserColumns || [];
22
+
23
+ //: Contributors whose column is shown for the list being rendered.
24
+ let usersShownColumns = [];
25
+
26
+ /** Asks each contributor whether its column belongs in this list, and prepares it. */
27
+ async function usersPrepareColumns(users) {
28
+ usersShownColumns = [];
29
+ for (const column of window.KeepupUserColumns) {
30
+ try {
31
+ const shown = typeof column.prepare === 'function' ? await column.prepare(users) : true;
32
+ if (shown !== false) usersShownColumns.push(column);
33
+ } catch (error) {
34
+ // A contributor that fell over costs its own column, not the table.
35
+ console.warn('A column of the users section failed to prepare:', error);
36
+ }
37
+ }
38
+ }
39
+
40
+ /** Puts the headings of the added columns in front of the actions column.
41
+
42
+ Inserted rather than written into the template, because which columns there
43
+ are is known only after the contributors have been asked -- and asked
44
+ afresh on every load, since a capability can stop answering between two. */
45
+ function usersPlaceExtraHeadings() {
46
+ const actions = document.getElementById('usersActionsHeading');
47
+ if (!actions) return;
48
+ actions.parentElement.querySelectorAll('.users-extra-heading').forEach(node => node.remove());
49
+ for (const column of usersShownColumns) {
50
+ const heading = document.createElement('th');
51
+ heading.className = 'users-extra-heading px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase';
52
+ heading.textContent = column.heading || '';
53
+ actions.parentElement.insertBefore(heading, actions);
54
+ }
55
+ }
56
+
57
+ /** Body cells of the added columns for one user. */
58
+ function usersExtraCells(user) {
59
+ return usersShownColumns.map(column => {
60
+ let body = '';
61
+ try {
62
+ body = column.render(user) || '';
63
+ } catch (error) {
64
+ console.warn('A column of the users section failed to render:', error);
65
+ }
66
+ return `<td class="px-6 py-4 whitespace-nowrap">${body}</td>`;
67
+ }).join('');
68
+ }
69
+
70
+ /** Lets the contributors wire their own controls once the table is in the page. */
71
+ function usersColumnsRendered() {
72
+ for (const column of usersShownColumns) {
73
+ if (typeof column.afterRender !== 'function') continue;
74
+ try {
75
+ column.afterRender();
76
+ } catch (error) {
77
+ console.warn('A column of the users section failed to wire itself:', error);
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Lets contributors fetch their own data before the table renders. */
83
+ async function usersPrepareActions(users) {
84
+ for (const action of window.KeepupUserActions) {
85
+ if (typeof action.prepare !== 'function') continue;
86
+ try {
87
+ await action.prepare(users);
88
+ } catch (error) {
89
+ // A contributor's failure does not take the whole users section down with it.
90
+ console.warn('A contributor to the users section failed to prepare:', error);
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Markup for the added actions on one row. */
96
+ function usersExtraActions(user) {
97
+ return window.KeepupUserActions.map(action => {
98
+ try {
99
+ return typeof action.render === 'function' ? (action.render(user) || '') : '';
100
+ } catch (error) {
101
+ console.warn('A contributor to the users section failed to render:', error);
102
+ return '';
103
+ }
104
+ }).join('');
105
+ }
106
+
107
+ function usersEscapeHtml(str) {
108
+ return String(str === undefined || str === null ? '' : str)
109
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
110
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
111
+ }
112
+
113
+
114
+ function renderUsers() {
115
+ addUsersNavigation();
116
+ addUsersSection();
117
+ setupUsersEventListeners();
118
+ }
119
+
120
+ function addUsersNavigation() {
121
+ const mainNav = document.getElementById('main-nav');
122
+ if (!mainNav) return;
123
+
124
+ if (document.querySelector('[data-target="users-section"]')) return;
125
+
126
+ const usersNavItem = document.createElement('a');
127
+ usersNavItem.href = '#';
128
+ usersNavItem.className = 'nav-item flex items-center px-6 py-3 text-blue-100 hover:bg-blue-600';
129
+ usersNavItem.setAttribute('data-target', 'users-section');
130
+ usersNavItem.innerHTML = `
131
+ <i data-feather="users" class="w-5 h-5 mr-3"></i>
132
+ <span>Users</span>
133
+ `;
134
+
135
+ mainNav.appendChild(usersNavItem);
136
+ feather.replace();
137
+ }
138
+
139
+ function addUsersSection() {
140
+ const mainContent = document.querySelector('main');
141
+ if (!mainContent) return;
142
+
143
+ if (document.getElementById('users-section')) return;
144
+
145
+ const usersSection = document.createElement('div');
146
+ usersSection.id = 'users-section';
147
+ usersSection.className = 'content-section';
148
+ usersSection.innerHTML = `
149
+ <div class="bg-white rounded-lg shadow-sm p-6 mb-6">
150
+ <div class="flex justify-between items-center mb-6">
151
+ <h2 class="text-2xl font-bold text-gray-800">User management</h2>
152
+ <button type="button" data-action="users.refresh" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center">
153
+ <i data-feather="refresh-cw" class="w-4 h-4 mr-2"></i>
154
+ Refresh list
155
+ </button>
156
+ </div>
157
+
158
+ <div class="bg-white rounded-lg shadow-sm overflow-hidden">
159
+ <table class="responsive-table min-w-full divide-y divide-gray-200">
160
+ <thead class="bg-gray-50">
161
+ <tr>
162
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
163
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
164
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
165
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Roles</th>
166
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
167
+ <th id="usersActionsHeading" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
168
+ </tr>
169
+ </thead>
170
+ <tbody id="usersListBody" class="bg-white divide-y divide-gray-200">
171
+ <tr>
172
+ <td colspan="7" class="px-6 py-4 text-center text-gray-500">
173
+ Loading...
174
+ </td>
175
+ </tr>
176
+ </tbody>
177
+ </table>
178
+ </div>
179
+ </div>
180
+ `;
181
+
182
+ const modulesContainer = document.getElementById('modules-container');
183
+ if (modulesContainer) {
184
+ modulesContainer.appendChild(usersSection);
185
+ } else {
186
+ const shopsSection = document.getElementById('shops-section');
187
+ if (shopsSection) {
188
+ shopsSection.parentNode.insertBefore(usersSection, shopsSection.nextSibling);
189
+ }
190
+ }
191
+
192
+ feather.replace();
193
+ }
194
+
195
+ function setupUsersEventListeners() {
196
+ document.addEventListener('click', function(e) {
197
+ if (e.target.closest('[data-target="users-section"]')) {
198
+ loadAllUsers();
199
+ }
200
+ });
201
+ }
202
+
203
+ async function loadAllUsers() {
204
+ try {
205
+ const token = localStorage.getItem('authToken');
206
+ const response = await fetch('/api/admin/users', {
207
+ headers: {
208
+ 'Authorization': `Bearer ${token}`
209
+ }
210
+ });
211
+
212
+ if (response.ok) {
213
+ const users = await response.json();
214
+ await usersPrepareActions(users);
215
+ await usersPrepareColumns(users);
216
+ displayUsersList(users);
217
+ } else if (response.status === 403) {
218
+ showNotification('Access denied. Administrator rights required.', 'error');
219
+ }
220
+ } catch (error) {
221
+
222
+ showNotification('Error loading users', 'error');
223
+ }
224
+ }
225
+
226
+
227
+
228
+
229
+ // The roles an account holds, as badges. A role name comes from the deployment's
230
+ // own catalogue, so it is escaped rather than spliced into the markup as it is.
231
+ function usersEscapeText(value) {
232
+ const holder = document.createElement('span');
233
+ holder.textContent = value === null || value === undefined ? '' : String(value);
234
+ return holder.innerHTML;
235
+ }
236
+
237
+ function usersRoleBadges(user) {
238
+ const roles = (Array.isArray(user.roles) && user.roles.length)
239
+ ? user.roles
240
+ : (user.role ? [user.role] : []);
241
+ if (!roles.length) return '<span class="text-gray-400 text-xs">none</span>';
242
+ return roles.map(role => {
243
+ const tone = role === ROLE_ADMIN ? 'role-admin' : 'role-client';
244
+ return `<span class="role-badge ${tone} mr-1">${usersEscapeText(role)}</span>`;
245
+ }).join('');
246
+ }
247
+
248
+ //: The accounts of the list as it was last drawn, by id: the buttons carry only
249
+ //: the id, and the name is taken from here, never from the markup.
250
+ let usersById = new Map();
251
+
252
+ const USER_ACTIONS = {
253
+ block: (user) => blockUserWithReason(user.id, user.username),
254
+ unblock: (user) => unblockUserWithReason(user.id, user.username),
255
+ history: (user) => showUserBlockHistory(user.id, user.username),
256
+ roles: (user) => showUserRolesModal(user.id, user.username),
257
+ password: (user) => showPasswordModal(user.id, user.username),
258
+ };
259
+
260
+ /** Runs a row's action against the account the row names.
261
+
262
+ The button carries the action's name and the account's id; the action is
263
+ taken from USER_ACTIONS and the account from the list as it was drawn, so
264
+ neither a function nor a name reaches the click from the markup
265
+ (keepup-62, keepup-93). */
266
+ function usersRowAction(element, action) {
267
+ const user = usersById.get(element.dataset.userId);
268
+ if (user && action) action(user);
269
+ }
270
+
271
+ function displayUsersList(users) {
272
+ const usersListBody = document.getElementById('usersListBody');
273
+ if (!usersListBody) return;
274
+
275
+ if (users.length === 0) {
276
+ usersListBody.innerHTML = `
277
+ <tr>
278
+ <td colspan="7" class="text-center py-4 text-gray-500">
279
+ No users found
280
+ </td>
281
+ </tr>
282
+ `;
283
+ return;
284
+ }
285
+
286
+ // Every value from an account is escaped, and the buttons carry the account's
287
+ // id in a data attribute rather than a script built from its name: a name
288
+ // is whatever somebody registered, and a name with markup or a quote in it
289
+ // ran in the session of the administrator who opened this list (keepup-62).
290
+ usersById = new Map(users.map(user => [String(user.id), user]));
291
+ usersListBody.innerHTML = users.map(user => `
292
+ <tr>
293
+ <td class="px-6 py-4 whitespace-nowrap">${usersEscapeHtml(user.id)}</td>
294
+ <td class="px-6 py-4 whitespace-nowrap font-medium">${usersEscapeHtml(user.username)}</td>
295
+ <td class="px-6 py-4 whitespace-nowrap">
296
+ <span class="status-badge ${user.status === 'active' ? 'status-active' : 'status-blocked'}">
297
+ ${user.status === 'active' ? 'Active' : 'Blocked'}
298
+ </span>
299
+ </td>
300
+ <td class="px-6 py-4">
301
+ ${usersRoleBadges(user)}
302
+ </td>
303
+ <td class="px-6 py-4 whitespace-nowrap">${new Date(user.created_at).toLocaleDateString('en-GB')}</td>
304
+ ${usersExtraCells(user)}
305
+ <td class="px-6 py-4 whitespace-nowrap">
306
+ <div class="user-actions flex flex-col space-y-2">
307
+ <div class="flex space-x-2">
308
+ ${user.status === 'active' ?
309
+ `<button data-action="users.block" data-user-id="${usersEscapeHtml(user.id)}" class="user-action-btn btn-block text-xs px-2 py-1">Block</button>` :
310
+ `<button data-action="users.unblock" data-user-id="${usersEscapeHtml(user.id)}" class="user-action-btn btn-activate text-xs px-2 py-1">Activate</button>`
311
+ }
312
+ <button data-action="users.history" data-user-id="${usersEscapeHtml(user.id)}" class="user-action-btn text-xs px-2 py-1">Block log</button>
313
+ <button data-action="users.roles" data-user-id="${usersEscapeHtml(user.id)}"
314
+ class="user-action-btn btn-make-admin text-xs px-2 py-1">Roles</button>
315
+ </div>
316
+ <div class="flex space-x-2">
317
+ <button data-action="users.password" data-user-id="${usersEscapeHtml(user.id)}"
318
+ class="user-action-btn btn-change-password text-xs px-2 py-1">
319
+ Change password
320
+ </button>
321
+ ${usersExtraActions(user)}
322
+ </div>
323
+ </div>
324
+ </td>
325
+ </tr>
326
+ `).join('');
327
+
328
+ usersPlaceExtraHeadings();
329
+ usersColumnsRendered();
330
+ feather.replace();
331
+ }
332
+
333
+ function showPasswordModal(userId, username) {
334
+ const modal = document.createElement('div');
335
+ modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
336
+ modal.innerHTML = `
337
+ <div class="bg-white rounded-lg p-6 w-full max-w-md">
338
+ <div class="flex justify-between items-center mb-4">
339
+ <h3 class="text-lg font-semibold">Change password for ${usersEscapeHtml(username)}</h3>
340
+ <button type="button" data-close-modal class="text-gray-500 hover:text-gray-700">
341
+ <i data-feather="x" class="w-5 h-5"></i>
342
+ </button>
343
+ </div>
344
+
345
+ <div class="space-y-4">
346
+ <div>
347
+ <label class="block text-sm font-medium text-gray-700 mb-1">
348
+ New password
349
+ </label>
350
+ <div class="relative">
351
+ <input type="password"
352
+ id="newPasswordInput"
353
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
354
+ placeholder="Enter a new password"
355
+ minlength="6"
356
+ required>
357
+ <button type="button"
358
+ data-toggle-password="newPasswordInput"
359
+ class="absolute right-3 top-2 text-gray-500 hover:text-gray-700">
360
+ <i data-feather="eye" class="w-5 h-5"></i>
361
+ </button>
362
+ </div>
363
+ <p class="mt-1 text-xs text-gray-500">
364
+ At least 6 characters
365
+ </p>
366
+ </div>
367
+
368
+ <div>
369
+ <label class="block text-sm font-medium text-gray-700 mb-1">
370
+ Confirm password
371
+ </label>
372
+ <div class="relative">
373
+ <input type="password"
374
+ id="confirmPasswordInput"
375
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
376
+ placeholder="Repeat the new password"
377
+ minlength="6"
378
+ required>
379
+ <button type="button"
380
+ data-toggle-password="confirmPasswordInput"
381
+ class="absolute right-3 top-2 text-gray-500 hover:text-gray-700">
382
+ <i data-feather="eye" class="w-5 h-5"></i>
383
+ </button>
384
+ </div>
385
+ </div>
386
+
387
+ <div class="flex items-center space-x-2 mb-4">
388
+ <input type="checkbox"
389
+ id="generateRandomPassword"
390
+ class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
391
+ <label for="generateRandomPassword" class="text-sm text-gray-700">
392
+ Generate a random password
393
+ </label>
394
+ </div>
395
+
396
+ <div id="generatedPasswordContainer" class="hidden">
397
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
398
+ <div class="flex justify-between items-center mb-2">
399
+ <span class="font-medium text-gray-700">Generated password:</span>
400
+ <button type="button"
401
+ data-copy-password
402
+ class="text-blue-600 hover:text-blue-800 text-sm">
403
+ <i data-feather="copy" class="w-4 h-4"></i>
404
+ </button>
405
+ </div>
406
+ <div class="font-mono text-sm bg-white p-2 rounded border" id="generatedPasswordText"></div>
407
+ <p class="mt-2 text-xs text-red-600">
408
+ Save this password! It will not be shown again.
409
+ </p>
410
+ </div>
411
+ </div>
412
+
413
+ <div class="flex items-center space-x-2 mb-4">
414
+ <input type="checkbox"
415
+ id="forcePasswordChange"
416
+ class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
417
+ <label for="forcePasswordChange" class="text-sm text-gray-700">
418
+ The user must change their password at the next sign-in
419
+ </label>
420
+ </div>
421
+ </div>
422
+
423
+ <div class="flex justify-end space-x-3 mt-6">
424
+ <button type="button" data-generate-password
425
+ class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 flex items-center">
426
+ <i data-feather="refresh-cw" class="w-4 h-4 mr-2"></i>
427
+ Generate
428
+ </button>
429
+ <button type="button" data-close-modal
430
+ class="px-4 py-2 text-gray-600 hover:text-gray-800">
431
+ Cancel
432
+ </button>
433
+ <button data-save-password
434
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center">
435
+ <i data-feather="key" class="w-4 h-4 mr-2"></i>
436
+ Save password
437
+ </button>
438
+ </div>
439
+ </div>
440
+ `;
441
+
442
+ window.closeModal = () => {
443
+ document.body.removeChild(modal);
444
+ };
445
+
446
+ window.togglePasswordVisibility = (inputId) => {
447
+ const input = document.getElementById(inputId);
448
+ const icon = input.parentNode.querySelector('i');
449
+
450
+ if (input.type === 'password') {
451
+ input.type = 'text';
452
+ icon.setAttribute('data-feather', 'eye-off');
453
+ } else {
454
+ input.type = 'password';
455
+ icon.setAttribute('data-feather', 'eye');
456
+ }
457
+ feather.replace();
458
+ };
459
+
460
+ window.generateRandomPassword = () => {
461
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
462
+ const password = Array.from({length: 12}, () => chars[Math.floor(Math.random() * chars.length)]).join('');
463
+
464
+ document.getElementById('generatedPasswordText').textContent = password;
465
+ document.getElementById('generatedPasswordContainer').classList.remove('hidden');
466
+
467
+ // Automatically fill the fields
468
+ document.getElementById('newPasswordInput').value = password;
469
+ document.getElementById('confirmPasswordInput').value = password;
470
+
471
+ // Show the password so it can be copied
472
+ document.getElementById('newPasswordInput').type = 'text';
473
+ document.getElementById('confirmPasswordInput').type = 'text';
474
+
475
+ // Update the icons
476
+ feather.replace();
477
+ };
478
+
479
+ window.copyToClipboard = (elementId) => {
480
+ const text = document.getElementById(elementId).textContent;
481
+ navigator.clipboard.writeText(text).then(() => {
482
+ showNotification('Password copied to clipboard', 'success');
483
+ });
484
+ };
485
+
486
+ window.changeUserPassword = async (userId, username) => {
487
+ const newPassword = document.getElementById('newPasswordInput').value;
488
+ const confirmPassword = document.getElementById('confirmPasswordInput').value;
489
+ const generateRandom = document.getElementById('generateRandomPassword').checked;
490
+
491
+ // Validation
492
+ if (!newPassword && !generateRandom) {
493
+ showNotification('Enter a new password or generate a random one', 'error');
494
+ return;
495
+ }
496
+
497
+ if (newPassword && newPassword.length < 6) {
498
+ showNotification('Password must be at least 6 characters', 'error');
499
+ return;
500
+ }
501
+
502
+ if (newPassword !== confirmPassword) {
503
+ showNotification('Passwords do not match', 'error');
504
+ return;
505
+ }
506
+
507
+ try {
508
+ const token = localStorage.getItem('authToken');
509
+
510
+ let response;
511
+ if (generateRandom) {
512
+ // Use random password generation
513
+ response = await fetch(`/api/admin/users/${userId}/generate-password`, {
514
+ method: 'POST',
515
+ headers: {
516
+ 'Authorization': `Bearer ${token}`
517
+ }
518
+ });
519
+ } else {
520
+ // Use setting a specific password
521
+ response = await fetch(`/api/admin/users/${userId}/password`, {
522
+ method: 'PUT',
523
+ headers: {
524
+ 'Content-Type': 'application/json',
525
+ 'Authorization': `Bearer ${token}`
526
+ },
527
+ body: JSON.stringify({
528
+ new_password: newPassword
529
+ })
530
+ });
531
+ }
532
+
533
+ const data = await response.json();
534
+
535
+ if (response.ok) {
536
+ if (generateRandom) {
537
+ // Show the generated password
538
+ showNotification(
539
+ `New password for ${username}: ${data.generated_password}`,
540
+ 'success',
541
+ 10000 // Show it longer so there is time to copy it
542
+ );
543
+
544
+ // Automatic copying could be added here
545
+ navigator.clipboard.writeText(data.generated_password).then(() => {
546
+
547
+ });
548
+ } else {
549
+ showNotification(`Password for ${username} changed successfully`, 'success');
550
+ }
551
+
552
+ // If needed, mark that the password must be changed at next sign-in
553
+ const forceChange = document.getElementById('forcePasswordChange').checked;
554
+ if (forceChange) {
555
+ // Logic for marking "change password at sign-in" could go here
556
+ // For example, add a users.force_password_change column to the database
557
+
558
+ }
559
+
560
+ closeModal();
561
+ } else {
562
+ showNotification(data.detail || 'Error changing password', 'error');
563
+ }
564
+ } catch (error) {
565
+
566
+ showNotification('Network error while changing password', 'error');
567
+ }
568
+ };
569
+
570
+ document.body.appendChild(modal);
571
+ modal.querySelector('[data-save-password]')
572
+ .addEventListener('click', () => changeUserPassword(userId, username));
573
+ // The buttons of this modal, bound where it was put in the page: it is
574
+ // created once per opening, so delegation would only add an indirection.
575
+ modal.querySelectorAll('[data-close-modal]').forEach(button =>
576
+ button.addEventListener('click', () => window.closeModal()));
577
+ modal.querySelectorAll('[data-toggle-password]').forEach(button =>
578
+ button.addEventListener('click', () => window.togglePasswordVisibility(button.dataset.togglePassword)));
579
+ const copyButton = modal.querySelector('[data-copy-password]');
580
+ if (copyButton) copyButton.addEventListener('click', () => window.copyToClipboard('generatedPasswordText'));
581
+ const generateButton = modal.querySelector('[data-generate-password]');
582
+ if (generateButton) generateButton.addEventListener('click', () => window.generateRandomPassword());
583
+ feather.replace();
584
+
585
+ // Handler for the generation checkbox
586
+ document.getElementById('generateRandomPassword').addEventListener('change', function() {
587
+ const passwordInputs = document.querySelectorAll('#newPasswordInput, #confirmPasswordInput');
588
+ passwordInputs.forEach(input => {
589
+ input.disabled = this.checked;
590
+ input.required = !this.checked;
591
+ });
592
+
593
+ if (this.checked) {
594
+ generateRandomPassword();
595
+ } else {
596
+ document.getElementById('generatedPasswordContainer').classList.add('hidden');
597
+ passwordInputs.forEach(input => {
598
+ input.value = '';
599
+ input.type = 'password';
600
+ });
601
+ feather.replace();
602
+ }
603
+ });
604
+ }
605
+
606
+ async function resetUserPassword(userId, username) {
607
+ if (!confirm(`Reset ${username}'s password to the default (ChangeMe123!)?`)) {
608
+ return;
609
+ }
610
+
611
+ try {
612
+ const token = localStorage.getItem('authToken');
613
+ const response = await fetch(`/api/admin/users/${userId}/reset-password`, {
614
+ method: 'POST',
615
+ headers: {
616
+ 'Authorization': `Bearer ${token}`
617
+ }
618
+ });
619
+
620
+ const data = await response.json();
621
+
622
+ if (response.ok) {
623
+ showNotification(
624
+ `Password for ${username} reset to: ${data.default_password}`,
625
+ 'warning',
626
+ 10000
627
+ );
628
+
629
+ navigator.clipboard.writeText(data.default_password).then(() => {
630
+
631
+ });
632
+ } else {
633
+ showNotification(data.detail || 'Error resetting password', 'error');
634
+ }
635
+ } catch (error) {
636
+
637
+ showNotification('Network error while resetting password', 'error');
638
+ }
639
+ }
640
+
641
+
642
+
643
+
644
+
645
+ // Blocking and unblocking go through the account as a whole (task 229):
646
+ // a PATCH to the status used to only clear the flag, while API keys, node tokens
647
+ // and running machines stayed as they were -- a blocked user kept working.
648
+ // A reason is required: it lands in the block log and the audit, and it is
649
+ // what later explains the decision.
650
+ async function blockUserWithReason(userId, username) {
651
+ const reason = prompt(`Why is «${username}» being blocked? The reason is recorded.`);
652
+ if (reason === null) return;
653
+ if (!String(reason).trim()) {
654
+ showNotification('A reason is required', 'error');
655
+ return;
656
+ }
657
+ await sendUserBlockAction(userId, 'block', reason);
658
+ }
659
+
660
+ async function unblockUserWithReason(userId, username) {
661
+ const reason = prompt(`Why is «${username}» being unblocked? The reason is recorded.`);
662
+ if (reason === null) return;
663
+ if (!String(reason).trim()) {
664
+ showNotification('A reason is required', 'error');
665
+ return;
666
+ }
667
+ await sendUserBlockAction(userId, 'unblock', reason);
668
+ }
669
+
670
+ async function sendUserBlockAction(userId, action, reason) {
671
+ try {
672
+ const token = localStorage.getItem('authToken');
673
+ const response = await fetch(`/api/admin/users/${userId}/${action}`, {
674
+ method: 'POST',
675
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
676
+ body: JSON.stringify({ reason: String(reason).trim() })
677
+ });
678
+ const data = await response.json();
679
+ if (!response.ok) {
680
+ showNotification(data.detail || 'Error changing status', 'error');
681
+ return;
682
+ }
683
+ // What exactly was revoked is stated by the server: the administrator
684
+ // must see that blocking did more than flip a flag.
685
+ const summary = data.summary || {};
686
+ const consequences = Object.keys(summary)
687
+ .filter(name => summary[name])
688
+ .map(name => `${name.replace(/_/g, ' ')}: ${summary[name]}`)
689
+ .join(', ');
690
+ showNotification(
691
+ `Account ${action === 'block' ? 'blocked' : 'unblocked'}${consequences ? ' — ' + consequences : ''}`,
692
+ 'success');
693
+ loadAllUsers();
694
+ } catch (error) {
695
+ showNotification('Network error', 'error');
696
+ }
697
+ }
698
+
699
+ async function showUserBlockHistory(userId, username) {
700
+ try {
701
+ const token = localStorage.getItem('authToken');
702
+ const response = await fetch(`/api/admin/users/${userId}/blocks`, {
703
+ headers: { 'Authorization': `Bearer ${token}` }
704
+ });
705
+ const data = await response.json();
706
+ if (!response.ok) {
707
+ showNotification(data.detail || 'Error loading the block log', 'error');
708
+ return;
709
+ }
710
+ renderUserBlockHistory(username, data.blocks || []);
711
+ } catch (error) {
712
+ showNotification('Network error', 'error');
713
+ }
714
+ }
715
+
716
+ function renderUserBlockHistory(username, blocks) {
717
+ const modal = document.createElement('div');
718
+ modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
719
+ modal.innerHTML = `
720
+ <div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
721
+ <h3 class="text-lg font-semibold mb-4">Block log — ${escapeUserHtml(username)}</h3>
722
+ ${blocks.length ? `
723
+ <table class="w-full text-sm responsive-table">
724
+ <thead><tr class="text-left text-gray-500 border-b">
725
+ <th class="py-2 pr-4">When</th><th class="py-2 pr-4">Action</th>
726
+ <th class="py-2 pr-4">Reason</th><th class="py-2 pr-4">Administrator</th>
727
+ </tr></thead>
728
+ <tbody>${blocks.map(entry => `
729
+ <tr class="border-b">
730
+ <td class="py-2 pr-4">${escapeUserHtml(entry.created_at)}</td>
731
+ <td class="py-2 pr-4">${escapeUserHtml(entry.action)}</td>
732
+ <td class="py-2 pr-4">${escapeUserHtml(entry.reason)}</td>
733
+ <td class="py-2 pr-4">${escapeUserHtml(entry.admin || entry.admin_id || '—')}</td>
734
+ </tr>`).join('')}
735
+ </tbody>
736
+ </table>`
737
+ : '<p class="text-gray-500">This account has never been blocked.</p>'}
738
+ <div class="flex justify-end mt-4">
739
+ <button type="button" class="px-4 py-2 bg-gray-200 rounded-lg" data-close-history>Close</button>
740
+ </div>
741
+ </div>
742
+ `;
743
+ document.body.appendChild(modal);
744
+ const closeHistory = modal.querySelector('[data-close-history]');
745
+ if (closeHistory) closeHistory.addEventListener('click', () => modal.remove());
746
+ }
747
+
748
+ function escapeUserHtml(value) {
749
+ const holder = document.createElement('div');
750
+ holder.textContent = value === null || value === undefined ? '' : String(value);
751
+ return holder.innerHTML;
752
+ }
753
+
754
+ // Existing user management functions
755
+ async function toggleUserStatus(userId, newStatus) {
756
+ try {
757
+ const token = localStorage.getItem('authToken');
758
+ const response = await fetch(`/api/admin/users/${userId}`, {
759
+ method: 'PATCH',
760
+ headers: {
761
+ 'Content-Type': 'application/json',
762
+ 'Authorization': `Bearer ${token}`
763
+ },
764
+ body: JSON.stringify({ status: newStatus })
765
+ });
766
+
767
+ const data = await response.json();
768
+
769
+ if (response.ok) {
770
+ showNotification(`User status changed to "${newStatus === 'active' ? 'active' : 'blocked'}"`, 'success');
771
+ loadAllUsers();
772
+ } else {
773
+ showNotification(data.detail || 'Error changing status', 'error');
774
+ }
775
+ } catch (error) {
776
+ showNotification('Network error', 'error');
777
+ }
778
+ }
779
+
780
+ // A user holds a set of roles, so the panel offers the set rather than the two
781
+ // buttons that used to move a person between one role and another. The roles a
782
+ // deployment has are the ones the server names -- the section catalogue is where
783
+ // a role comes into being, and offering a list of our own would let somebody
784
+ // grant a role that grants nothing.
785
+ async function showUserRolesModal(userId, username) {
786
+ const token = localStorage.getItem('authToken');
787
+ let held = [];
788
+ let known = [];
789
+ try {
790
+ const response = await fetch(`/api/admin/users/${userId}/roles`, {
791
+ headers: { 'Authorization': `Bearer ${token}` }
792
+ });
793
+ const data = await response.json();
794
+ if (!response.ok) {
795
+ showNotification(data.detail || 'Error loading roles', 'error');
796
+ return;
797
+ }
798
+ held = data.roles || [];
799
+ known = data.known_roles || [];
800
+ } catch (error) {
801
+ showNotification('Network error', 'error');
802
+ return;
803
+ }
804
+
805
+ const modal = document.createElement('div');
806
+ modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
807
+ modal.innerHTML = `
808
+ <div class="bg-white rounded-lg p-6 w-full max-w-md">
809
+ <div class="flex justify-between items-center mb-4">
810
+ <h3 class="text-lg font-semibold">Roles of ${usersEscapeText(username)}</h3>
811
+ <button type="button" data-close-roles class="text-gray-500 hover:text-gray-700">
812
+ <i data-feather="x" class="w-5 h-5"></i>
813
+ </button>
814
+ </div>
815
+ <p class="text-sm text-gray-600 mb-3">
816
+ The panel sections and plugins of every role selected here are
817
+ available to this user. To close access, block the account.
818
+ </p>
819
+ <div id="userRolesChoices" class="space-y-2 mb-4"></div>
820
+ <div class="flex justify-end space-x-3">
821
+ <button type="button" data-close-roles class="px-4 py-2 text-gray-600 hover:text-gray-800">
822
+ Cancel
823
+ </button>
824
+ <button type="button" data-save-roles
825
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
826
+ Save roles
827
+ </button>
828
+ </div>
829
+ </div>
830
+ `;
831
+
832
+ const choices = modal.querySelector('#userRolesChoices');
833
+ known.forEach((role, index) => {
834
+ const row = document.createElement('label');
835
+ row.className = 'flex items-center space-x-2';
836
+ const box = document.createElement('input');
837
+ box.type = 'checkbox';
838
+ box.value = role;
839
+ box.checked = held.indexOf(role) !== -1;
840
+ box.id = `userRoleChoice${index}`;
841
+ box.className = 'h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500';
842
+ const text = document.createElement('span');
843
+ text.className = 'text-sm text-gray-700';
844
+ text.textContent = role;
845
+ row.appendChild(box);
846
+ row.appendChild(text);
847
+ choices.appendChild(row);
848
+ });
849
+
850
+ window.closeRolesModal = () => {
851
+ if (modal.parentNode) document.body.removeChild(modal);
852
+ };
853
+
854
+ window.saveUserRoles = async (id) => {
855
+ const chosen = Array.from(choices.querySelectorAll('input[type="checkbox"]'))
856
+ .filter(box => box.checked)
857
+ .map(box => box.value);
858
+ try {
859
+ const response = await fetch(`/api/admin/users/${id}/roles`, {
860
+ method: 'PUT',
861
+ headers: {
862
+ 'Content-Type': 'application/json',
863
+ 'Authorization': `Bearer ${token}`
864
+ },
865
+ body: JSON.stringify({ roles: chosen })
866
+ });
867
+ const data = await response.json();
868
+ if (response.ok) {
869
+ showNotification('Roles saved', 'success');
870
+ window.closeRolesModal();
871
+ loadAllUsers();
872
+ } else {
873
+ showNotification(data.detail || 'Error saving roles', 'error');
874
+ }
875
+ } catch (error) {
876
+ showNotification('Network error', 'error');
877
+ }
878
+ };
879
+
880
+ document.body.appendChild(modal);
881
+ modal.querySelectorAll('[data-close-roles]').forEach(button =>
882
+ button.addEventListener('click', () => window.closeRolesModal()));
883
+ const saveRoles = modal.querySelector('[data-save-roles]');
884
+ if (saveRoles) saveRoles.addEventListener('click', () => window.saveUserRoles(userId));
885
+ feather.replace();
886
+ }
887
+
888
+ // The actions this section's markup asks the shell to take (keepup-93). The
889
+ // row actions are named here and resolved against the account the row carries;
890
+ // a name nothing registered does nothing.
891
+ KeepupActions.register({
892
+ 'users.refresh': () => loadAllUsers(),
893
+ 'users.block': (element) => usersRowAction(element, USER_ACTIONS.block),
894
+ 'users.unblock': (element) => usersRowAction(element, USER_ACTIONS.unblock),
895
+ 'users.history': (element) => usersRowAction(element, USER_ACTIONS.history),
896
+ 'users.roles': (element) => usersRowAction(element, USER_ACTIONS.roles),
897
+ 'users.password': (element) => usersRowAction(element, USER_ACTIONS.password),
898
+ });