@andrian.yablonskyy/thub-coordinator 1.0.43 → 1.1.1
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.
- package/bin/thub-admin.js +98 -7
- package/package.json +2 -2
- package/public/css/thub.css +35 -18
- package/public/js/actions.js +10 -0
- package/public/js/client-config.js +0 -1
- package/src/api/agent.js +36 -9
- package/src/auth.js +39 -6
- package/src/db/migrations/026_users.sql +74 -0
- package/src/server.js +1 -1
- package/src/services/admin-users.js +291 -39
- package/src/services/agents.js +1 -1
- package/src/services/list-prefs.js +4 -0
- package/src/services/registry.js +14 -3
- package/src/web/routes.js +213 -32
- package/test/admin-users.test.js +4 -4
- package/test/groups-tab.test.js +48 -3
- package/test/users.test.js +269 -0
- package/test/views.test.js +3 -1
- package/views/admin/agents.pug +7 -8
- package/views/admin/users.pug +249 -0
- package/views/help/_agent-cli.pug +4 -2
- package/views/help/_agent-setup.pug +28 -23
- package/views/help/_ci.pug +4 -4
- package/views/help/_coordinator.pug +4 -3
- package/views/help/_env.pug +1 -1
- package/views/help/_overview.pug +1 -1
- package/views/help/_quick-start.pug +5 -5
- package/views/help/_troubleshooting.pug +1 -0
- package/views/index.pug +1 -1
- package/views/layout.pug +29 -15
- package/views/mixins/client-config.pug +14 -9
- package/views/mixins/resource.pug +23 -21
- package/views/mixins/updates.pug +2 -2
- package/views/profile.pug +56 -0
- package/views/resources/list.pug +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/src/services/admin-users.js
|
|
3
|
-
* @description
|
|
3
|
+
* @description User accounts (§10.3): roles, blocking, passwords, each user's access key, and per-user profile settings (§10.1)
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
const crypto = require('node:crypto'),
|
|
17
17
|
{ promisify } = require('node:util'),
|
|
18
18
|
{ v4: uuid } = require('uuid'),
|
|
19
|
-
{ normalize: normalizeListPrefs } = require('./list-prefs')
|
|
19
|
+
{ normalize: normalizeListPrefs } = require('./list-prefs'),
|
|
20
|
+
{ generateToken, hashToken } = require('./tokens');
|
|
20
21
|
|
|
21
22
|
// Stored as "<salt hex>:<scrypt hex>". The synchronous one is only for the
|
|
22
23
|
// CLI and startup (thub-admin create-admin, the bootstrap password); every
|
|
@@ -36,7 +37,7 @@ async function hashPasswordAsync(password){
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async function verifyPassword(password, stored){
|
|
39
|
-
const [salt, derived] = stored.split(':'),
|
|
40
|
+
const [salt, derived] = (stored || DUMMY_HASH).split(':'),
|
|
40
41
|
check = await scrypt(String(password ?? ''), salt, 64);
|
|
41
42
|
return crypto.timingSafeEqual(check, Buffer.from(derived, 'hex'));
|
|
42
43
|
}
|
|
@@ -50,12 +51,48 @@ const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex')),
|
|
|
50
51
|
// free-text field, so a user can't accidentally set a 3-second or 10-year
|
|
51
52
|
// idle timeout.
|
|
52
53
|
SESSION_TIMEOUT_OPTIONS_MIN = [15, 30, 60, 120, 240, 480, 1440],
|
|
53
|
-
THEMES = ['auto', 'light', 'dark']
|
|
54
|
+
THEMES = ['auto', 'light', 'dark'],
|
|
55
|
+
|
|
56
|
+
// §10.3. user: the Agent only (submit jobs, read their own results);
|
|
57
|
+
// maintainer: + the dashboard, without its security features; admin: all.
|
|
58
|
+
ROLES = ['user', 'maintainer', 'admin'],
|
|
59
|
+
DASHBOARD_ROLES = ['maintainer', 'admin'],
|
|
60
|
+
USERNAME_RE = /^[A-Za-z0-9._@+-]{1,64}$/,
|
|
61
|
+
EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,63}$/,
|
|
62
|
+
// Access keys: thk_ + 256 random bits, stored hashed like every token (§12).
|
|
63
|
+
KEY_PREFIX = 'thk';
|
|
64
|
+
|
|
65
|
+
// How a role is shown: User, Maintainer, Admin (stored and typed lowercase).
|
|
66
|
+
function roleLabel(role){
|
|
67
|
+
return role ? role.charAt(0).toUpperCase() + role.slice(1) : role;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The role a request names, in any case: `viewer` (before §10.3) is now
|
|
71
|
+
// maintainer.
|
|
72
|
+
function normalizeRole(role){
|
|
73
|
+
const lower = String(role ?? '').trim().toLowerCase(),
|
|
74
|
+
r = lower === 'viewer' ? 'maintainer' : lower;
|
|
75
|
+
if (!ROLES.includes(r)){
|
|
76
|
+
throw Object.assign(new Error(`role must be one of: ${ROLES.join(', ')}`), { status: 400 });
|
|
77
|
+
}
|
|
78
|
+
return r;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bad(message, status = 400){
|
|
82
|
+
return Object.assign(new Error(message), { status });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A readable one-time password: shown once to the admin, changed at the
|
|
86
|
+
// first sign-in.
|
|
87
|
+
function tempPassword(){
|
|
88
|
+
return crypto.randomBytes(12).toString('base64url');
|
|
89
|
+
}
|
|
54
90
|
|
|
55
91
|
function toProfile(row){
|
|
56
92
|
return {
|
|
57
93
|
id: row.id,
|
|
58
94
|
username: row.username,
|
|
95
|
+
email: row.email || '',
|
|
59
96
|
role: row.role,
|
|
60
97
|
firstName: row.first_name,
|
|
61
98
|
lastName: row.last_name,
|
|
@@ -63,7 +100,12 @@ function toProfile(row){
|
|
|
63
100
|
timezone: row.timezone,
|
|
64
101
|
theme: row.theme,
|
|
65
102
|
sessionTimeoutMin: row.session_timeout_min,
|
|
66
|
-
listPrefs: parseListPrefs(row.list_prefs)
|
|
103
|
+
listPrefs: parseListPrefs(row.list_prefs),
|
|
104
|
+
blocked: Boolean(row.blocked_at),
|
|
105
|
+
blockedAt: row.blocked_at || null,
|
|
106
|
+
mustChangePassword: Boolean(row.must_change_password),
|
|
107
|
+
hasPassword: Boolean(row.password_hash),
|
|
108
|
+
canUseDashboard: DASHBOARD_ROLES.includes(row.role)
|
|
67
109
|
};
|
|
68
110
|
}
|
|
69
111
|
|
|
@@ -77,67 +119,257 @@ function parseListPrefs(json){
|
|
|
77
119
|
}
|
|
78
120
|
}
|
|
79
121
|
|
|
80
|
-
// §10:
|
|
81
|
-
//
|
|
82
|
-
|
|
122
|
+
// §10.3: every account — dashboard sign-in for maintainers and admins, an
|
|
123
|
+
// access key for the Agent for anyone. `events` (the audit log) is optional
|
|
124
|
+
// so the profile/password functions can be used on their own (tests).
|
|
125
|
+
function createAdminUsersService(db, { events } = {}){
|
|
126
|
+
const audit = (id, type, data) => events?.record('user', id, type, data);
|
|
127
|
+
|
|
83
128
|
function getByUsername(username){
|
|
84
|
-
return db.prepare('SELECT * FROM
|
|
129
|
+
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
|
85
130
|
}
|
|
86
131
|
|
|
87
132
|
function getById(id){
|
|
88
|
-
const row = db.prepare('SELECT * FROM
|
|
133
|
+
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
89
134
|
return row ? toProfile(row) : null;
|
|
90
135
|
}
|
|
91
136
|
|
|
92
137
|
function count(){
|
|
93
|
-
return db.prepare('SELECT COUNT(*) AS n FROM
|
|
138
|
+
return db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
|
94
139
|
}
|
|
95
140
|
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
141
|
+
function activeAdmins(){
|
|
142
|
+
return db.prepare('SELECT COUNT(*) AS n FROM users WHERE role = \'admin\' AND blocked_at IS NULL').get().n;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The access key of a user: its agents row (hash, hint, last use, the Agent
|
|
146
|
+
// version it reported), or null.
|
|
147
|
+
function keyOf(userId){
|
|
148
|
+
return db.prepare('SELECT * FROM agents WHERE user_id = ?').get(userId) || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Users page: every user with their key's state.
|
|
152
|
+
function list(){
|
|
153
|
+
return db.prepare(
|
|
154
|
+
`SELECT u.*, a.id AS key_id, a.token_hint, a.token_created_at, a.last_used_at AS key_last_used_at,
|
|
155
|
+
a.revoked_at AS key_revoked_at, a.version AS agent_version, a.update_to AS agent_update_to
|
|
156
|
+
FROM users u LEFT JOIN agents a ON a.user_id = u.id
|
|
157
|
+
ORDER BY u.username COLLATE NOCASE`
|
|
158
|
+
).all().map((row) => ({
|
|
159
|
+
...toProfile(row),
|
|
160
|
+
createdAt: row.created_at,
|
|
161
|
+
key: row.key_id && !row.key_revoked_at
|
|
162
|
+
? { id: row.key_id, hint: row.token_hint, createdAt: row.token_created_at, lastUsedAt: row.key_last_used_at,
|
|
163
|
+
version: row.agent_version, updateTo: row.agent_update_to }
|
|
164
|
+
: null
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function checkUsername(username, exceptId){
|
|
169
|
+
const name = String(username ?? '').trim();
|
|
170
|
+
if (!USERNAME_RE.test(name)){
|
|
171
|
+
throw bad('Username: 1–64 letters, digits or . _ @ + -');
|
|
172
|
+
}
|
|
173
|
+
const owner = getByUsername(name);
|
|
174
|
+
if (owner && owner.id !== exceptId){
|
|
175
|
+
throw bad(`Username "${name}" is already taken`, 409);
|
|
176
|
+
}
|
|
177
|
+
return name;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function checkEmail(email, exceptId){
|
|
181
|
+
const value = String(email ?? '').trim();
|
|
182
|
+
if (!EMAIL_RE.test(value)){
|
|
183
|
+
throw bad('Email: a valid address, like jane@example.com');
|
|
184
|
+
}
|
|
185
|
+
const owner = db.prepare('SELECT id FROM users WHERE lower(email) = lower(?)').get(value);
|
|
186
|
+
if (owner && owner.id !== exceptId){
|
|
187
|
+
throw bad(`Email "${value}" is already used by another user`, 409);
|
|
188
|
+
}
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Nobody may remove the last way in: the last active admin can't be
|
|
193
|
+
// blocked, demoted or deleted, and an admin can't do that to themselves.
|
|
194
|
+
function guardAdmins(target, { actorId, losesAdmin }){
|
|
195
|
+
if (!losesAdmin || target.role !== 'admin' || target.blocked_at){
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (actorId && actorId === target.id){
|
|
199
|
+
throw bad('You can\'t block, demote or delete your own account — ask another admin', 409);
|
|
200
|
+
}
|
|
201
|
+
if (activeAdmins() <= 1){
|
|
202
|
+
throw bad('This is the last active admin — make someone else an admin first', 409);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// `email` is required where people create users (dashboard, thub-admin
|
|
207
|
+
// user add); the bootstrap admin (THUB_BOOTSTRAP_ADMIN_PASSWORD) and older
|
|
208
|
+
// callers may leave it out — the Users page flags a missing one.
|
|
209
|
+
// A dashboard role without a password gets a temporary one, returned
|
|
210
|
+
// once, to be changed at the first sign-in.
|
|
211
|
+
function create({ username, email, role, password, firstName, lastName, requireEmail = false }, { by } = {}){
|
|
212
|
+
const id = `usr_${uuid()}`,
|
|
213
|
+
name = checkUsername(username),
|
|
214
|
+
r = normalizeRole(role),
|
|
215
|
+
mail = email || requireEmail ? checkEmail(email) : '',
|
|
216
|
+
temp = !password && DASHBOARD_ROLES.includes(r) ? tempPassword() : null,
|
|
217
|
+
pw = password || temp;
|
|
218
|
+
db.prepare(
|
|
219
|
+
`INSERT INTO users (id, username, email, password_hash, role, first_name, last_name, must_change_password, created_at)
|
|
220
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
221
|
+
).run(id, name, mail, pw ? hashPassword(pw) : null, r, firstName || null, lastName || null, temp ? 1 : 0, new Date().toISOString());
|
|
222
|
+
audit(id, 'user.created', { by, username: name, role: r });
|
|
223
|
+
return { id, username: name, role: r, tempPassword: temp };
|
|
106
224
|
}
|
|
107
225
|
|
|
108
226
|
// Exceptional password reset (README §13.1): resets an *existing*
|
|
109
227
|
// user's password without touching their role (a reset shouldn't
|
|
110
|
-
// silently promote
|
|
228
|
+
// silently promote anyone to admin), or creates one as admin if the
|
|
111
229
|
// named account doesn't exist yet — the original first-run bootstrap
|
|
112
230
|
// case, now handled by the same path.
|
|
113
231
|
function resetPassword({ username, password, role = 'admin' }){
|
|
114
232
|
const existing = getByUsername(username);
|
|
115
233
|
if (existing){
|
|
116
|
-
db.prepare('UPDATE
|
|
234
|
+
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(hashPassword(password), existing.id);
|
|
117
235
|
return { id: existing.id, username, role: existing.role };
|
|
118
236
|
}
|
|
119
237
|
return create({ username, password, role });
|
|
120
238
|
}
|
|
121
239
|
|
|
240
|
+
// Users page: a new temporary password for someone who lost theirs.
|
|
241
|
+
function adminResetPassword(id, { by } = {}){
|
|
242
|
+
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
243
|
+
if (!target){
|
|
244
|
+
throw bad('Unknown user', 404);
|
|
245
|
+
}
|
|
246
|
+
if (!DASHBOARD_ROLES.includes(target.role)){
|
|
247
|
+
throw bad(`${target.username} has no dashboard access (role User) — there's no password to reset`);
|
|
248
|
+
}
|
|
249
|
+
const temp = tempPassword();
|
|
250
|
+
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run(hashPassword(temp), id);
|
|
251
|
+
audit(id, 'user.password_reset', { by });
|
|
252
|
+
return temp;
|
|
253
|
+
}
|
|
254
|
+
|
|
122
255
|
// Self-service password change (§10.1) — requires knowing the *current*
|
|
123
|
-
// password, unlike
|
|
124
|
-
// break-glass path, which deliberately doesn't need it).
|
|
256
|
+
// password, unlike the resets above. Clears "must change".
|
|
125
257
|
async function changePassword(id, currentPassword, newPassword){
|
|
126
|
-
const row = db.prepare('SELECT * FROM
|
|
127
|
-
if (!row || !(await verifyPassword(currentPassword, row.password_hash))){
|
|
258
|
+
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
259
|
+
if (!row || !(await verifyPassword(currentPassword, row.password_hash)) || !row.password_hash){
|
|
128
260
|
throw Object.assign(new Error('Current password is incorrect'), { status: 401 });
|
|
129
261
|
}
|
|
130
262
|
if (!newPassword){
|
|
131
263
|
throw Object.assign(new Error('New password must not be empty'), { status: 400 });
|
|
132
264
|
}
|
|
133
|
-
|
|
265
|
+
if (newPassword === currentPassword){
|
|
266
|
+
throw bad('Choose a password different from the current one');
|
|
267
|
+
}
|
|
268
|
+
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
|
|
269
|
+
audit(id, 'user.password_changed', {});
|
|
134
270
|
}
|
|
135
271
|
|
|
136
|
-
// Async (thread pool); the same work whether or not the user exists
|
|
272
|
+
// Async (thread pool); the same work whether or not the user exists, or
|
|
273
|
+
// has a password at all.
|
|
137
274
|
async function verify(username, password){
|
|
138
275
|
const user = getByUsername(String(username ?? '')),
|
|
139
276
|
ok = await verifyPassword(password, user ? user.password_hash : DUMMY_HASH);
|
|
140
|
-
return user && ok ? toProfile(user) : null;
|
|
277
|
+
return user && user.password_hash && ok ? toProfile(user) : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Users page edit: username, email, role, names. Guards the last admin.
|
|
281
|
+
function update(id, fields, { by, actorId } = {}){
|
|
282
|
+
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
283
|
+
if (!target){
|
|
284
|
+
throw bad('Unknown user', 404);
|
|
285
|
+
}
|
|
286
|
+
const next = {
|
|
287
|
+
username: fields.username !== undefined ? checkUsername(fields.username, id) : target.username,
|
|
288
|
+
email: fields.email !== undefined ? checkEmail(fields.email, id) : target.email,
|
|
289
|
+
role: fields.role !== undefined ? normalizeRole(fields.role) : target.role,
|
|
290
|
+
first_name: fields.firstName !== undefined ? fields.firstName || null : target.first_name,
|
|
291
|
+
last_name: fields.lastName !== undefined ? fields.lastName || null : target.last_name
|
|
292
|
+
};
|
|
293
|
+
guardAdmins(target, { actorId, losesAdmin: next.role !== 'admin' });
|
|
294
|
+
// Promoted to the dashboard without a password: a temporary one.
|
|
295
|
+
const temp = DASHBOARD_ROLES.includes(next.role) && !target.password_hash ? tempPassword() : null;
|
|
296
|
+
db.transaction(() => {
|
|
297
|
+
db.prepare('UPDATE users SET username = ?, email = ?, role = ?, first_name = ?, last_name = ? WHERE id = ?')
|
|
298
|
+
.run(next.username, next.email, next.role, next.first_name, next.last_name, id);
|
|
299
|
+
if (temp){
|
|
300
|
+
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run(hashPassword(temp), id);
|
|
301
|
+
}
|
|
302
|
+
// The key's agents row carries the name the job lists show.
|
|
303
|
+
db.prepare('UPDATE agents SET name = ? WHERE user_id = ?').run(next.username, id);
|
|
304
|
+
})();
|
|
305
|
+
const changed = Object.keys(next).filter((k) => next[k] !== target[k]);
|
|
306
|
+
if (changed.length){
|
|
307
|
+
audit(id, 'user.updated', { by, changed, ...(next.role !== target.role ? { role: { from: target.role, to: next.role } } : {}) });
|
|
308
|
+
}
|
|
309
|
+
return { user: getById(id), tempPassword: temp };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Block: no sign-in, no Agent — at once (the session check and the key
|
|
313
|
+
// check read it on every request). Unblock gives both back.
|
|
314
|
+
function setBlocked(id, blocked, { by, actorId } = {}){
|
|
315
|
+
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
316
|
+
if (!target){
|
|
317
|
+
throw bad('Unknown user', 404);
|
|
318
|
+
}
|
|
319
|
+
if (blocked){
|
|
320
|
+
guardAdmins(target, { actorId, losesAdmin: true });
|
|
321
|
+
}
|
|
322
|
+
db.prepare('UPDATE users SET blocked_at = ? WHERE id = ?').run(blocked ? new Date().toISOString() : null, id);
|
|
323
|
+
audit(id, blocked ? 'user.blocked' : 'user.unblocked', { by });
|
|
324
|
+
return getById(id);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Delete: the account goes; its jobs stay, still listed under its name —
|
|
328
|
+
// its key's agents row is kept, revoked and unlinked.
|
|
329
|
+
function remove(id, { by, actorId } = {}){
|
|
330
|
+
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
331
|
+
if (!target){
|
|
332
|
+
throw bad('Unknown user', 404);
|
|
333
|
+
}
|
|
334
|
+
guardAdmins(target, { actorId, losesAdmin: true });
|
|
335
|
+
db.transaction(() => {
|
|
336
|
+
db.prepare('UPDATE agents SET user_id = NULL, revoked_at = COALESCE(revoked_at, ?) WHERE user_id = ?').run(new Date().toISOString(), id);
|
|
337
|
+
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
|
338
|
+
})();
|
|
339
|
+
audit(id, 'user.deleted', { by, username: target.username });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// A new access key for the user — their first, or one replacing the old
|
|
343
|
+
// (which stops working at once). Returned once; only its hash and last
|
|
344
|
+
// characters are kept. The key's row stays the same, so the user's jobs
|
|
345
|
+
// stay theirs.
|
|
346
|
+
function issueKey(userId, { by } = {}){
|
|
347
|
+
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
|
348
|
+
if (!user){
|
|
349
|
+
throw bad('Unknown user', 404);
|
|
350
|
+
}
|
|
351
|
+
const key = generateToken(KEY_PREFIX),
|
|
352
|
+
now = new Date().toISOString(),
|
|
353
|
+
existing = keyOf(userId);
|
|
354
|
+
if (existing){
|
|
355
|
+
db.prepare('UPDATE agents SET token_hash = ?, token_hint = ?, token_created_at = ?, revoked_at = NULL, name = ? WHERE id = ?')
|
|
356
|
+
.run(hashToken(key), key.slice(-4), now, user.username, existing.id);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
db.prepare(`INSERT INTO agents (id, name, kind, token_hash, token_hint, token_created_at, created_at, user_id)
|
|
360
|
+
VALUES (?, ?, 'cli', ?, ?, ?, ?, ?)`)
|
|
361
|
+
.run(`agt_${uuid()}`, user.username, hashToken(key), key.slice(-4), now, now, userId);
|
|
362
|
+
}
|
|
363
|
+
audit(userId, existing ? 'user.key_rotated' : 'user.key_created', { by });
|
|
364
|
+
return key;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function revokeKey(userId, { by } = {}){
|
|
368
|
+
const n = db.prepare('UPDATE agents SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL').run(new Date().toISOString(), userId).changes;
|
|
369
|
+
if (n){
|
|
370
|
+
audit(userId, 'user.key_revoked', { by });
|
|
371
|
+
}
|
|
372
|
+
return n > 0;
|
|
141
373
|
}
|
|
142
374
|
|
|
143
375
|
// Partial update — only the fields present in `fields` are touched, so a
|
|
@@ -147,10 +379,10 @@ function createAdminUsersService(db){
|
|
|
147
379
|
// silent overwrite.
|
|
148
380
|
function updateProfile(id, fields){
|
|
149
381
|
if (fields.username !== undefined){
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
382
|
+
checkUsername(fields.username, id);
|
|
383
|
+
}
|
|
384
|
+
if (fields.email !== undefined && fields.email !== ''){
|
|
385
|
+
checkEmail(fields.email, id);
|
|
154
386
|
}
|
|
155
387
|
if (fields.theme !== undefined && !THEMES.includes(fields.theme)){
|
|
156
388
|
throw Object.assign(new Error(`theme must be one of: ${THEMES.join(', ')}`), { status: 400 });
|
|
@@ -163,6 +395,7 @@ function createAdminUsersService(db){
|
|
|
163
395
|
|
|
164
396
|
const columns = {
|
|
165
397
|
username: fields.username,
|
|
398
|
+
email: fields.email !== undefined ? String(fields.email).trim() : undefined,
|
|
166
399
|
first_name: fields.firstName,
|
|
167
400
|
last_name: fields.lastName,
|
|
168
401
|
avatar_path: fields.avatarPath,
|
|
@@ -175,38 +408,57 @@ function createAdminUsersService(db){
|
|
|
175
408
|
return getById(id);
|
|
176
409
|
}
|
|
177
410
|
|
|
178
|
-
db.prepare(`UPDATE
|
|
411
|
+
db.prepare(`UPDATE users SET ${set.map(([k]) => `${k} = ?`).join(', ')} WHERE id = ?`).run(
|
|
179
412
|
...set.map(([, v]) => v),
|
|
180
413
|
id
|
|
181
414
|
);
|
|
415
|
+
if (fields.username !== undefined){
|
|
416
|
+
db.prepare('UPDATE agents SET name = ? WHERE user_id = ?').run(fields.username, id);
|
|
417
|
+
}
|
|
182
418
|
return getById(id);
|
|
183
419
|
}
|
|
184
420
|
|
|
185
421
|
// Remembers one list page's view (size/sort/dir, §10.1) for this user;
|
|
186
422
|
// stored normalized, so only valid values ever reach the profile.
|
|
187
423
|
function setListPrefs(id, list, prefs){
|
|
188
|
-
const row = db.prepare('SELECT list_prefs FROM
|
|
424
|
+
const row = db.prepare('SELECT list_prefs FROM users WHERE id = ?').get(id);
|
|
189
425
|
if (!row){
|
|
190
426
|
throw Object.assign(new Error('Unknown user'), { status: 404 });
|
|
191
427
|
}
|
|
192
428
|
const all = { ...parseListPrefs(row.list_prefs), [list]: normalizeListPrefs(list, prefs) };
|
|
193
|
-
db.prepare('UPDATE
|
|
429
|
+
db.prepare('UPDATE users SET list_prefs = ? WHERE id = ?').run(JSON.stringify(all), id);
|
|
194
430
|
return getById(id);
|
|
195
431
|
}
|
|
196
432
|
|
|
433
|
+
// Users page: recent account events (who did what to which user).
|
|
434
|
+
function recentEvents(limit = 30){
|
|
435
|
+
return db.prepare('SELECT * FROM events WHERE entity = \'user\' ORDER BY id DESC LIMIT ?').all(limit)
|
|
436
|
+
.map((e) => ({ ...e, data: e.data ? JSON.parse(e.data) : {} }));
|
|
437
|
+
}
|
|
438
|
+
|
|
197
439
|
return {
|
|
198
440
|
getByUsername,
|
|
199
441
|
getById,
|
|
200
442
|
count,
|
|
443
|
+
list,
|
|
444
|
+
keyOf,
|
|
201
445
|
create,
|
|
446
|
+
update,
|
|
447
|
+
setBlocked,
|
|
448
|
+
remove,
|
|
202
449
|
resetPassword,
|
|
450
|
+
adminResetPassword,
|
|
203
451
|
changePassword,
|
|
204
452
|
verify,
|
|
205
453
|
updateProfile,
|
|
206
454
|
setListPrefs,
|
|
455
|
+
issueKey,
|
|
456
|
+
revokeKey,
|
|
457
|
+
recentEvents,
|
|
207
458
|
SESSION_TIMEOUT_OPTIONS_MIN,
|
|
208
|
-
THEMES
|
|
459
|
+
THEMES,
|
|
460
|
+
ROLES
|
|
209
461
|
};
|
|
210
462
|
}
|
|
211
463
|
|
|
212
|
-
module.exports = { createAdminUsersService, SESSION_TIMEOUT_OPTIONS_MIN, THEMES };
|
|
464
|
+
module.exports = { createAdminUsersService, SESSION_TIMEOUT_OPTIONS_MIN, THEMES, ROLES, DASHBOARD_ROLES, normalizeRole, roleLabel };
|
package/src/services/agents.js
CHANGED
|
@@ -29,7 +29,7 @@ function createAgentsService(db, { events }){
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function list(){
|
|
32
|
-
return db.prepare('SELECT id, name, kind, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
|
|
32
|
+
return db.prepare('SELECT id, name, kind, user_id, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function create({ name, kind }){
|
|
@@ -34,6 +34,10 @@ const PAGE_SIZES = [10, 25, 50, 100, 'all'],
|
|
|
34
34
|
agents: {
|
|
35
35
|
sorts: ['name', 'kind', 'version', 'created', 'used', 'status'],
|
|
36
36
|
defaults: { size: DEFAULT_PAGE_SIZE, sort: 'created', dir: 'desc' }
|
|
37
|
+
},
|
|
38
|
+
users: {
|
|
39
|
+
sorts: ['username', 'role', 'status', 'used'],
|
|
40
|
+
defaults: { size: DEFAULT_PAGE_SIZE, sort: 'username', dir: 'asc' }
|
|
37
41
|
}
|
|
38
42
|
};
|
|
39
43
|
|
package/src/services/registry.js
CHANGED
|
@@ -655,6 +655,17 @@ function createRegistryService(db, { bus, events }){
|
|
|
655
655
|
return get(resourceId);
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
+
// The Client's hw/sw section as it stands: what it reported at its last
|
|
659
|
+
// start, unless a saved revision hasn't reached it yet — then that one.
|
|
660
|
+
// The base for a revision that only changes something else (groups, an
|
|
661
|
+
// Import without a section) and for Export: the saved one alone could be
|
|
662
|
+
// older than the Client's file, and sending it would undo what changed
|
|
663
|
+
// there since.
|
|
664
|
+
function currentSection(r){
|
|
665
|
+
const pending = r.config_desired && r.config_applied_revision < r.config_revision;
|
|
666
|
+
return pending ? r.config_desired : (r.client_config || r.config_desired);
|
|
667
|
+
}
|
|
668
|
+
|
|
658
669
|
// { revision, type, config[, file] } while the Client hasn't applied the
|
|
659
670
|
// saved one; `file`: an import's other fields, until it's applied.
|
|
660
671
|
function pendingConfig(resourceId){
|
|
@@ -684,7 +695,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
684
695
|
if (!valid){
|
|
685
696
|
throw Object.assign(new Error(`Can't import this config into ${r.name}: ${errors.join('; ')}`), { status: 400 });
|
|
686
697
|
}
|
|
687
|
-
const desired = section || r
|
|
698
|
+
const desired = section || currentSection(r);
|
|
688
699
|
if (!desired){
|
|
689
700
|
throw Object.assign(new Error(`Can't import this config into ${r.name}: it has no "${HW_DEVICES_SECTION}" section, and the Client hasn't reported one`),
|
|
690
701
|
{ status: 400 });
|
|
@@ -714,7 +725,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
714
725
|
}
|
|
715
726
|
// Writing top-level fields needs a Client that reports its config file
|
|
716
727
|
// (the same that Import needs); an older one would drop them.
|
|
717
|
-
const desired = r
|
|
728
|
+
const desired = currentSection(r);
|
|
718
729
|
if (!desired || !r.client_config_file){
|
|
719
730
|
throw Object.assign(new Error(`${r.name} is too old to take its groups from the dashboard — update its Client first`), { status: 409 });
|
|
720
731
|
}
|
|
@@ -740,7 +751,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
740
751
|
}
|
|
741
752
|
// Re-shared: a file stored from an older Client may still say hw / sw.
|
|
742
753
|
const base = shareableClientConfigFile(r.client_config_file || { name: r.reported_name || r.name, type: r.type, labels: r.labels }),
|
|
743
|
-
section = r.type === 'hw' ? r
|
|
754
|
+
section = r.type === 'hw' ? currentSection(r) : null;
|
|
744
755
|
return { ...base, ...(pendingImport(r) || {}), type: r.type, ...(section ? { [HW_DEVICES_SECTION]: section } : {}) };
|
|
745
756
|
}
|
|
746
757
|
|