@andrian.yablonskyy/thub-coordinator 1.1.2 → 1.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
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): Users, Settings, CI tokens…
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/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', requireAdminRole, updateAction(async () => {
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', requireAdminRole, updateAction(async (req) => {
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,40 @@ 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
+
1210
+ // A maintainer's Recent activity: about the users they see, and deleted
1211
+ // ones that were Agent-only users when created — nothing about admins or
1212
+ // other maintainers.
1213
+ function visibleEvents(me, visibleIds){
1214
+ const events = services.adminUsers.recentEvents(500),
1215
+ existing = new Set(services.adminUsers.list().map((u) => u.id)),
1216
+ createdAsUser = new Set(events.filter((e) => e.type === 'user.created' && e.data.role === 'user').map((e) => e.entity_id));
1217
+ return events.filter((e) => visibleIds.has(e.entity_id) || (!existing.has(e.entity_id) && createdAsUser.has(e.entity_id))).slice(0, 30);
1218
+ }
1202
1219
 
1203
1220
  function renderUsers(req, res, extra = {}){
1204
- const q = search.normalizeQuery(req.query.q),
1221
+ const me = req.session.user,
1222
+ filters = userFiltersFor(me),
1223
+ q = search.normalizeQuery(req.query.q),
1205
1224
  terms = search.parseTerms(q),
1206
- role = USER_ROLE_FILTERS.includes(req.query.role) ? req.query.role : undefined,
1225
+ role = filters.includes(req.query.role) ? req.query.role : undefined,
1207
1226
  view = listView(req, res, 'users', { q, role }),
1208
1227
  { sort, dir, size } = view.prefs,
1209
1228
  value = USER_SORT_VALUE[sort],
1210
- searched = services.adminUsers.list()
1229
+ visible = services.adminUsers.list().filter((u) => managesUser(me, u)),
1230
+ visibleIds = new Set(visible.map((u) => u.id)),
1231
+ searched = visible
1211
1232
  .filter((u) => search.matches([u.username, u.email, u.firstName, u.lastName, u.role, u.blocked ? 'blocked' : 'active'], terms)),
1212
1233
  // Counts for the filter buttons: within the search, before the role filter.
1213
- roleCounts = Object.fromEntries(USER_ROLE_FILTERS.map((r) => [r, searched.filter((u) => (r === 'blocked' ? u.blocked : u.role === r)).length])),
1234
+ roleCounts = Object.fromEntries(filters.map((r) => [r, searched.filter((u) => (r === 'blocked' ? u.blocked : u.role === r)).length])),
1214
1235
  sorted = searched
1215
1236
  .filter((u) => !role || (role === 'blocked' ? u.blocked : u.role === role))
1216
1237
  .sort((a, b) => {
@@ -1231,46 +1252,64 @@ function createWebRouter({ services, config }){
1231
1252
  users: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
1232
1253
  total: searched.length,
1233
1254
  roleFilter: role,
1234
- roleFilters: USER_ROLE_FILTERS,
1255
+ roleFilters: filters,
1235
1256
  userDisplayName,
1236
1257
  roleCounts,
1237
1258
  list: { ...view, pagination, pageUrl, q },
1238
1259
  q,
1239
- me: req.session.user,
1240
- roles: services.adminUsers.ROLES,
1260
+ me,
1261
+ // The roles this admin or maintainer may give.
1262
+ roles: isAdmin(me) ? services.adminUsers.ROLES : ['user'],
1241
1263
  roleHelp: {
1242
1264
  user: 'Agent only: submits jobs, reads their own results; no dashboard',
1243
- maintainer: 'Agent + the dashboard, without Users, Settings and CI tokens; sees and cancels every job',
1265
+ maintainer: 'Agent + the dashboard without Settings; manages Agent-only users and CI tokens; sees and cancels every job',
1244
1266
  admin: 'Everything'
1245
1267
  },
1246
1268
  roleTone: { user: 'secondary', maintainer: 'info', admin: 'primary' },
1247
- events: services.adminUsers.recentEvents(30),
1269
+ events: isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds),
1248
1270
  ...extra
1249
1271
  });
1250
1272
  }
1251
1273
 
