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.
- keepup/THIRD-PARTY.md +44 -0
- keepup/__init__.py +41 -0
- keepup/api_versions.py +100 -0
- keepup/audit.py +499 -0
- keepup/auth/__init__.py +7 -0
- keepup/auth/config.py +128 -0
- keepup/auth/dependencies.py +485 -0
- keepup/auth/dto/__init__.py +0 -0
- keepup/auth/dto/token.py +31 -0
- keepup/auth/factory.py +28 -0
- keepup/auth/login_throttle.py +151 -0
- keepup/auth/oidc.py +245 -0
- keepup/auth/oidc_policy.py +111 -0
- keepup/auth/oidc_routes.py +288 -0
- keepup/auth/panel_session.py +220 -0
- keepup/auth/permissions.py +59 -0
- keepup/auth/providers/__init__.py +0 -0
- keepup/auth/providers/base.py +168 -0
- keepup/auth/providers/local.py +180 -0
- keepup/auth/routes.py +660 -0
- keepup/auth/seed_accounts.py +322 -0
- keepup/auth/session_lifetime.py +104 -0
- keepup/auth/signing_key.py +138 -0
- keepup/auth/websocket.py +86 -0
- keepup/cluster.py +634 -0
- keepup/db.py +663 -0
- keepup/events.py +764 -0
- keepup/factory.py +484 -0
- keepup/instance.py +46 -0
- keepup/integrations.py +260 -0
- keepup/locks.py +412 -0
- keepup/logging_setup.py +690 -0
- keepup/metrics.py +818 -0
- keepup/metrics_retention.py +376 -0
- keepup/modules.py +572 -0
- keepup/notification_bus.py +355 -0
- keepup/plugins/__init__.py +9 -0
- keepup/plugins/admin.py +246 -0
- keepup/plugins/base.py +234 -0
- keepup/plugins/enablement.py +184 -0
- keepup/plugins/registry.py +171 -0
- keepup/plugins/route_mask.py +338 -0
- keepup/plugins/routes.py +376 -0
- keepup/roles.py +17 -0
- keepup/scheduler.py +93 -0
- keepup/schema.py +295 -0
- keepup/sections.json +104 -0
- keepup/settings.py +184 -0
- keepup/static/css/aos.css +1 -0
- keepup/static/css/main_nebula.css +232 -0
- keepup/static/css/main_new.css +852 -0
- keepup/static/css/tailwind.css +1 -0
- keepup/static/index_nebula.html +293 -0
- keepup/static/index_new.html +286 -0
- keepup/static/js/aos.js +1 -0
- keepup/static/js/feather-icons.js +13 -0
- keepup/static/js/main_new.js +1861 -0
- keepup/static/js/tailwind.js +83 -0
- keepup/static/modules/css/background_tasks.css +233 -0
- keepup/static/modules/css/cluster.css +16 -0
- keepup/static/modules/css/event_manager.css +386 -0
- keepup/static/modules/css/integration_logs.css +33 -0
- keepup/static/modules/css/metrics.css +115 -0
- keepup/static/modules/css/modules.css +189 -0
- keepup/static/modules/css/themes.css +563 -0
- keepup/static/modules/css/users.css +278 -0
- keepup/static/modules/js/background_tasks.js +657 -0
- keepup/static/modules/js/chart.js +14 -0
- keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup/static/modules/js/cluster.js +363 -0
- keepup/static/modules/js/event_manager.js +979 -0
- keepup/static/modules/js/integration_logs.js +767 -0
- keepup/static/modules/js/metrics.js +908 -0
- keepup/static/modules/js/modules.js +1086 -0
- keepup/static/modules/js/themes.js +653 -0
- keepup/static/modules/js/users.js +784 -0
- keepup/tables.py +302 -0
- keepup/themes.py +496 -0
- keepup/web.py +182 -0
- keepup_admin-0.1.0.dist-info/METADATA +117 -0
- keepup_admin-0.1.0.dist-info/RECORD +86 -0
- keepup_admin-0.1.0.dist-info/WHEEL +5 -0
- keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
- keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
- 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, '&').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 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
|
+
|