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.
- keepup_http/__init__.py +38 -0
- keepup_http/api_docs.py +76 -0
- keepup_http/body_limit.py +156 -0
- keepup_http/events_api.py +237 -0
- keepup_http/factory.py +562 -0
- keepup_http/lock_routes.py +155 -0
- keepup_http/plugin.py +53 -0
- keepup_http/routes.py +549 -0
- keepup_http/static/css/aos.css +1 -0
- keepup_http/static/css/main_nebula.css +232 -0
- keepup_http/static/css/main_new.css +869 -0
- keepup_http/static/css/tailwind.css +1 -0
- keepup_http/static/index_nebula.html +282 -0
- keepup_http/static/index_new.html +275 -0
- keepup_http/static/js/aos.js +1 -0
- keepup_http/static/js/feather-icons.js +13 -0
- keepup_http/static/js/layout_bootstrap.js +29 -0
- keepup_http/static/js/main_new.js +2076 -0
- keepup_http/static/js/tailwind.js +83 -0
- keepup_http/static/modules/css/background_tasks.css +233 -0
- keepup_http/static/modules/css/cluster.css +16 -0
- keepup_http/static/modules/css/event_manager.css +386 -0
- keepup_http/static/modules/css/integration_logs.css +33 -0
- keepup_http/static/modules/css/metrics.css +115 -0
- keepup_http/static/modules/css/modules.css +189 -0
- keepup_http/static/modules/css/themes.css +563 -0
- keepup_http/static/modules/css/users.css +278 -0
- keepup_http/static/modules/js/background_tasks.js +695 -0
- keepup_http/static/modules/js/chart.js +14 -0
- keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup_http/static/modules/js/cluster.js +374 -0
- keepup_http/static/modules/js/event_manager.js +1007 -0
- keepup_http/static/modules/js/integration_logs.js +795 -0
- keepup_http/static/modules/js/metrics.js +929 -0
- keepup_http/static/modules/js/modules.js +1128 -0
- keepup_http/static/modules/js/themes.js +684 -0
- keepup_http/static/modules/js/users.js +898 -0
- keepup_http/web.py +214 -0
- keepup_http-0.5.0.dist-info/METADATA +29 -0
- keepup_http-0.5.0.dist-info/RECORD +43 -0
- keepup_http-0.5.0.dist-info/WHEEL +5 -0
- keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
110
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
});
|