1252
1274
  const userAction = (fn) => (req, res) => {
1253
- const by = req.session.user.username,
1254
- actorId = req.session.user.id;
1255
- try {
1256
- const result = fn(req, { by, actorId });
1257
- if (result?.secret){
1258
- return renderUsers(req, res, { secret: result.secret });
1275
+ const actor = req.session.user,
1276
+ by = actor.username,
1277
+ actorId = actor.id;
1278
+ try {
1279
+ // Someone this admin or maintainer may not manage answers like an
1280
+ // unknown id.
1281
+ if (req.params.id && !managesUser(actor, services.adminUsers.getById(req.params.id))){
1282
+ throw Object.assign(new Error('Unknown user'), { status: 404 });
1283
+ }
1284
+ const result = fn(req, { by, actorId, actor });
1285
+ if (result?.secret){
1286
+ return renderUsers(req, res, { secret: result.secret });
1287
+ }
1288
+ if (result?.message){
1289
+ flash(req, 'success', result.message);
1290
+ }
1259
1291
  }
1260
- if (result?.message){
1261
- flash(req, 'success', result.message);
1292
+ catch (err){
1293
+ flash(req, 'danger', err.message);
1262
1294
  }
1263
- }
1264
- catch (err){
1265
- flash(req, 'danger', err.message);
1266
- }
1267
- res.redirect(returnTo(req, '/admin/users')); // the same filter, sort and page
1268
- };
1295
+ res.redirect(returnTo(req, '/admin/users')); // the same filter, sort and page
1296
+ },
1297
+ notOwnAccount = (req, { actorId }, what) => {
1298
+ if (req.params.id === actorId){
1299
+ throw Object.assign(new Error(`You can't ${what} your own account.`), { status: 409 });
1300
+ }
1301
+ };
1269
1302
 
1270
- router.get('/admin/users', requireAdminRole, (req, res) => renderUsers(req, res));
1303
+ // Users and CI tokens: admins and maintainers (everyone who has a
1304
+ // dashboard session), each within managesUser.
1305
+ router.get('/admin/users', (req, res) => renderUsers(req, res));
1271
1306
 
1272
- router.post('/admin/users', requireAdminRole, userAction((req, ctx) => {
1273
- const { username, email, role, firstName, lastName } = req.body,
1307
+ router.post('/admin/users', userAction((req, ctx) => {
1308
+ const { username, email, role, firstName, lastName } = req.body;
1309
+ if (!isAdmin(ctx.actor) && normalizeRole(role) !== 'user'){
1310
+ throw new Error('Only an admin can create a maintainer or an admin.');
1311
+ }
1312
+ const
1274
1313
  created = services.adminUsers.create({ username, email, role, firstName, lastName, requireEmail: true }, ctx),
1275
1314
  key = req.body.issueKey === '1' ? services.adminUsers.issueKey(created.id, ctx) : null;
1276
1315
  if (!key && !created.tempPassword){
@@ -1279,31 +1318,36 @@ function createWebRouter({ services, config }){
1279
1318
  return { secret: { title: `User ${created.username} created`, key, password: created.tempPassword, username: created.username } };
1280
1319
  }));
1281
1320
 
1282
- router.post('/admin/users/:id', requireAdminRole, userAction((req, ctx) => {
1283
- const { username, email, role, firstName, lastName } = req.body,
1321
+ router.post('/admin/users/:id', userAction((req, ctx) => {
1322
+ const { username, email, role, firstName, lastName } = req.body;
1323
+ if (!isAdmin(ctx.actor) && role !== undefined && normalizeRole(role) !== services.adminUsers.getById(req.params.id).role){
1324
+ throw new Error('Only an admin can change a role.');
1325
+ }
1326
+ const
1284
1327
  { user, tempPassword } = services.adminUsers.update(req.params.id, { username, email, role, firstName, lastName }, ctx);
1285
1328
  return tempPassword
1286
1329
  ? { secret: { title: `${user.username} can now use the dashboard`, password: tempPassword, username: user.username } }
1287
1330
  : { message: `Saved ${user.username}.` };
1288
1331
  }));
1289
1332
 
1290
- router.post('/admin/users/:id/block', requireAdminRole, userAction((req, ctx) => {
1333
+ router.post('/admin/users/:id/block', userAction((req, ctx) => {
1334
+ notOwnAccount(req, ctx, 'block');
1291
1335
  const u = services.adminUsers.setBlocked(req.params.id, true, ctx);
1292
1336
  return { message: `Blocked ${u.username}: no sign-in, and their access key stops working.` };
1293
1337
  }));
1294
1338
 
1295
- router.post('/admin/users/:id/unblock', requireAdminRole, userAction((req, ctx) => {
1339
+ router.post('/admin/users/:id/unblock', userAction((req, ctx) => {
1296
1340
  const u = services.adminUsers.setBlocked(req.params.id, false, ctx);
1297
1341
  return { message: `Unblocked ${u.username}.` };
1298
1342
  }));
1299
1343
 
1300
- router.post('/admin/users/:id/reset-password', requireAdminRole, userAction((req, ctx) => {
1344
+ router.post('/admin/users/:id/reset-password', userAction((req, ctx) => {
1301
1345
  const u = services.adminUsers.getById(req.params.id),
1302
1346
  password = services.adminUsers.adminResetPassword(req.params.id, ctx);
1303
1347
  return { secret: { title: `New temporary password for ${u.username}`, password, username: u.username } };
1304
1348
  }));
1305
1349
 
1306
- router.post('/admin/users/:id/key', requireAdminRole, userAction((req, ctx) => {
1350
+ router.post('/admin/users/:id/key', userAction((req, ctx) => {
1307
1351
  const u = services.adminUsers.getById(req.params.id);
1308
1352
  if (!u){
1309
1353
  throw Object.assign(new Error('Unknown user'), { status: 404 });
@@ -1315,27 +1359,28 @@ function createWebRouter({ services, config }){
1315
1359
  return { secret: { title, key, username: u.username } };
1316
1360
  }));
1317
1361
 
1318
- router.post('/admin/users/:id/key/revoke', requireAdminRole, userAction((req, ctx) => {
1362
+ router.post('/admin/users/:id/key/revoke', userAction((req, ctx) => {
1319
1363
  const u = services.adminUsers.getById(req.params.id);
1320
1364
  services.adminUsers.revokeKey(req.params.id, ctx);
1321
1365
  return { message: `Revoked the access key of ${u?.username}.` };
1322
1366
  }));
1323
1367
 
1324
- router.post('/admin/users/:id/delete', requireAdminRole, userAction((req, ctx) => {
1368
+ router.post('/admin/users/:id/delete', userAction((req, ctx) => {
1369
+ notOwnAccount(req, ctx, 'delete');
1325
1370
  const u = services.adminUsers.getById(req.params.id);
1326
1371
  services.adminUsers.remove(req.params.id, ctx);
1327
1372
  return { message: `Deleted ${u?.username}. Their jobs stay, listed under their name.` };
1328
1373
  }));
1329
1374
 
1330
- router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
1375
+ router.get('/admin/agents', (req, res) => renderAgents(req, res));
1331
1376
 
1332
- router.post('/admin/agents', requireAdminRole, (req, res) => {
1377
+ router.post('/admin/agents', (req, res) => {
1333
1378
  // CI tokens only; people get their key from their user (§10.3).
1334
1379
  const { token } = services.agents.create({ name: req.body.name, kind: 'ci' });
1335
1380
  renderAgents(req, res, { newToken: token });
1336
1381
  });
1337
1382
 
1338
- router.post('/admin/agents/:id/rename', requireAdminRole, (req, res) => {
1383
+ router.post('/admin/agents/:id/rename', (req, res) => {
1339
1384
  try {
1340
1385
  const before = services.agents.get(req.params.id)?.name,
1341
1386
  agent = services.agents.rename(req.params.id, req.body.name);
@@ -1349,7 +1394,7 @@ function createWebRouter({ services, config }){
1349
1394
  res.redirect(returnTo(req, '/admin/agents'));
1350
1395
  });
1351
1396
 
1352
- router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
1397
+ router.post('/admin/agents/:id/revoke', (req, res) => {
1353
1398
  services.agents.revoke(req.params.id);
1354
1399
  res.redirect(returnTo(req, '/admin/agents'));
1355
1400
  });
@@ -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
- for (const url of ['/admin/users', '/admin/settings', '/admin/agents']){
190
- assert.equal((await get(mo.cookie, url)).status, 403, url);
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,100 @@ 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&#39;t block your own account|can't block your own account/);
351
+ assert.match(await post(`/admin/users/${ops.id}/delete`), /can&#39;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
+ });
@@ -1,8 +1,9 @@
1
1
  extends ../layout
2
2
  include ../mixins/list-controls
3
3
 
4
- //- Users (§10.3, admins). Roles: user — the Agent only; maintainer — the
5
- //- dashboard without its security features; admin — everything.
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
- .modal.text-start(tabindex="-1" id=`editUser-${u.id}` aria-labelledby=`editUserTitle-${u.id}`)
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
- +roleSelect(`u-${u.id}-role`, u.role)
181
- if !u.canUseDashboard
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
@@ -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,11 @@
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] (admins), click a name to edit that user. The quick filter is
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].
131
134
  +note('warning').
132
135
  Forgot the admin password? Start the Coordinator once with #[code THUB_BOOTSTRAP_ADMIN_PASSWORD=…]
133
136
  (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 &lt;name&gt; --email &lt;email&gt; --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']].concat(user.role === 'admin' ? [['/admin/users', 'users', 'Users', 'people'], ['/admin/agents', 'agents', 'CI tokens', 'key'], ['/admin/settings', 'settings', 'Settings', 'gear']] : []) : []
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