@andrian.yablonskyy/thub-coordinator 1.1.2 → 1.1.4
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 +16 -3
- package/package.json +1 -1
- package/src/auth.js +3 -1
- package/src/server.js +1 -1
- package/src/services/admin-users.js +8 -4
- package/src/web/routes.js +138 -42
- package/test/users.test.js +154 -2
- package/views/admin/users.pug +28 -19
- package/views/help/_ci.pug +1 -1
- package/views/help/_coordinator.pug +6 -1
- package/views/help/_quick-start.pug +1 -1
- package/views/layout.pug +1 -1
package/bin/thub-admin.js
CHANGED
|
@@ -37,8 +37,21 @@ const { loadConfig } = require('../src/config'),
|
|
|
37
37
|
{ roleLabel, normalizeRole } = require('../src/services/admin-users'),
|
|
38
38
|
{ PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin, formatDateTime, parseDateTime } = require('@andrian.yablonskyy/thub-common'),
|
|
39
39
|
{ spawnSync } = require('node:child_process'),
|
|
40
|
+
os = require('node:os'),
|
|
40
41
|
{ version: installedVersion } = require('../package.json');
|
|
41
42
|
|
|
43
|
+
// Who the audit log names for a change made here: the OS user running it.
|
|
44
|
+
function cliActor(){
|
|
45
|
+
let name = process.env.SUDO_USER || '';
|
|
46
|
+
try {
|
|
47
|
+
name ||= os.userInfo().username;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// no passwd entry (a container's random uid)
|
|
51
|
+
}
|
|
52
|
+
return name ? `thub-admin (${name})` : 'thub-admin';
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
function usage(){
|
|
43
56
|
console.log(`Usage:
|
|
44
57
|
thub-admin user add <username> --email <email> [--role user|maintainer|admin] [--password <p>] [--key]
|
|
@@ -174,7 +187,7 @@ function main(){
|
|
|
174
187
|
if (cmd === 'user'){
|
|
175
188
|
const { positional, flags } = parseFlags(rest),
|
|
176
189
|
[username, arg] = positional,
|
|
177
|
-
by =
|
|
190
|
+
by = cliActor(),
|
|
178
191
|
need = (u) => {
|
|
179
192
|
const row = adminUsers.getByUsername(u || '');
|
|
180
193
|
if (!row){
|
|
@@ -221,7 +234,7 @@ function main(){
|
|
|
221
234
|
if (!arg){
|
|
222
235
|
return usage(), process.exit(4);
|
|
223
236
|
}
|
|
224
|
-
adminUsers.resetPassword({ username: target.username, password: arg });
|
|
237
|
+
adminUsers.resetPassword({ username: target.username, password: arg }, { by });
|
|
225
238
|
console.log(`Password of "${target.username}" set`);
|
|
226
239
|
}
|
|
227
240
|
else if (sub === 'key'){
|
|
@@ -252,7 +265,7 @@ function main(){
|
|
|
252
265
|
if (!username || !password){
|
|
253
266
|
return usage(), process.exit(4);
|
|
254
267
|
}
|
|
255
|
-
adminUsers.create({ username, password, role: flags.role || 'admin' });
|
|
268
|
+
adminUsers.create({ username, password, role: flags.role || 'admin' }, { by: cliActor() });
|
|
256
269
|
console.log(`Created ${roleLabel(normalizeRole(flags.role || 'admin'))} user "${username}"`);
|
|
257
270
|
return;
|
|
258
271
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
package/src/auth.js
CHANGED
|
@@ -120,7 +120,9 @@ function requireAdminSession(req, res, next){
|
|
|
120
120
|
next();
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
// The dashboard's security features (§10.3):
|
|
123
|
+
// The dashboard's security features (§10.3): Settings, restarting and
|
|
124
|
+
// updating the Coordinator, rotating a Client's token. (Users and CI tokens
|
|
125
|
+
// are maintainers' too, within web/routes.js managesUser.)
|
|
124
126
|
function requireAdminRole(req, res, next){
|
|
125
127
|
if (req.session?.user?.role !== 'admin'){
|
|
126
128
|
const text = 'Only admins can open this — it\'s one of the Coordinator\'s security features.';
|
package/src/server.js
CHANGED
|
@@ -247,7 +247,7 @@ function ensureBootstrapAdmin(services){
|
|
|
247
247
|
return;
|
|
248
248
|
}
|
|
249
249
|
const username = process.env.THUB_BOOTSTRAP_ADMIN_USER || 'admin';
|
|
250
|
-
services.adminUsers.resetPassword({ username, password });
|
|
250
|
+
services.adminUsers.resetPassword({ username, password }, { by: 'system (THUB_BOOTSTRAP_ADMIN_PASSWORD)' });
|
|
251
251
|
console.log(`Reset password for admin user "${username}" from THUB_BOOTSTRAP_ADMIN_PASSWORD`);
|
|
252
252
|
}
|
|
253
253
|
|
|
@@ -123,7 +123,10 @@ function parseListPrefs(json){
|
|
|
123
123
|
// access key for the Agent for anyone. `events` (the audit log) is optional
|
|
124
124
|
// so the profile/password functions can be used on their own (tests).
|
|
125
125
|
function createAdminUsersService(db, { events } = {}){
|
|
126
|
-
|
|
126
|
+
// Every user event names who did it (`by`): a username, `thub-admin
|
|
127
|
+
// (<os user>)`, or `system` for what no person did (the bootstrap reset,
|
|
128
|
+
// internal callers that pass none).
|
|
129
|
+
const audit = (id, type, data) => events?.record('user', id, type, { ...data, by: data.by || 'system' });
|
|
127
130
|
|
|
128
131
|
function getByUsername(username){
|
|
129
132
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
|
@@ -228,13 +231,14 @@ function createAdminUsersService(db, { events } = {}){
|
|
|
228
231
|
// silently promote anyone to admin), or creates one as admin if the
|
|
229
232
|
// named account doesn't exist yet — the original first-run bootstrap
|
|
230
233
|
// case, now handled by the same path.
|
|
231
|
-
function resetPassword({ username, password, role = 'admin' }){
|
|
234
|
+
function resetPassword({ username, password, role = 'admin' }, { by } = {}){
|
|
232
235
|
const existing = getByUsername(username);
|
|
233
236
|
if (existing){
|
|
234
237
|
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(hashPassword(password), existing.id);
|
|
238
|
+
audit(existing.id, 'user.password_set', { by });
|
|
235
239
|
return { id: existing.id, username, role: existing.role };
|
|
236
240
|
}
|
|
237
|
-
return create({ username, password, role });
|
|
241
|
+
return create({ username, password, role }, { by });
|
|
238
242
|
}
|
|
239
243
|
|
|
240
244
|
// Users page: a new temporary password for someone who lost theirs.
|
|
@@ -266,7 +270,7 @@ function createAdminUsersService(db, { events } = {}){
|
|
|
266
270
|
throw bad('Choose a password different from the current one');
|
|
267
271
|
}
|
|
268
272
|
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
|
|
269
|
-
audit(id, 'user.password_changed', {});
|
|
273
|
+
audit(id, 'user.password_changed', { by: row.username });
|
|
270
274
|
}
|
|
271
275
|
|
|
272
276
|
// Async (thread pool); the same work whether or not the user exists, or
|
package/src/web/routes.js
CHANGED
|
@@ -30,7 +30,7 @@ const fs = require('node:fs'),
|
|
|
30
30
|
search = require('../services/search'),
|
|
31
31
|
{ createLoginGuard } = require('../login-guard'),
|
|
32
32
|
{ formatBytes } = require('../services/job-artifacts'),
|
|
33
|
-
{ roleLabel } = require('../services/admin-users');
|
|
33
|
+
{ roleLabel, normalizeRole } = require('../services/admin-users');
|
|
34
34
|
|
|
35
35
|
// When this process started: the settings page tells a restart happened by it changing.
|
|
36
36
|
const STARTED_AT = new Date().toISOString(),
|
|
@@ -858,13 +858,13 @@ function createWebRouter({ services, config }){
|
|
|
858
858
|
return `Requested update to client v${version} — it starts on the Client's next heartbeat.`;
|
|
859
859
|
}));
|
|
860
860
|
|
|
861
|
-
router.post('/admin/agents/update-all',
|
|
861
|
+
router.post('/admin/agents/update-all', updateAction(async () => {
|
|
862
862
|
const version = await services.updates.targetVersion('agent'),
|
|
863
863
|
n = services.agents.requestUpdateAll(version);
|
|
864
864
|
return `Requested update to agent v${version} for ${n} agent(s) — each updates on its next run.`;
|
|
865
865
|
}));
|
|
866
866
|
|
|
867
|
-
router.post('/admin/agents/:id/update',
|
|
867
|
+
router.post('/admin/agents/:id/update', updateAction(async (req) => {
|
|
868
868
|
if (req.body.cancel === '1'){
|
|
869
869
|
services.agents.setUpdateTo(req.params.id, null);
|
|
870
870
|
return 'Update request canceled.';
|
|
@@ -1198,19 +1198,91 @@ function createWebRouter({ services, config }){
|
|
|
1198
1198
|
role: (u) => ROLE_ORDER[u.role],
|
|
1199
1199
|
status: (u) => (u.blocked ? 1 : 0),
|
|
1200
1200
|
used: (u) => u.key?.lastUsedAt || null
|
|
1201
|
-
}
|
|
1201
|
+
},
|
|
1202
|
+
// Who manages whom on Users (§10.3): an admin everyone; a maintainer
|
|
1203
|
+
// Agent-only users and their own account. Admins and other maintainers
|
|
1204
|
+
// aren't even listed for them, and only an admin gives anyone a
|
|
1205
|
+
// dashboard role.
|
|
1206
|
+
isAdmin = (actor) => actor.role === 'admin',
|
|
1207
|
+
managesUser = (actor, u) => Boolean(u) && (isAdmin(actor) || u.role === 'user' || u.id === actor.id),
|
|
1208
|
+
userFiltersFor = (actor) => (isAdmin(actor) ? USER_ROLE_FILTERS : ['user', 'blocked']),
|
|
1209
|
+
// The audit log's Action column names changed fields like this.
|
|
1210
|
+
FIELD_LABEL = { username: 'username', email: 'email', role: 'role', first_name: 'first name', last_name: 'last name' };
|
|
1211
|
+
|
|
1212
|
+
// The audit log's Action column: what was done, to whom. `nameOf` gives
|
|
1213
|
+
// the account's current username (else the one its events recorded).
|
|
1214
|
+
function describeUserEvent(e, nameOf){
|
|
1215
|
+
const name = nameOf(e.entity_id),
|
|
1216
|
+
whose = e.data.by === name ? 'their own' : `${name}'s`;
|
|
1217
|
+
switch (e.type){
|
|
1218
|
+
case 'user.created': return `Created ${name} (${roleLabel(e.data.role)})`;
|
|
1219
|
+
case 'user.updated': {
|
|
1220
|
+
const parts = [];
|
|
1221
|
+
if (e.data.role?.from){
|
|
1222
|
+
parts.push(`role ${roleLabel(e.data.role.from)} → ${roleLabel(e.data.role.to)}`);
|
|
1223
|
+
}
|
|
1224
|
+
const fields = (e.data.changed || []).filter((f) => f !== 'role').map((f) => FIELD_LABEL[f] || f);
|
|
1225
|
+
if (fields.length){
|
|
1226
|
+
parts.push(fields.join(', '));
|
|
1227
|
+
}
|
|
1228
|
+
return `Edited ${name}${parts.length ? `: ${parts.join('; ')}` : ''}`;
|
|
1229
|
+
}
|
|
1230
|
+
case 'user.blocked': return `Blocked ${name}`;
|
|
1231
|
+
case 'user.unblocked': return `Unblocked ${name}`;
|
|
1232
|
+
case 'user.deleted': return `Deleted ${name}`;
|
|
1233
|
+
case 'user.password_reset': return `Issued a temporary password for ${name}`;
|
|
1234
|
+
case 'user.password_set': return `Set ${whose} password`;
|
|
1235
|
+
case 'user.password_changed': return `Changed ${whose} password`;
|
|
1236
|
+
case 'user.key_created': return `Created ${whose} access key`;
|
|
1237
|
+
case 'user.key_rotated': return `Replaced ${whose} access key`;
|
|
1238
|
+
case 'user.key_revoked': return `Revoked ${whose} access key`;
|
|
1239
|
+
default: return `${e.type.replace('user.', '').replace(/_/g, ' ')} ${name}`;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Rows for the audit log: Timestamp, User (who did it — always shown;
|
|
1244
|
+
// events from before it was recorded for everything say `system`, or the
|
|
1245
|
+
// account itself for its own password change), Action.
|
|
1246
|
+
function auditRows(events){
|
|
1247
|
+
const names = new Map(services.adminUsers.list().map((u) => [u.id, u.username]));
|
|
1248
|
+
for (const e of services.adminUsers.recentEvents(1000)){
|
|
1249
|
+
if (!names.has(e.entity_id) && e.data.username){
|
|
1250
|
+
names.set(e.entity_id, e.data.username);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const nameOf = (id) => names.get(id) || id;
|
|
1254
|
+
return events.map((e) => ({
|
|
1255
|
+
ts: e.ts,
|
|
1256
|
+
by: e.data.by || (e.type === 'user.password_changed' ? nameOf(e.entity_id) : 'system'),
|
|
1257
|
+
action: describeUserEvent(e, nameOf)
|
|
1258
|
+
}));
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// A maintainer's Recent activity: about the users they see, and deleted
|
|
1262
|
+
// ones that were Agent-only users when created — nothing about admins or
|
|
1263
|
+
// other maintainers.
|
|
1264
|
+
function visibleEvents(me, visibleIds){
|
|
1265
|
+
const events = services.adminUsers.recentEvents(500),
|
|
1266
|
+
existing = new Set(services.adminUsers.list().map((u) => u.id)),
|
|
1267
|
+
createdAsUser = new Set(events.filter((e) => e.type === 'user.created' && e.data.role === 'user').map((e) => e.entity_id));
|
|
1268
|
+
return events.filter((e) => visibleIds.has(e.entity_id) || (!existing.has(e.entity_id) && createdAsUser.has(e.entity_id))).slice(0, 30);
|
|
1269
|
+
}
|
|
1202
1270
|
|
|
1203
1271
|
function renderUsers(req, res, extra = {}){
|
|
1204
|
-
const
|
|
1272
|
+
const me = req.session.user,
|
|
1273
|
+
filters = userFiltersFor(me),
|
|
1274
|
+
q = search.normalizeQuery(req.query.q),
|
|
1205
1275
|
terms = search.parseTerms(q),
|
|
1206
|
-
role =
|
|
1276
|
+
role = filters.includes(req.query.role) ? req.query.role : undefined,
|
|
1207
1277
|
view = listView(req, res, 'users', { q, role }),
|
|
1208
1278
|
{ sort, dir, size } = view.prefs,
|
|
1209
1279
|
value = USER_SORT_VALUE[sort],
|
|
1210
|
-
|
|
1280
|
+
visible = services.adminUsers.list().filter((u) => managesUser(me, u)),
|
|
1281
|
+
visibleIds = new Set(visible.map((u) => u.id)),
|
|
1282
|
+
searched = visible
|
|
1211
1283
|
.filter((u) => search.matches([u.username, u.email, u.firstName, u.lastName, u.role, u.blocked ? 'blocked' : 'active'], terms)),
|
|
1212
1284
|
// Counts for the filter buttons: within the search, before the role filter.
|
|
1213
|
-
roleCounts = Object.fromEntries(
|
|
1285
|
+
roleCounts = Object.fromEntries(filters.map((r) => [r, searched.filter((u) => (r === 'blocked' ? u.blocked : u.role === r)).length])),
|
|
1214
1286
|
sorted = searched
|
|
1215
1287
|
.filter((u) => !role || (role === 'blocked' ? u.blocked : u.role === role))
|
|
1216
1288
|
.sort((a, b) => {
|
|
@@ -1231,46 +1303,64 @@ function createWebRouter({ services, config }){
|
|
|
1231
1303
|
users: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
|
|
1232
1304
|
total: searched.length,
|
|
1233
1305
|
roleFilter: role,
|
|
1234
|
-
roleFilters:
|
|
1306
|
+
roleFilters: filters,
|
|
1235
1307
|
userDisplayName,
|
|
1236
1308
|
roleCounts,
|
|
1237
1309
|
list: { ...view, pagination, pageUrl, q },
|
|
1238
1310
|
q,
|
|
1239
|
-
me
|
|
1240
|
-
roles
|
|
1311
|
+
me,
|
|
1312
|
+
// The roles this admin or maintainer may give.
|
|
1313
|
+
roles: isAdmin(me) ? services.adminUsers.ROLES : ['user'],
|
|
1241
1314
|
roleHelp: {
|
|
1242
1315
|
user: 'Agent only: submits jobs, reads their own results; no dashboard',
|
|
1243
|
-
maintainer: 'Agent + the dashboard
|
|
1316
|
+
maintainer: 'Agent + the dashboard without Settings; manages Agent-only users and CI tokens; sees and cancels every job',
|
|
1244
1317
|
admin: 'Everything'
|
|
1245
1318
|
},
|
|
1246
1319
|
roleTone: { user: 'secondary', maintainer: 'info', admin: 'primary' },
|
|
1247
|
-
|
|
1320
|
+
audit: auditRows(isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds)),
|
|
1248
1321
|
...extra
|
|
1249
1322
|
});
|
|
1250
1323
|
}
|
|
1251
1324
|
|
|
1252
1325
|
const userAction = (fn) => (req, res) => {
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1326
|
+
const actor = req.session.user,
|
|
1327
|
+
by = actor.username,
|
|
1328
|
+
actorId = actor.id;
|
|
1329
|
+
try {
|
|
1330
|
+
// Someone this admin or maintainer may not manage answers like an
|
|
1331
|
+
// unknown id.
|
|
1332
|
+
if (req.params.id && !managesUser(actor, services.adminUsers.getById(req.params.id))){
|
|
1333
|
+
throw Object.assign(new Error('Unknown user'), { status: 404 });
|
|
1334
|
+
}
|
|
1335
|
+
const result = fn(req, { by, actorId, actor });
|
|
1336
|
+
if (result?.secret){
|
|
1337
|
+
return renderUsers(req, res, { secret: result.secret });
|
|
1338
|
+
}
|
|
1339
|
+
if (result?.message){
|
|
1340
|
+
flash(req, 'success', result.message);
|
|
1341
|
+
}
|
|
1259
1342
|
}
|
|
1260
|
-
|
|
1261
|
-
flash(req, '
|
|
1343
|
+
catch (err){
|
|
1344
|
+
flash(req, 'danger', err.message);
|
|
1262
1345
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1346
|
+
res.redirect(returnTo(req, '/admin/users')); // the same filter, sort and page
|
|
1347
|
+
},
|
|
1348
|
+
notOwnAccount = (req, { actorId }, what) => {
|
|
1349
|
+
if (req.params.id === actorId){
|
|
1350
|
+
throw Object.assign(new Error(`You can't ${what} your own account.`), { status: 409 });
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1269
1353
|
|
|
1270
|
-
|
|
1354
|
+
// Users and CI tokens: admins and maintainers (everyone who has a
|
|
1355
|
+
// dashboard session), each within managesUser.
|
|
1356
|
+
router.get('/admin/users', (req, res) => renderUsers(req, res));
|
|
1271
1357
|
|
|
1272
|
-
router.post('/admin/users',
|
|
1273
|
-
const { username, email, role, firstName, lastName } = req.body
|
|
1358
|
+
router.post('/admin/users', userAction((req, ctx) => {
|
|
1359
|
+
const { username, email, role, firstName, lastName } = req.body;
|
|
1360
|
+
if (!isAdmin(ctx.actor) && normalizeRole(role) !== 'user'){
|
|
1361
|
+
throw new Error('Only an admin can create a maintainer or an admin.');
|
|
1362
|
+
}
|
|
1363
|
+
const
|
|
1274
1364
|
created = services.adminUsers.create({ username, email, role, firstName, lastName, requireEmail: true }, ctx),
|
|
1275
1365
|
key = req.body.issueKey === '1' ? services.adminUsers.issueKey(created.id, ctx) : null;
|
|
1276
1366
|
if (!key && !created.tempPassword){
|
|
@@ -1279,31 +1369,36 @@ function createWebRouter({ services, config }){
|
|
|
1279
1369
|
return { secret: { title: `User ${created.username} created`, key, password: created.tempPassword, username: created.username } };
|
|
1280
1370
|
}));
|
|
1281
1371
|
|
|
1282
|
-
router.post('/admin/users/:id',
|
|
1283
|
-
const { username, email, role, firstName, lastName } = req.body
|
|
1372
|
+
router.post('/admin/users/:id', userAction((req, ctx) => {
|
|
1373
|
+
const { username, email, role, firstName, lastName } = req.body;
|
|
1374
|
+
if (!isAdmin(ctx.actor) && role !== undefined && normalizeRole(role) !== services.adminUsers.getById(req.params.id).role){
|
|
1375
|
+
throw new Error('Only an admin can change a role.');
|
|
1376
|
+
}
|
|
1377
|
+
const
|
|
1284
1378
|
{ user, tempPassword } = services.adminUsers.update(req.params.id, { username, email, role, firstName, lastName }, ctx);
|
|
1285
1379
|
return tempPassword
|
|
1286
1380
|
? { secret: { title: `${user.username} can now use the dashboard`, password: tempPassword, username: user.username } }
|
|
1287
1381
|
: { message: `Saved ${user.username}.` };
|
|
1288
1382
|
}));
|
|
1289
1383
|
|
|
1290
|
-
router.post('/admin/users/:id/block',
|
|
1384
|
+
router.post('/admin/users/:id/block', userAction((req, ctx) => {
|
|
1385
|
+
notOwnAccount(req, ctx, 'block');
|
|
1291
1386
|
const u = services.adminUsers.setBlocked(req.params.id, true, ctx);
|
|
1292
1387
|
return { message: `Blocked ${u.username}: no sign-in, and their access key stops working.` };
|
|
1293
1388
|
}));
|
|
1294
1389
|
|
|
1295
|
-
router.post('/admin/users/:id/unblock',
|
|
1390
|
+
router.post('/admin/users/:id/unblock', userAction((req, ctx) => {
|
|
1296
1391
|
const u = services.adminUsers.setBlocked(req.params.id, false, ctx);
|
|
1297
1392
|
return { message: `Unblocked ${u.username}.` };
|
|
1298
1393
|
}));
|
|
1299
1394
|
|
|
1300
|
-
router.post('/admin/users/:id/reset-password',
|
|
1395
|
+
router.post('/admin/users/:id/reset-password', userAction((req, ctx) => {
|
|
1301
1396
|
const u = services.adminUsers.getById(req.params.id),
|
|
1302
1397
|
password = services.adminUsers.adminResetPassword(req.params.id, ctx);
|
|
1303
1398
|
return { secret: { title: `New temporary password for ${u.username}`, password, username: u.username } };
|
|
1304
1399
|
}));
|
|
1305
1400
|
|
|
1306
|
-
router.post('/admin/users/:id/key',
|
|
1401
|
+
router.post('/admin/users/:id/key', userAction((req, ctx) => {
|
|
1307
1402
|
const u = services.adminUsers.getById(req.params.id);
|
|
1308
1403
|
if (!u){
|
|
1309
1404
|
throw Object.assign(new Error('Unknown user'), { status: 404 });
|
|
@@ -1315,27 +1410,28 @@ function createWebRouter({ services, config }){
|
|
|
1315
1410
|
return { secret: { title, key, username: u.username } };
|
|
1316
1411
|
}));
|
|
1317
1412
|
|
|
1318
|
-
router.post('/admin/users/:id/key/revoke',
|
|
1413
|
+
router.post('/admin/users/:id/key/revoke', userAction((req, ctx) => {
|
|
1319
1414
|
const u = services.adminUsers.getById(req.params.id);
|
|
1320
1415
|
services.adminUsers.revokeKey(req.params.id, ctx);
|
|
1321
1416
|
return { message: `Revoked the access key of ${u?.username}.` };
|
|
1322
1417
|
}));
|
|
1323
1418
|
|
|
1324
|
-
router.post('/admin/users/:id/delete',
|
|
1419
|
+
router.post('/admin/users/:id/delete', userAction((req, ctx) => {
|
|
1420
|
+
notOwnAccount(req, ctx, 'delete');
|
|
1325
1421
|
const u = services.adminUsers.getById(req.params.id);
|
|
1326
1422
|
services.adminUsers.remove(req.params.id, ctx);
|
|
1327
1423
|
return { message: `Deleted ${u?.username}. Their jobs stay, listed under their name.` };
|
|
1328
1424
|
}));
|
|
1329
1425
|
|
|
1330
|
-
router.get('/admin/agents',
|
|
1426
|
+
router.get('/admin/agents', (req, res) => renderAgents(req, res));
|
|
1331
1427
|
|
|
1332
|
-
router.post('/admin/agents',
|
|
1428
|
+
router.post('/admin/agents', (req, res) => {
|
|
1333
1429
|
// CI tokens only; people get their key from their user (§10.3).
|
|
1334
1430
|
const { token } = services.agents.create({ name: req.body.name, kind: 'ci' });
|
|
1335
1431
|
renderAgents(req, res, { newToken: token });
|
|
1336
1432
|
});
|
|
1337
1433
|
|
|
1338
|
-
router.post('/admin/agents/:id/rename',
|
|
1434
|
+
router.post('/admin/agents/:id/rename', (req, res) => {
|
|
1339
1435
|
try {
|
|
1340
1436
|
const before = services.agents.get(req.params.id)?.name,
|
|
1341
1437
|
agent = services.agents.rename(req.params.id, req.body.name);
|
|
@@ -1349,7 +1445,7 @@ function createWebRouter({ services, config }){
|
|
|
1349
1445
|
res.redirect(returnTo(req, '/admin/agents'));
|
|
1350
1446
|
});
|
|
1351
1447
|
|
|
1352
|
-
router.post('/admin/agents/:id/revoke',
|
|
1448
|
+
router.post('/admin/agents/:id/revoke', (req, res) => {
|
|
1353
1449
|
services.agents.revoke(req.params.id);
|
|
1354
1450
|
res.redirect(returnTo(req, '/admin/agents'));
|
|
1355
1451
|
});
|
package/test/users.test.js
CHANGED
|
@@ -186,8 +186,9 @@ test('dashboard: only maintainers and admins sign in; security pages are admins\
|
|
|
186
186
|
|
|
187
187
|
const mo = await signIn('mo', 'pw');
|
|
188
188
|
assert.equal((await get(mo.cookie, '/resources')).status, 200);
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
assert.equal((await get(mo.cookie, '/admin/settings')).status, 403); // admins' only
|
|
190
|
+
for (const url of ['/admin/users', '/admin/agents']){
|
|
191
|
+
assert.equal((await get(mo.cookie, url)).status, 200, url); // within limits — see below
|
|
191
192
|
}
|
|
192
193
|
const root = await signIn('root', 'pw');
|
|
193
194
|
assert.equal((await get(root.cookie, '/admin/users')).status, 200);
|
|
@@ -286,3 +287,154 @@ test('Users page: the User column shows first and last name, sorts by it, and op
|
|
|
286
287
|
assert.equal(cells[2][1], zed.id);
|
|
287
288
|
assert.match(html, new RegExp(`id="editUser-${zed.id}"`)); // the modal it opens
|
|
288
289
|
});
|
|
290
|
+
|
|
291
|
+
test('maintainers manage Agent-only users, their own account and CI tokens — never admins or other maintainers', async (t) => {
|
|
292
|
+
const { base, services } = await start(t),
|
|
293
|
+
u = services.adminUsers,
|
|
294
|
+
root = u.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' }),
|
|
295
|
+
ops = u.create({ username: 'ops', email: 'o@example.com', role: 'maintainer', password: 'pw' }),
|
|
296
|
+
ops2 = u.create({ username: 'ops2', email: 'o2@example.com', role: 'maintainer', password: 'pw' }),
|
|
297
|
+
dev = u.create({ username: 'dev', email: 'd@example.com', role: 'user' }),
|
|
298
|
+
cookie = (await fetch(`${base}/login`, {
|
|
299
|
+
method: 'POST', body: new URLSearchParams({ username: 'ops', password: 'pw' }), redirect: 'manual'
|
|
300
|
+
})).headers.get('set-cookie').split(';')[0],
|
|
301
|
+
get = (p) => fetch(`${base}${p}`, { headers: { cookie } }).then((r) => r.text()),
|
|
302
|
+
post = (p, body = {}) => fetch(`${base}${p}`, { method: 'POST', headers: { cookie }, body: new URLSearchParams(body), redirect: 'manual' })
|
|
303
|
+
.then(async (r) => (r.status === 302 ? get(r.headers.get('location')) : r.text()));
|
|
304
|
+
|
|
305
|
+
// The list: Agent-only users and themselves; filters without Admin/Maintainer; only User to give.
|
|
306
|
+
let page = await get('/admin/users?size=all');
|
|
307
|
+
assert.match(page, /href="\/admin\/users"[^>]*>(<i [^>]*><\/i>)?Users</); // in the navbar
|
|
308
|
+
assert.match(page, /href="\/admin\/agents"[^>]*>(<i [^>]*><\/i>)?CI tokens</);
|
|
309
|
+
assert.doesNotMatch(page, /href="\/admin\/settings"/);
|
|
310
|
+
const ids = [...page.matchAll(/href="#editUser-([^"]+)"/g)].map((m) => m[1]).sort();
|
|
311
|
+
assert.deepEqual(ids, [ops.id, dev.id].sort());
|
|
312
|
+
const filterNav = page.slice(page.indexOf('aria-label="Filter by role"'), page.indexOf('</nav>', page.indexOf('aria-label="Filter by role"')));
|
|
313
|
+
assert.deepEqual([...filterNav.matchAll(/<a [^>]*>([^<]+)</g)].map((m) => m[1].trim()), ['All', 'User', 'Blocked']);
|
|
314
|
+
assert.doesNotMatch(page.slice(page.indexOf('id="addUserModal"')), /<option value="(maintainer|admin)"/);
|
|
315
|
+
assert.match(page, new RegExp(`id="u-${ops.id}-role" value="Maintainer" disabled`)); // their own role, no picker
|
|
316
|
+
|
|
317
|
+
// Users: create, edit, key, block, delete.
|
|
318
|
+
page = await post('/admin/users', { username: 'dev2', email: 'd2@example.com', role: 'user', issueKey: '1' });
|
|
319
|
+
assert.match(page, /User dev2 created/);
|
|
320
|
+
assert.match(page, /value="thk_/);
|
|
321
|
+
const dev2 = u.getByUsername('dev2');
|
|
322
|
+
assert.match(await post(`/admin/users/${dev2.id}`, { username: 'dev2', email: 'd2@example.com', role: 'user', firstName: 'Dee' }), /Saved dev2/);
|
|
323
|
+
assert.match(await post(`/admin/users/${dev.id}/key`), /Access key for dev/);
|
|
324
|
+
assert.match(await post(`/admin/users/${dev.id}/key/revoke`), /Revoked the access key of dev/);
|
|
325
|
+
assert.match(await post(`/admin/users/${dev.id}/block`), /Blocked dev/);
|
|
326
|
+
assert.match(await post(`/admin/users/${dev.id}/unblock`), /Unblocked dev/);
|
|
327
|
+
assert.match(await post(`/admin/users/${dev2.id}/delete`), /Deleted dev2/);
|
|
328
|
+
|
|
329
|
+
// …but never a dashboard role.
|
|
330
|
+
assert.match(await post('/admin/users', { username: 'boss', email: 'b@example.com', role: 'admin' }), /Only an admin can create a maintainer or an admin/);
|
|
331
|
+
assert.match(await post('/admin/users', { username: 'm2', email: 'm2@example.com', role: 'maintainer' }), /Only an admin can create/);
|
|
332
|
+
assert.equal(u.getByUsername('boss'), undefined);
|
|
333
|
+
assert.match(await post(`/admin/users/${dev.id}`, { username: 'dev', email: 'd@example.com', role: 'maintainer' }), /Only an admin can change a role/);
|
|
334
|
+
assert.equal(u.getById(dev.id).role, 'user');
|
|
335
|
+
|
|
336
|
+
// Admins and other maintainers: as if they didn't exist.
|
|
337
|
+
for (const other of [root, ops2]){
|
|
338
|
+
for (const action of ['', '/block', '/reset-password', '/key', '/key/revoke', '/delete']){
|
|
339
|
+
assert.match(await post(`/admin/users/${other.id}${action}`, { username: 'x', email: 'x@example.com' }), /Unknown user/, `${other.username}${action}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
assert.equal(u.getById(root.id).blocked, false);
|
|
343
|
+
assert.equal(u.getById(ops2.id).username, 'ops2');
|
|
344
|
+
|
|
345
|
+
// Their own account: edit, key, new temporary password — not block or delete, not a new role.
|
|
346
|
+
assert.match(await post(`/admin/users/${ops.id}`, { username: 'ops', email: 'o@example.com', firstName: 'Olga' }), /Saved ops/);
|
|
347
|
+
assert.equal(u.getById(ops.id).role, 'maintainer');
|
|
348
|
+
assert.match(await post(`/admin/users/${ops.id}`, { username: 'ops', email: 'o@example.com', role: 'admin' }), /Only an admin can change a role/);
|
|
349
|
+
assert.match(await post(`/admin/users/${ops.id}/key`), /Access key for ops/);
|
|
350
|
+
assert.match(await post(`/admin/users/${ops.id}/block`), /can't block your own account|can't block your own account/);
|
|
351
|
+
assert.match(await post(`/admin/users/${ops.id}/delete`), /can't delete your own account|can't delete your own account/);
|
|
352
|
+
|
|
353
|
+
// Recent activity: nothing about admins or other maintainers.
|
|
354
|
+
page = await get('/admin/users');
|
|
355
|
+
const activity = page.slice(page.indexOf('Recent activity'), page.indexOf('id="addUserModal"'));
|
|
356
|
+
assert.doesNotMatch(activity, /\b(root|ops2)\b/);
|
|
357
|
+
assert.match(activity, /dev2/);
|
|
358
|
+
|
|
359
|
+
// CI tokens: issue, rename, revoke.
|
|
360
|
+
page = await post('/admin/agents', { name: 'ci-nightly' });
|
|
361
|
+
assert.match(page, /Token \(shown once\):[\s\S]*?<code>agt_/);
|
|
362
|
+
const ci = services.agents.list().find((a) => a.name === 'ci-nightly');
|
|
363
|
+
await post(`/admin/agents/${ci.id}/rename`, { name: 'ci-night' });
|
|
364
|
+
await post(`/admin/agents/${ci.id}/revoke`);
|
|
365
|
+
assert.equal(services.agents.get(ci.id).name, 'ci-night');
|
|
366
|
+
assert.ok(services.agents.get(ci.id).revoked_at);
|
|
367
|
+
|
|
368
|
+
// Still admin-only: Settings.
|
|
369
|
+
assert.equal((await fetch(`${base}/admin/settings`, { headers: { cookie } })).status, 403);
|
|
370
|
+
|
|
371
|
+
// Every job and its details, whoever submitted it.
|
|
372
|
+
services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
373
|
+
const ciAgent = services.agents.create({ name: 'ci-x', kind: 'ci' }).agent,
|
|
374
|
+
ciJob = services.jobs.create({ agentId: ciAgent.id, source: 'ci', spec: { target: { type: 'sw' }, command: './ci.sh' } }),
|
|
375
|
+
devKey = u.issueKey(dev.id, { by: 'root' }),
|
|
376
|
+
devAgent = services.agents.list().find((a) => a.user_id === dev.id),
|
|
377
|
+
devJob = services.jobs.create({ agentId: devAgent.id, source: 'cli', spec: { target: { type: 'sw' }, command: './dev.sh' } });
|
|
378
|
+
assert.ok(devKey);
|
|
379
|
+
const jobs = await get('/jobs?size=all');
|
|
380
|
+
assert.ok(jobs.includes(ciJob.id) && jobs.includes(devJob.id));
|
|
381
|
+
for (const job of [devJob, ciJob]){
|
|
382
|
+
const r = await fetch(`${base}/jobs/${job.id}`, { headers: { cookie } });
|
|
383
|
+
assert.equal(r.status, 200);
|
|
384
|
+
assert.match(await r.text(), new RegExp(`<title>${job.id} `));
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('audit log: Timestamp, User (who did it — always), Action — from the dashboard, the profile, the Agent and thub-admin', async (t) => {
|
|
389
|
+
const { base, services, file } = await start(t),
|
|
390
|
+
u = services.adminUsers;
|
|
391
|
+
u.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' }, { by: 'root' });
|
|
392
|
+
const login = async (name, pw) => (await fetch(`${base}/login`, {
|
|
393
|
+
method: 'POST', body: new URLSearchParams({ username: name, password: pw }), redirect: 'manual'
|
|
394
|
+
})).headers.get('set-cookie').split(';')[0],
|
|
395
|
+
root = await login('root', 'pw'),
|
|
396
|
+
post = (cookie, p, body = {}) => fetch(`${base}${p}`, { method: 'POST', headers: { cookie }, body: new URLSearchParams(body), redirect: 'manual' });
|
|
397
|
+
|
|
398
|
+
// Dashboard (an admin), then the profile (the user themselves).
|
|
399
|
+
await post(root, '/admin/users', { username: 'dev', email: 'd@example.com', role: 'user', issueKey: '1' });
|
|
400
|
+
const dev = u.getByUsername('dev');
|
|
401
|
+
await post(root, '/admin/users', { username: 'ops', email: 'o@example.com', role: 'maintainer' });
|
|
402
|
+
const ops = u.getByUsername('ops');
|
|
403
|
+
await post(root, `/admin/users/${ops.id}`, { username: 'ops', email: 'ops@example.com', role: 'admin', firstName: 'Olga' });
|
|
404
|
+
const temp = u.adminResetPassword(ops.id, { by: 'root' }),
|
|
405
|
+
opsCookie = await login('ops', temp);
|
|
406
|
+
await post(opsCookie, '/profile/password', { currentPassword: temp, newPassword: 'Ops-own-1', confirmPassword: 'Ops-own-1' });
|
|
407
|
+
|
|
408
|
+
// The Agent rotating its own key.
|
|
409
|
+
const devKey = u.issueKey(dev.id, { by: 'root' });
|
|
410
|
+
assert.equal((await fetch(`${base}/api/v1/me/key/rotate`, { method: 'POST', headers: { authorization: `Bearer ${devKey}` } })).status, 200);
|
|
411
|
+
|
|
412
|
+
// thub-admin on the Coordinator host, and an internal call naming no one.
|
|
413
|
+
const cli = spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'thub-admin.js'), 'user', 'block', 'dev'], {
|
|
414
|
+
env: { ...process.env, THUB_COORDINATOR_CONFIG: file, SUDO_USER: 'alice' }, encoding: 'utf8'
|
|
415
|
+
});
|
|
416
|
+
assert.match(cli.stdout, /Blocked "dev"/);
|
|
417
|
+
u.setBlocked(dev.id, false);
|
|
418
|
+
|
|
419
|
+
// Every recorded event names who did it.
|
|
420
|
+
assert.ok(u.recentEvents(100).every((e) => e.data.by), 'every user event has `by`');
|
|
421
|
+
|
|
422
|
+
const html = await (await fetch(`${base}/admin/users`, { headers: { cookie: root } })).text(),
|
|
423
|
+
log = html.slice(html.indexOf('Recent activity'), html.indexOf('id="addUserModal"')),
|
|
424
|
+
rows = [...log.matchAll(/<tr><td[^>]*>[^<]*<\/td><td[^>]*>([^<]*)<\/td><td>([^<]*)<\/td><\/tr>/g)]
|
|
425
|
+
.map((m) => [m[1], m[2].replace(/'/g, '\'').replace(/>/g, '>')]).reverse(); // oldest first
|
|
426
|
+
assert.match(log, /<th class="text-nowrap">Timestamp<\/th><th>User<\/th><th>Action<\/th>/);
|
|
427
|
+
assert.deepEqual(rows, [
|
|
428
|
+
['root', 'Created root (Admin)'],
|
|
429
|
+
['root', 'Created dev (User)'],
|
|
430
|
+
['root', 'Created dev\'s access key'],
|
|
431
|
+
['root', 'Created ops (Maintainer)'],
|
|
432
|
+
['root', 'Edited ops: role Maintainer → Admin; email, first name'],
|
|
433
|
+
['root', 'Issued a temporary password for ops'],
|
|
434
|
+
['ops', 'Changed their own password'],
|
|
435
|
+
['root', 'Replaced dev\'s access key'],
|
|
436
|
+
['dev', 'Replaced their own access key'],
|
|
437
|
+
['thub-admin (alice)', 'Blocked dev'],
|
|
438
|
+
['system', 'Unblocked dev']
|
|
439
|
+
]);
|
|
440
|
+
});
|
package/views/admin/users.pug
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
extends ../layout
|
|
2
2
|
include ../mixins/list-controls
|
|
3
3
|
|
|
4
|
-
//- Users (§10.3
|
|
5
|
-
//-
|
|
4
|
+
//- Users (§10.3). Roles: user — the Agent only; maintainer — the dashboard
|
|
5
|
+
//- without its security features; admin — everything. A maintainer sees and
|
|
6
|
+
//- manages Agent-only users and their own account (web/routes.js managesUser).
|
|
6
7
|
//- Role picker: names in the list, the chosen role's description below it
|
|
7
8
|
//- (kept in step by public/js/actions.js, select[data-describe]).
|
|
8
9
|
mixin roleSelect(id, selected)
|
|
@@ -153,7 +154,8 @@ block content
|
|
|
153
154
|
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Delete" data-bs-toggle="tooltip" data-bs-title="Delete the user")
|
|
154
155
|
i.bi.bi-trash
|
|
155
156
|
|
|
156
|
-
|
|
157
|
+
//- Inside the actions cell (text-nowrap): wrap again in here.
|
|
158
|
+
.modal.text-start.text-wrap(tabindex="-1" id=`editUser-${u.id}` aria-labelledby=`editUserTitle-${u.id}`)
|
|
157
159
|
.modal-dialog
|
|
158
160
|
.modal-content
|
|
159
161
|
form(method="post" action=`/admin/users/${u.id}`)
|
|
@@ -177,8 +179,14 @@ block content
|
|
|
177
179
|
input.form-control(id=`u-${u.id}-last` name="lastName" value=u.lastName || '')
|
|
178
180
|
.col-12
|
|
179
181
|
label.form-label(for=`u-${u.id}-role`) Role
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
//- A maintainer gives no roles but User; their own
|
|
183
|
+
//- row shows theirs without a picker (and posts none).
|
|
184
|
+
if roles.includes(u.role)
|
|
185
|
+
+roleSelect(`u-${u.id}-role`, u.role)
|
|
186
|
+
else
|
|
187
|
+
input.form-control(id=`u-${u.id}-role` value=roleLabel(u.role) disabled)
|
|
188
|
+
.form-text Only an admin can change a role.
|
|
189
|
+
if !u.canUseDashboard && roles.length > 1
|
|
182
190
|
.form-text Making them a maintainer or admin gives them a temporary password for the dashboard.
|
|
183
191
|
.modal-footer
|
|
184
192
|
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
|
@@ -187,21 +195,22 @@ block content
|
|
|
187
195
|
tr
|
|
188
196
|
td.text-body-secondary(colspan="5")= q || roleFilter ? 'No user matches.' : 'No users yet.'
|
|
189
197
|
|
|
198
|
+
//- The audit log: who did what to which account (web/routes.js auditRows).
|
|
190
199
|
h2.h6.mt-4 Recent activity
|
|
191
|
-
if
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
200
|
+
if audit.length
|
|
201
|
+
.table-responsive.mb-4
|
|
202
|
+
table.table.table-sm.small.align-middle
|
|
203
|
+
thead
|
|
204
|
+
tr
|
|
205
|
+
th.text-nowrap Timestamp
|
|
206
|
+
th User
|
|
207
|
+
th Action
|
|
208
|
+
tbody
|
|
209
|
+
each e in audit
|
|
210
|
+
tr
|
|
211
|
+
td.text-nowrap.text-body-secondary= fmtDate(e.ts)
|
|
212
|
+
td.text-nowrap= e.by
|
|
213
|
+
td= e.action
|
|
205
214
|
else
|
|
206
215
|
p.small.text-body-secondary Nothing yet.
|
|
207
216
|
|
package/views/help/_ci.pug
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
+section('ci', 'CI/CD integration (GitHub Actions)', 'github')
|
|
2
2
|
p.
|
|
3
3
|
The test job only needs the Agent and HTTPS access to the Coordinator, so it runs on an ordinary hosted runner.
|
|
4
|
-
Create a #[strong CI token] on #[a(href="/admin/agents") CI tokens] (admins) and store it as the repository secret
|
|
4
|
+
Create a #[strong CI token] on #[a(href="/admin/agents") CI tokens] (admins and maintainers) and store it as the repository secret
|
|
5
5
|
#[code THUB_CI_TOKEN].
|
|
6
6
|
+code('.github/workflows/firmware.yml').
|
|
7
7
|
name: firmware
|
|
@@ -126,8 +126,13 @@
|
|
|
126
126
|
thub-admin jobs reset --yes # cancel every active job
|
|
127
127
|
thub-admin jobs clean --yes --before "01/09/2026 00:00:00"
|
|
128
128
|
p.small.
|
|
129
|
-
On #[a(href="/admin/users") Users]
|
|
129
|
+
On #[a(href="/admin/users") Users], click a name to edit that user. The quick filter is
|
|
130
130
|
#[strong All | Admin | Maintainer | User | Blocked]. The search box also matches usernames and emails.
|
|
131
|
+
Maintainers use Users too, for Agent-only users and their own account only. They can create, edit, block
|
|
132
|
+
and delete those users and issue or revoke their keys, but can't create or promote a maintainer or admin.
|
|
133
|
+
They don't see admins or other maintainers. They also manage #[a(href="/admin/agents") CI tokens].
|
|
134
|
+
#[strong Recent activity] at the bottom of Users is the audit log: #[strong Timestamp | User | Action], where
|
|
135
|
+
User is who made the change (a username, #[code thub-admin (<OS user>)], or #[code system]).
|
|
131
136
|
+note('warning').
|
|
132
137
|
Forgot the admin password? Start the Coordinator once with #[code THUB_BOOTSTRAP_ADMIN_PASSWORD=…]
|
|
133
138
|
(and optionally #[code THUB_BOOTSTRAP_ADMIN_USER], default #[code admin]). The password is reset on
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
| It shows up on #[a(href="/resources") Resources] by itself, usually within 10 s.
|
|
22
22
|
li
|
|
23
23
|
strong Create users and their access keys
|
|
24
|
-
| on #[a(href="/admin/users") Users] (admins), or on the Coordinator host with
|
|
24
|
+
| on #[a(href="/admin/users") Users] (admins and maintainers), or on the Coordinator host with
|
|
25
25
|
| #[code thub-admin user add <name> --email <email> --key]. The key is shown once.
|
|
26
26
|
li
|
|
27
27
|
strong Install and configure the Agent
|
package/views/layout.pug
CHANGED
|
@@ -53,7 +53,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
53
53
|
//- Below lg the menu drops down over the page (thub.css .thub-nav,
|
|
54
54
|
//- closed by public/js/actions.js): pages as a two-column grid with
|
|
55
55
|
//- icons, then the tools in a row, then the account row.
|
|
56
|
-
- const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/resources', 'resources', 'Resources', 'hdd-rack'], ['/groups', 'groups', 'Groups', 'collection'], ['/jobs', 'jobs', 'Jobs', 'list-task']
|
|
56
|
+
- const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/resources', 'resources', 'Resources', 'hdd-rack'], ['/groups', 'groups', 'Groups', 'collection'], ['/jobs', 'jobs', 'Jobs', 'list-task'], ['/admin/users', 'users', 'Users', 'people'], ['/admin/agents', 'agents', 'CI tokens', 'key']].concat(user.role === 'admin' ? [['/admin/settings', 'settings', 'Settings', 'gear']] : []) : []
|
|
57
57
|
ul.navbar-nav.me-auto.thub-nav-pages
|
|
58
58
|
each pg in pages
|
|
59
59
|
li.nav-item
|