keepup-admin 0.1.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 (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,784 @@
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 onclick="loadAllUsers()" 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">Role</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
+ function displayUsersList(users) {
230
+ const usersListBody = document.getElementById('usersListBody');
231
+ if (!usersListBody) return;
232
+
233
+ if (users.length === 0) {
234
+ usersListBody.innerHTML = `
235
+ <tr>
236
+ <td colspan="7" class="text-center py-4 text-gray-500">
237
+ No users found
238
+ </td>
239
+ </tr>
240
+ `;
241
+ return;
242
+ }
243
+
244
+ usersListBody.innerHTML = users.map(user => `
245
+ <tr>
246
+ <td class="px-6 py-4 whitespace-nowrap">${user.id}</td>
247
+ <td class="px-6 py-4 whitespace-nowrap font-medium">${user.username}</td>
248
+ <td class="px-6 py-4 whitespace-nowrap">
249
+ <span class="status-badge ${user.status === 'active' ? 'status-active' : 'status-blocked'}">
250
+ ${user.status === 'active' ? 'Active' : 'Blocked'}
251
+ </span>
252
+ </td>
253
+ <td class="px-6 py-4 whitespace-nowrap">
254
+ <span class="role-badge ${user.role === ROLE_ADMIN ? 'role-admin' : 'role-client'}">
255
+ ${user.role === ROLE_ADMIN ? 'Admin' : 'Client'}
256
+ </span>
257
+ </td>
258
+ <td class="px-6 py-4 whitespace-nowrap">${new Date(user.created_at).toLocaleDateString('en-GB')}</td>
259
+ ${usersExtraCells(user)}
260
+ <td class="px-6 py-4 whitespace-nowrap">
261
+ <div class="user-actions flex flex-col space-y-2">
262
+ <div class="flex space-x-2">
263
+ ${user.status === 'active' ?
264
+ `<button onclick="blockUserWithReason(${user.id}, '${user.username}')" class="user-action-btn btn-block text-xs px-2 py-1">Block</button>` :
265
+ `<button onclick="unblockUserWithReason(${user.id}, '${user.username}')" class="user-action-btn btn-activate text-xs px-2 py-1">Activate</button>`
266
+ }
267
+ <button onclick="showUserBlockHistory(${user.id}, '${user.username}')" class="user-action-btn text-xs px-2 py-1">Block log</button>
268
+ ${user.role === ROLE_CLIENT ?
269
+ `<button onclick="makeUserAdmin(${user.id})" class="user-action-btn btn-make-admin text-xs px-2 py-1">Make admin</button>` :
270
+ user.id !== (currentUser?.id || 0) ?
271
+ `<button onclick="makeUserClient(${user.id})" class="user-action-btn btn-make-client text-xs px-2 py-1">Make client</button>` :
272
+ ``
273
+ }
274
+ </div>
275
+ <div class="flex space-x-2">
276
+ <button onclick="showPasswordModal(${user.id}, '${user.username}')"
277
+ class="user-action-btn btn-change-password text-xs px-2 py-1">
278
+ Change password
279
+ </button>
280
+ ${usersExtraActions(user)}
281
+ </div>
282
+ </div>
283
+ </td>
284
+ </tr>
285
+ `).join('');
286
+
287
+ usersPlaceExtraHeadings();
288
+ usersColumnsRendered();
289
+ feather.replace();
290
+ }
291
+
292
+ function showPasswordModal(userId, username) {
293
+ const modal = document.createElement('div');
294
+ modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
295
+ modal.innerHTML = `
296
+ <div class="bg-white rounded-lg p-6 w-full max-w-md">
297
+ <div class="flex justify-between items-center mb-4">
298
+ <h3 class="text-lg font-semibold">Change password for ${username}</h3>
299
+ <button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">
300
+ <i data-feather="x" class="w-5 h-5"></i>
301
+ </button>
302
+ </div>
303
+
304
+ <div class="space-y-4">
305
+ <div>
306
+ <label class="block text-sm font-medium text-gray-700 mb-1">
307
+ New password
308
+ </label>
309
+ <div class="relative">
310
+ <input type="password"
311
+ id="newPasswordInput"
312
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
313
+ placeholder="Enter a new password"
314
+ minlength="6"
315
+ required>
316
+ <button type="button"
317
+ onclick="togglePasswordVisibility('newPasswordInput')"
318
+ class="absolute right-3 top-2 text-gray-500 hover:text-gray-700">
319
+ <i data-feather="eye" class="w-5 h-5"></i>
320
+ </button>
321
+ </div>
322
+ <p class="mt-1 text-xs text-gray-500">
323
+ At least 6 characters
324
+ </p>
325
+ </div>
326
+
327
+ <div>
328
+ <label class="block text-sm font-medium text-gray-700 mb-1">
329
+ Confirm password
330
+ </label>
331
+ <div class="relative">
332
+ <input type="password"
333
+ id="confirmPasswordInput"
334
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
335
+ placeholder="Repeat the new password"
336
+ minlength="6"
337
+ required>
338
+ <button type="button"
339
+ onclick="togglePasswordVisibility('confirmPasswordInput')"
340
+ class="absolute right-3 top-2 text-gray-500 hover:text-gray-700">
341
+ <i data-feather="eye" class="w-5 h-5"></i>
342
+ </button>
343
+ </div>
344
+ </div>
345
+
346
+ <div class="flex items-center space-x-2 mb-4">
347
+ <input type="checkbox"
348
+ id="generateRandomPassword"
349
+ class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
350
+ <label for="generateRandomPassword" class="text-sm text-gray-700">
351
+ Generate a random password
352
+ </label>
353
+ </div>
354
+
355
+ <div id="generatedPasswordContainer" class="hidden">
356
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
357
+ <div class="flex justify-between items-center mb-2">
358
+ <span class="font-medium text-gray-700">Generated password:</span>
359
+ <button type="button"
360
+ onclick="copyToClipboard('generatedPasswordText')"
361
+ class="text-blue-600 hover:text-blue-800 text-sm">
362
+ <i data-feather="copy" class="w-4 h-4"></i>
363
+ </button>
364
+ </div>
365
+ <div class="font-mono text-sm bg-white p-2 rounded border" id="generatedPasswordText"></div>
366
+ <p class="mt-2 text-xs text-red-600">
367
+ Save this password! It will not be shown again.
368
+ </p>
369
+ </div>
370
+ </div>
371
+
372
+ <div class="flex items-center space-x-2 mb-4">
373
+ <input type="checkbox"
374
+ id="forcePasswordChange"
375
+ class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
376
+ <label for="forcePasswordChange" class="text-sm text-gray-700">
377
+ The user must change their password at the next sign-in
378
+ </label>
379
+ </div>
380
+ </div>
381
+
382
+ <div class="flex justify-end space-x-3 mt-6">
383
+ <button onclick="generateRandomPassword()"
384
+ class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 flex items-center">
385
+ <i data-feather="refresh-cw" class="w-4 h-4 mr-2"></i>
386
+ Generate
387
+ </button>
388
+ <button onclick="closeModal()"
389
+ class="px-4 py-2 text-gray-600 hover:text-gray-800">
390
+ Cancel
391
+ </button>
392
+ <button onclick="changeUserPassword(${userId}, '${username}')"
393
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center">
394
+ <i data-feather="key" class="w-4 h-4 mr-2"></i>
395
+ Save password
396
+ </button>
397
+ </div>
398
+ </div>
399
+ `;
400
+
401
+ window.closeModal = () => {
402
+ document.body.removeChild(modal);
403
+ };
404
+
405
+ window.togglePasswordVisibility = (inputId) => {
406
+ const input = document.getElementById(inputId);
407
+ const icon = input.parentNode.querySelector('i');
408
+
409
+ if (input.type === 'password') {
410
+ input.type = 'text';
411
+ icon.setAttribute('data-feather', 'eye-off');
412
+ } else {
413
+ input.type = 'password';
414
+ icon.setAttribute('data-feather', 'eye');
415
+ }
416
+ feather.replace();
417
+ };
418
+
419
+ window.generateRandomPassword = () => {
420
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
421
+ const password = Array.from({length: 12}, () => chars[Math.floor(Math.random() * chars.length)]).join('');
422
+
423
+ document.getElementById('generatedPasswordText').textContent = password;
424
+ document.getElementById('generatedPasswordContainer').classList.remove('hidden');
425
+
426
+ // Automatically fill the fields
427
+ document.getElementById('newPasswordInput').value = password;
428
+ document.getElementById('confirmPasswordInput').value = password;
429
+
430
+ // Show the password so it can be copied
431
+ document.getElementById('newPasswordInput').type = 'text';
432
+ document.getElementById('confirmPasswordInput').type = 'text';
433
+
434
+ // Update the icons
435
+ feather.replace();
436
+ };
437
+
438
+ window.copyToClipboard = (elementId) => {
439
+ const text = document.getElementById(elementId).textContent;
440
+ navigator.clipboard.writeText(text).then(() => {
441
+ showNotification('Password copied to clipboard', 'success');
442
+ });
443
+ };
444
+
445
+ window.changeUserPassword = async (userId, username) => {
446
+ const newPassword = document.getElementById('newPasswordInput').value;
447
+ const confirmPassword = document.getElementById('confirmPasswordInput').value;
448
+ const generateRandom = document.getElementById('generateRandomPassword').checked;
449
+
450
+ // Validation
451
+ if (!newPassword && !generateRandom) {
452
+ showNotification('Enter a new password or generate a random one', 'error');
453
+ return;
454
+ }
455
+
456
+ if (newPassword && newPassword.length < 6) {
457
+ showNotification('Password must be at least 6 characters', 'error');
458
+ return;
459
+ }
460
+
461
+ if (newPassword !== confirmPassword) {
462
+ showNotification('Passwords do not match', 'error');
463
+ return;
464
+ }
465
+
466
+ try {
467
+ const token = localStorage.getItem('authToken');
468
+
469
+ let response;
470
+ if (generateRandom) {
471
+ // Use random password generation
472
+ response = await fetch(`/api/admin/users/${userId}/generate-password`, {
473
+ method: 'POST',
474
+ headers: {
475
+ 'Authorization': `Bearer ${token}`
476
+ }
477
+ });
478
+ } else {
479
+ // Use setting a specific password
480
+ response = await fetch(`/api/admin/users/${userId}/password`, {
481
+ method: 'PUT',
482
+ headers: {
483
+ 'Content-Type': 'application/json',
484
+ 'Authorization': `Bearer ${token}`
485
+ },
486
+ body: JSON.stringify({
487
+ new_password: newPassword
488
+ })
489
+ });
490
+ }
491
+
492
+ const data = await response.json();
493
+
494
+ if (response.ok) {
495
+ if (generateRandom) {
496
+ // Show the generated password
497
+ showNotification(
498
+ `New password for ${username}: ${data.generated_password}`,
499
+ 'success',
500
+ 10000 // Show it longer so there is time to copy it
501
+ );
502
+
503
+ // Automatic copying could be added here
504
+ navigator.clipboard.writeText(data.generated_password).then(() => {
505
+
506
+ });
507
+ } else {
508
+ showNotification(`Password for ${username} changed successfully`, 'success');
509
+ }
510
+
511
+ // If needed, mark that the password must be changed at next sign-in
512
+ const forceChange = document.getElementById('forcePasswordChange').checked;
513
+ if (forceChange) {
514
+ // Logic for marking "change password at sign-in" could go here
515
+ // For example, add a users.force_password_change column to the database
516
+
517
+ }
518
+
519
+ closeModal();
520
+ } else {
521
+ showNotification(data.detail || 'Error changing password', 'error');
522
+ }
523
+ } catch (error) {
524
+
525
+ showNotification('Network error while changing password', 'error');
526
+ }
527
+ };
528
+
529
+ document.body.appendChild(modal);
530
+ feather.replace();
531
+
532
+ // Handler for the generation checkbox
533
+ document.getElementById('generateRandomPassword').addEventListener('change', function() {
534
+ const passwordInputs = document.querySelectorAll('#newPasswordInput, #confirmPasswordInput');
535
+ passwordInputs.forEach(input => {
536
+ input.disabled = this.checked;
537
+ input.required = !this.checked;
538
+ });
539
+
540
+ if (this.checked) {
541
+ generateRandomPassword();
542
+ } else {
543
+ document.getElementById('generatedPasswordContainer').classList.add('hidden');
544
+ passwordInputs.forEach(input => {
545
+ input.value = '';
546
+ input.type = 'password';
547
+ });
548
+ feather.replace();
549
+ }
550
+ });
551
+ }
552
+
553
+ // Also add a function for the password reset button
554
+ function addPasswordResetButton() {
555
+ return `
556
+ <button onclick="resetUserPassword(${user.id}, '${user.username}')"
557
+ class="user-action-btn btn-reset-password text-xs px-2 py-1">
558
+ Reset password
559
+ </button>
560
+ `;
561
+ }
562
+
563
+ async function resetUserPassword(userId, username) {
564
+ if (!confirm(`Reset ${username}'s password to the default (ChangeMe123!)?`)) {
565
+ return;
566
+ }
567
+
568
+ try {
569
+ const token = localStorage.getItem('authToken');
570
+ const response = await fetch(`/api/admin/users/${userId}/reset-password`, {
571
+ method: 'POST',
572
+ headers: {
573
+ 'Authorization': `Bearer ${token}`
574
+ }
575
+ });
576
+
577
+ const data = await response.json();
578
+
579
+ if (response.ok) {
580
+ showNotification(
581
+ `Password for ${username} reset to: ${data.default_password}`,
582
+ 'warning',
583
+ 10000
584
+ );
585
+
586
+ navigator.clipboard.writeText(data.default_password).then(() => {
587
+
588
+ });
589
+ } else {
590
+ showNotification(data.detail || 'Error resetting password', 'error');
591
+ }
592
+ } catch (error) {
593
+
594
+ showNotification('Network error while resetting password', 'error');
595
+ }
596
+ }
597
+
598
+
599
+
600
+
601
+
602
+ // Blocking and unblocking go through the account as a whole (task 229):
603
+ // a PATCH to the status used to only clear the flag, while API keys, node tokens
604
+ // and running machines stayed as they were -- a blocked user kept working.
605
+ // A reason is required: it lands in the block log and the audit, and it is
606
+ // what later explains the decision.
607
+ async function blockUserWithReason(userId, username) {
608
+ const reason = prompt(`Why is «${username}» being blocked? The reason is recorded.`);
609
+ if (reason === null) return;
610
+ if (!String(reason).trim()) {
611
+ showNotification('A reason is required', 'error');
612
+ return;
613
+ }
614
+ await sendUserBlockAction(userId, 'block', reason);
615
+ }
616
+
617
+ async function unblockUserWithReason(userId, username) {
618
+ const reason = prompt(`Why is «${username}» being unblocked? The reason is recorded.`);
619
+ if (reason === null) return;
620
+ if (!String(reason).trim()) {
621
+ showNotification('A reason is required', 'error');
622
+ return;
623
+ }
624
+ await sendUserBlockAction(userId, 'unblock', reason);
625
+ }
626
+
627
+ async function sendUserBlockAction(userId, action, reason) {
628
+ try {
629
+ const token = localStorage.getItem('authToken');
630
+ const response = await fetch(`/api/admin/users/${userId}/${action}`, {
631
+ method: 'POST',
632
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
633
+ body: JSON.stringify({ reason: String(reason).trim() })
634
+ });
635
+ const data = await response.json();
636
+ if (!response.ok) {
637
+ showNotification(data.detail || 'Error changing status', 'error');
638
+ return;
639
+ }
640
+ // What exactly was revoked is stated by the server: the administrator
641
+ // must see that blocking did more than flip a flag.
642
+ const summary = data.summary || {};
643
+ const consequences = Object.keys(summary)
644
+ .filter(name => summary[name])
645
+ .map(name => `${name.replace(/_/g, ' ')}: ${summary[name]}`)
646
+ .join(', ');
647
+ showNotification(
648
+ `Account ${action === 'block' ? 'blocked' : 'unblocked'}${consequences ? ' — ' + consequences : ''}`,
649
+ 'success');
650
+ loadAllUsers();
651
+ } catch (error) {
652
+ showNotification('Network error', 'error');
653
+ }
654
+ }
655
+
656
+ async function showUserBlockHistory(userId, username) {
657
+ try {
658
+ const token = localStorage.getItem('authToken');
659
+ const response = await fetch(`/api/admin/users/${userId}/blocks`, {
660
+ headers: { 'Authorization': `Bearer ${token}` }
661
+ });
662
+ const data = await response.json();
663
+ if (!response.ok) {
664
+ showNotification(data.detail || 'Error loading the block log', 'error');
665
+ return;
666
+ }
667
+ renderUserBlockHistory(username, data.blocks || []);
668
+ } catch (error) {
669
+ showNotification('Network error', 'error');
670
+ }
671
+ }
672
+
673
+ function renderUserBlockHistory(username, blocks) {
674
+ const modal = document.createElement('div');
675
+ modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
676
+ modal.innerHTML = `
677
+ <div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
678
+ <h3 class="text-lg font-semibold mb-4">Block log — ${escapeUserHtml(username)}</h3>
679
+ ${blocks.length ? `
680
+ <table class="w-full text-sm responsive-table">
681
+ <thead><tr class="text-left text-gray-500 border-b">
682
+ <th class="py-2 pr-4">When</th><th class="py-2 pr-4">Action</th>
683
+ <th class="py-2 pr-4">Reason</th><th class="py-2 pr-4">Administrator</th>
684
+ </tr></thead>
685
+ <tbody>${blocks.map(entry => `
686
+ <tr class="border-b">
687
+ <td class="py-2 pr-4">${escapeUserHtml(entry.created_at)}</td>
688
+ <td class="py-2 pr-4">${escapeUserHtml(entry.action)}</td>
689
+ <td class="py-2 pr-4">${escapeUserHtml(entry.reason)}</td>
690
+ <td class="py-2 pr-4">${escapeUserHtml(entry.admin || entry.admin_id || '—')}</td>
691
+ </tr>`).join('')}
692
+ </tbody>
693
+ </table>`
694
+ : '<p class="text-gray-500">This account has never been blocked.</p>'}
695
+ <div class="flex justify-end mt-4">
696
+ <button class="px-4 py-2 bg-gray-200 rounded-lg" onclick="this.closest('.fixed').remove()">Close</button>
697
+ </div>
698
+ </div>
699
+ `;
700
+ document.body.appendChild(modal);
701
+ }
702
+
703
+ function escapeUserHtml(value) {
704
+ const holder = document.createElement('div');
705
+ holder.textContent = value === null || value === undefined ? '' : String(value);
706
+ return holder.innerHTML;
707
+ }
708
+
709
+ // Existing user management functions
710
+ async function toggleUserStatus(userId, newStatus) {
711
+ try {
712
+ const token = localStorage.getItem('authToken');
713
+ const response = await fetch(`/api/admin/users/${userId}`, {
714
+ method: 'PATCH',
715
+ headers: {
716
+ 'Content-Type': 'application/json',
717
+ 'Authorization': `Bearer ${token}`
718
+ },
719
+ body: JSON.stringify({ status: newStatus })
720
+ });
721
+
722
+ const data = await response.json();
723
+
724
+ if (response.ok) {
725
+ showNotification(`User status changed to "${newStatus === 'active' ? 'active' : 'blocked'}"`, 'success');
726
+ loadAllUsers();
727
+ } else {
728
+ showNotification(data.detail || 'Error changing status', 'error');
729
+ }
730
+ } catch (error) {
731
+ showNotification('Network error', 'error');
732
+ }
733
+ }
734
+
735
+ async function makeUserAdmin(userId) {
736
+ try {
737
+ const token = localStorage.getItem('authToken');
738
+ const response = await fetch(`/api/admin/users/${userId}`, {
739
+ method: 'PATCH',
740
+ headers: {
741
+ 'Content-Type': 'application/json',
742
+ 'Authorization': `Bearer ${token}`
743
+ },
744
+ body: JSON.stringify({ role: ROLE_ADMIN })
745
+ });
746
+
747
+ const data = await response.json();
748
+
749
+ if (response.ok) {
750
+ showNotification('User is now an administrator', 'success');
751
+ loadAllUsers();
752
+ } else {
753
+ showNotification(data.detail || 'Error changing role', 'error');
754
+ }
755
+ } catch (error) {
756
+ showNotification('Network error', 'error');
757
+ }
758
+ }
759
+
760
+ async function makeUserClient(userId) {
761
+ try {
762
+ const token = localStorage.getItem('authToken');
763
+ const response = await fetch(`/api/admin/users/${userId}`, {
764
+ method: 'PATCH',
765
+ headers: {
766
+ 'Content-Type': 'application/json',
767
+ 'Authorization': `Bearer ${token}`
768
+ },
769
+ body: JSON.stringify({ role: ROLE_CLIENT })
770
+ });
771
+
772
+ const data = await response.json();
773
+
774
+ if (response.ok) {
775
+ showNotification('User is now a client', 'success');
776
+ loadAllUsers();
777
+ } else {
778
+ showNotification(data.detail || 'Error changing role', 'error');
779
+ }
780
+ } catch (error) {
781
+ showNotification('Network error', 'error');
782
+ }
783
+ }
784
+