@andrian.yablonskyy/thub-coordinator 1.0.42 → 1.1.0

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/src/web/routes.js CHANGED
@@ -29,7 +29,8 @@ const fs = require('node:fs'),
29
29
  listPrefs = require('../services/list-prefs'),
30
30
  search = require('../services/search'),
31
31
  { createLoginGuard } = require('../login-guard'),
32
- { formatBytes } = require('../services/job-artifacts');
32
+ { formatBytes } = require('../services/job-artifacts'),
33
+ { roleLabel } = require('../services/admin-users');
33
34
 
34
35
  // When this process started: the settings page tells a restart happened by it changing.
35
36
  const STARTED_AT = new Date().toISOString(),
@@ -157,6 +158,9 @@ function createWebRouter({ services, config }){
157
158
  router.use((req, res, next) => {
158
159
  const user = req.session?.user || null;
159
160
  res.locals.user = user;
161
+ // What the signed-in user may do (§10.3): `operate` — the dashboard's
162
+ // actions (maintainer, admin); `admin` — its security features too.
163
+ res.locals.can = { operate: Boolean(user?.canUseDashboard), admin: user?.role === 'admin' };
160
164
  res.locals.messages = req.session?.flash || [];
161
165
  // Latest published versions (README §10.2) — navbar, Agents, Resources.
162
166
  res.locals.updates = services.updates.status();
@@ -177,6 +181,8 @@ function createWebRouter({ services, config }){
177
181
  res.locals.formatDateTime = formatDateTime;
178
182
  // 1233 -> "1.2 KB" (job artifacts).
179
183
  res.locals.fmtBytes = formatBytes;
184
+ // user -> "User" (§10.3): how roles are shown.
185
+ res.locals.roleLabel = roleLabel;
180
186
  res.locals.currentPath = req.originalUrl;
181
187
  // A live-update re-fetch (server.js, req.thubPassive) renders the page
182
188
  // only for its data: flash messages stay for the next real page view.
@@ -268,9 +274,15 @@ function createWebRouter({ services, config }){
268
274
  return res.status(401).render('login', { title: 'Sign in', messages: [{ type: 'danger', text: 'Invalid credentials' }] });
269
275
  }
270
276
  loginGuard.success(req.ip, username);
277
+ // Right password, but no dashboard for this account (§10.3).
278
+ if (user.blocked || !user.canUseDashboard){
279
+ return res.status(403).render('login', { title: 'Sign in', messages: [{ type: 'danger', text: user.blocked
280
+ ? 'This account is blocked — ask an admin.'
281
+ : 'This account has no dashboard access — use the Agent with your access key.' }] });
282
+ }
271
283
  req.session.user = user;
272
284
  req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
273
- res.redirect('/');
285
+ res.redirect(user.mustChangePassword ? '/profile' : '/');
274
286
  });
275
287
 
276
288
  router.post('/logout', (req, res) => {
@@ -278,17 +290,41 @@ function createWebRouter({ services, config }){
278
290
  });
279
291
 
280
292
  router.use(requireAdminSession);
293
+ router.use((req, res, next) => {
294
+ const u = req.session.user;
295
+ res.locals.can = { operate: true, admin: u.role === 'admin' };
296
+ next();
297
+ });
281
298
 
282
299
  // §10.1: every logged-in user (admin or viewer) manages their own
283
300
  // profile — display name, avatar, timezone, theme and idle session
284
301
  // timeout. Not gated by requireAdminRole: this only ever touches the
285
302
  // caller's own account (req.session.user.id), never another user's.
286
- router.get('/profile', (req, res) => {
303
+ function renderProfile(req, res, extra = {}){
304
+ const key = services.adminUsers.keyOf(req.session.user.id);
287
305
  res.render('profile', {
288
306
  title: 'Profile',
289
307
  timezones: Intl.supportedValuesOf('timeZone'),
290
- sessionTimeoutOptions: services.adminUsers.SESSION_TIMEOUT_OPTIONS_MIN
308
+ sessionTimeoutOptions: services.adminUsers.SESSION_TIMEOUT_OPTIONS_MIN,
309
+ accessKey: key && !key.revoked_at ? key : null,
310
+ ...extra
291
311
  });
312
+ }
313
+
314
+ router.get('/profile', (req, res) => renderProfile(req, res));
315
+
316
+ // Your own access key (§10.3): created or replaced here, shown once.
317
+ router.post('/profile/key', (req, res) => {
318
+ const { id, username } = req.session.user,
319
+ had = services.adminUsers.keyOf(id),
320
+ key = services.adminUsers.issueKey(id, { by: username });
321
+ renderProfile(req, res, { secret: { key, title: had && !had.revoked_at ? 'Your new access key — the old one has stopped working' : 'Your access key' } });
322
+ });
323
+
324
+ router.post('/profile/key/revoke', (req, res) => {
325
+ services.adminUsers.revokeKey(req.session.user.id, { by: req.session.user.username });
326
+ flash(req, 'warning', 'Your access key is revoked.');
327
+ res.redirect('/profile');
292
328
  });
293
329
 
294
330
  router.post('/profile', (req, res) => {
@@ -307,6 +343,7 @@ function createWebRouter({ services, config }){
307
343
  const userId = req.session.user.id,
308
344
  fields = {
309
345
  username: req.body.username,
346
+ ...(req.body.email !== undefined ? { email: req.body.email } : {}),
310
347
  firstName: req.body.firstName || null,
311
348
  lastName: req.body.lastName || null,
312
349
  timezone: req.body.timezone,
@@ -420,7 +457,8 @@ function createWebRouter({ services, config }){
420
457
  ping = setInterval(() => res.write(': ping\n\n'), LIVE_PING_MS),
421
458
  sessionCheck = setInterval(() => {
422
459
  req.sessionStore.get(req.sessionID, (err, sess) => {
423
- if (!err && !sess?.user){
460
+ const fresh = sess?.user && services.adminUsers.getById(sess.user.id);
461
+ if (!err && (!fresh || fresh.blocked || !fresh.canUseDashboard)){
424
462
  write('session-ended', {});
425
463
  res.end();
426
464
  }
@@ -486,7 +524,7 @@ function createWebRouter({ services, config }){
486
524
  });
487
525
  });
488
526
 
489
- router.post('/resources/:id/maintenance', requireAdminRole, (req, res) => {
527
+ router.post('/resources/:id/maintenance', (req, res) => {
490
528
  services.registry.setMaintenance(req.params.id, req.body.enabled === '1');
491
529
  res.redirect(returnTo(req, '/resources'));
492
530
  });
@@ -494,7 +532,7 @@ function createWebRouter({ services, config }){
494
532
  // Cancel whatever the Client is doing (resource card): its job, a manual
495
533
  // local lock, or a self-update hold (README §10). The Client applies the
496
534
  // lock/update ones via heartbeat commands; a job is canceled right here.
497
- router.post('/resources/:id/cancel-activity', requireAdminRole, (req, res) => {
535
+ router.post('/resources/:id/cancel-activity', (req, res) => {
498
536
  const r = services.registry.get(req.params.id),
499
537
  job = r && services.jobs.activeForResource(r.id),
500
538
  activity = r?.activity?.state;
@@ -534,7 +572,7 @@ function createWebRouter({ services, config }){
534
572
  res.redirect(returnTo(req, '/resources'));
535
573
  });
536
574
 
537
- router.post('/resources/:id/remove', requireAdminRole, (req, res) => {
575
+ router.post('/resources/:id/remove', (req, res) => {
538
576
  try {
539
577
  const { resource: r, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
540
578
  by: req.session.user.username,
@@ -556,7 +594,7 @@ function createWebRouter({ services, config }){
556
594
 
557
595
  // Reboot now (resource card footer): cancels a running job, then the
558
596
  // Client reboots its host on its next heartbeat (jobs.requestReboot).
559
- router.post('/resources/:id/reboot', requireAdminRole, (req, res) => {
597
+ router.post('/resources/:id/reboot', (req, res) => {
560
598
  try {
561
599
  const { resource: r, canceledJob } = services.jobs.requestReboot(req.params.id, { by: req.session.user.username });
562
600
  flash(req, 'warning', `Rebooting ${r.name}'s host on its next heartbeat.` + (canceledJob ? `\nCanceled its running job ${canceledJob}.` : ''));
@@ -580,7 +618,7 @@ function createWebRouter({ services, config }){
580
618
  };
581
619
  }
582
620
 
583
- router.post('/resources/:id/usb-scan', requireAdminRole, (req, res) => {
621
+ router.post('/resources/:id/usb-scan', (req, res) => {
584
622
  try {
585
623
  services.registry.requestUsbScan(req.params.id, { by: req.session.user.username });
586
624
  res.json(usbScanView(req, services.registry.get(req.params.id)));
@@ -599,7 +637,7 @@ function createWebRouter({ services, config }){
599
637
  });
600
638
 
601
639
  // Rename (resource card, Name row); empty = back to the Client's own name.
602
- router.post('/resources/:id/rename', requireAdminRole, (req, res) => {
640
+ router.post('/resources/:id/rename', (req, res) => {
603
641
  try {
604
642
  const before = services.registry.get(req.params.id)?.name,
605
643
  r = services.registry.rename(req.params.id, req.body.name, { by: req.session.user.username });
@@ -616,7 +654,7 @@ function createWebRouter({ services, config }){
616
654
  // Capabilities (resource card, Config tab): public/js/client-config.js
617
655
  // sends the edited hw/sw section as JSON in `config`. Applied by the Client
618
656
  // on its next heartbeat, then it restarts once idle.
619
- router.post('/resources/:id/config', requireAdminRole, (req, res) => {
657
+ router.post('/resources/:id/config', (req, res) => {
620
658
  try {
621
659
  let config;
622
660
  try {
@@ -637,7 +675,7 @@ function createWebRouter({ services, config }){
637
675
 
638
676
  // Groups tab (resource card): the Client's group membership, as a config
639
677
  // revision the Client writes to its file (registry.setClientGroups).
640
- router.post('/resources/:id/groups', requireAdminRole, (req, res) => {
678
+ router.post('/resources/:id/groups', (req, res) => {
641
679
  try {
642
680
  const ids = [].concat(req.body.groups || []).filter((g) => typeof g === 'string' && g),
643
681
  r = services.registry.setClientGroups(req.params.id, ids, {
@@ -655,7 +693,7 @@ function createWebRouter({ services, config }){
655
693
 
656
694
  // Export (resource card, admins — the file holds the join key): the
657
695
  // Client's config file as JSON, secrets excluded.
658
- router.get('/resources/:id/config/export', requireAdminRole, (req, res) => {
696
+ router.get('/resources/:id/config/export', (req, res) => {
659
697
  try {
660
698
  const r = services.registry.get(req.params.id),
661
699
  file = services.registry.exportClientConfig(req.params.id);
@@ -669,7 +707,7 @@ function createWebRouter({ services, config }){
669
707
 
670
708
  // Import (resource card, public/js/config-import.js): a config file, applied
671
709
  // by the Client on its next heartbeat like a Config tab Save.
672
- router.post('/resources/:id/config/import', requireAdminRole, (req, res) => {
710
+ router.post('/resources/:id/config/import', (req, res) => {
673
711
  try {
674
712
  let file;
675
713
  try {
@@ -691,7 +729,7 @@ function createWebRouter({ services, config }){
691
729
 
692
730
  // Scheduled host reboot (resource card): save a cron expression, or clear
693
731
  // it; the Client applies it on its next heartbeat.
694
- router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
732
+ router.post('/resources/:id/reboot-schedule', (req, res) => {
695
733
  try {
696
734
  const cron = req.body.clear === '1' ? '' : req.body.cron,
697
735
  r = services.registry.setRebootSchedule(req.params.id, cron, { by: req.session.user.username });
@@ -736,7 +774,7 @@ function createWebRouter({ services, config }){
736
774
  // Check now (navbar, Agents and Resources pages): fetch the latest
737
775
  // versions, then report whether the Coordinator and the connected
738
776
  // (heartbeating) Clients are behind — as a warning if anything is.
739
- router.post('/updates/check', requireAdminRole, updateAction(async () => {
777
+ router.post('/updates/check', updateAction(async () => {
740
778
  const s = await services.updates.checkNow(),
741
779
  parts = [];
742
780
  if (!s.fetched.length){
@@ -792,13 +830,13 @@ function createWebRouter({ services, config }){
792
830
  });
793
831
  });
794
832
 
795
- router.post('/resources/update-all', requireAdminRole, updateAction(async () => {
833
+ router.post('/resources/update-all', updateAction(async () => {
796
834
  const version = await services.updates.targetVersion('client'),
797
835
  n = services.registry.requestUpdateAll(version);
798
836
  return `Requested update to client v${version} on ${n} resource(s).`;
799
837
  }));
800
838
 
801
- router.post('/resources/:id/update', requireAdminRole, updateAction(async (req) => {
839
+ router.post('/resources/:id/update', updateAction(async (req) => {
802
840
  // Cancel (next to the version): withdraw the request and, if the host is
803
841
  // already holding for the update, abort that too (the Client's next
804
842
  // heartbeat) — the one control for canceling a Client update.
@@ -859,17 +897,17 @@ function createWebRouter({ services, config }){
859
897
  list: { ...view, pagination, pageUrl, q: filters.q },
860
898
  // A row's Cancel comes back to this exact view (filters, sort, page).
861
899
  returnTo: pageUrl(),
862
- canManage: req.session.user.role === 'admin',
900
+ canManage: true,
863
901
  // For the Reset queue / Clean up database confirmation modals.
864
- jobCounts: req.session.user.role === 'admin' ? services.jobs.countActiveAndFinished() : null,
865
- cleanupInfo: req.session.user.role === 'admin' ? cleanupInfo(req) : null
902
+ jobCounts: services.jobs.countActiveAndFinished(),
903
+ cleanupInfo: cleanupInfo(req)
866
904
  });
867
905
  });
868
906
 
869
907
  // "Reset" the queue: cancel everything active. "Clean": delete finished
870
908
  // job history. Two distinct, deliberately separate destructive actions
871
909
  // (§13.1) — reset stops in-flight work, clean clears past work.
872
- router.post('/jobs/reset-queue', requireAdminRole, (req, res) => {
910
+ router.post('/jobs/reset-queue', (req, res) => {
873
911
  const canceled = services.jobs.resetQueue();
874
912
  flash(req, 'warning', `Canceled ${canceled} active job(s).`);
875
913
  res.redirect('/jobs');
@@ -878,7 +916,7 @@ function createWebRouter({ services, config }){
878
916
  // "Clean up database" (§9): everything before the given time — finished
879
917
  // jobs with their logs, and older history — then VACUUM. The
880
918
  // time is dd/mm/yyyy HH:MM:SS in the user's own time zone.
881
- router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
919
+ router.post('/jobs/clean-history', (req, res) => {
882
920
  const tz = req.session.user.timezone || 'UTC',
883
921
  text = String(req.body.before || '').trim(),
884
922
  before = text ? parseDateTime(text, { timeZone: tz }) : new Date();
@@ -919,12 +957,12 @@ function createWebRouter({ services, config }){
919
957
  job.spec.target.client)
920
958
  : null,
921
959
  jobActive,
922
- canCancel: req.session.user.role === 'admin' && jobActive
960
+ canCancel: jobActive
923
961
  });
924
962
  });
925
963
 
926
964
  // From the job page or a row on /jobs (returnTo says which).
927
- router.post('/jobs/:id/cancel', requireAdminRole, (req, res) => {
965
+ router.post('/jobs/:id/cancel', (req, res) => {
928
966
  try {
929
967
  services.jobs.cancel(req.params.id, { isAdmin: true });
930
968
  flash(req, 'warning', `Canceled job ${req.params.id}.`);
@@ -1002,21 +1040,21 @@ function createWebRouter({ services, config }){
1002
1040
  groups,
1003
1041
  q,
1004
1042
  totalCount: all.length,
1005
- canManage: req.session.user.role === 'admin'
1043
+ canManage: true
1006
1044
  });
1007
1045
  });
1008
1046
 
1009
- router.post('/groups', requireAdminRole, (req, res) => {
1047
+ router.post('/groups', (req, res) => {
1010
1048
  services.groups.create({ name: req.body.name, comment: req.body.comment });
1011
1049
  res.redirect('/groups');
1012
1050
  });
1013
1051
 
1014
- router.post('/groups/:id', requireAdminRole, (req, res) => {
1052
+ router.post('/groups/:id', (req, res) => {
1015
1053
  services.groups.update(req.params.id, { name: req.body.name, comment: req.body.comment });
1016
1054
  res.redirect('/groups');
1017
1055
  });
1018
1056
 
1019
- router.post('/groups/:id/delete', requireAdminRole, (req, res) => {
1057
+ router.post('/groups/:id/delete', (req, res) => {
1020
1058
  services.groups.remove(req.params.id);
1021
1059
  flash(req, 'warning', 'Group deleted; any resource that listed it just stopped matching on it.');
1022
1060
  res.redirect('/groups');
@@ -1030,7 +1068,9 @@ function createWebRouter({ services, config }){
1030
1068
  view = listView(req, res, 'agents', { q }),
1031
1069
  { sort, dir, size } = view.prefs,
1032
1070
  value = AGENT_SORT_VALUE[sort],
1071
+ // CI tokens only: a person's key belongs to their user (Users, §10.3).
1033
1072
  sorted = services.agents.list()
1073
+ .filter((a) => !a.user_id)
1034
1074
  .filter((a) => search.matches([a.id, a.name, a.kind, a.version, a.revoked_at ? 'revoked' : 'active'], terms))
1035
1075
  .sort((a, b) => {
1036
1076
  const va = value(a),
@@ -1043,7 +1083,7 @@ function createWebRouter({ services, config }){
1043
1083
  pagination = listPrefs.paginate(sorted.length, size, req.query.page),
1044
1084
  pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
1045
1085
  res.render('admin/agents', {
1046
- title: 'Agents',
1086
+ title: 'CI tokens',
1047
1087
  active: 'agents',
1048
1088
  liveTopics: 'agents',
1049
1089
  agents: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
@@ -1144,10 +1184,151 @@ function createWebRouter({ services, config }){
1144
1184
  res.set('Cache-Control', 'no-store').json({ startedAt: STARTED_AT });
1145
1185
  });
1146
1186
 
1187
+ // Users (§10.3, admins): accounts, roles, blocking, passwords and each
1188
+ // user's access key. A new key or temporary password is shown once, on
1189
+ // the page the action returns — never stored anywhere.
1190
+ // Searched (?q=), filtered by role (?role=user|maintainer|admin|blocked),
1191
+ // sorted and paged like the other lists (§10.1).
1192
+ const USER_ROLE_FILTERS = ['user', 'maintainer', 'admin', 'blocked'],
1193
+ ROLE_ORDER = { user: 0, maintainer: 1, admin: 2 },
1194
+ USER_SORT_VALUE = {
1195
+ username: (u) => u.username.toLowerCase(),
1196
+ role: (u) => ROLE_ORDER[u.role],
1197
+ status: (u) => (u.blocked ? 1 : 0),
1198
+ used: (u) => u.key?.lastUsedAt || null
1199
+ };
1200
+
1201
+ function renderUsers(req, res, extra = {}){
1202
+ const q = search.normalizeQuery(req.query.q),
1203
+ terms = search.parseTerms(q),
1204
+ role = USER_ROLE_FILTERS.includes(req.query.role) ? req.query.role : undefined,
1205
+ view = listView(req, res, 'users', { q, role }),
1206
+ { sort, dir, size } = view.prefs,
1207
+ value = USER_SORT_VALUE[sort],
1208
+ searched = services.adminUsers.list()
1209
+ .filter((u) => search.matches([u.username, u.email, u.firstName, u.lastName, u.role, u.blocked ? 'blocked' : 'active'], terms)),
1210
+ // Counts for the filter buttons: within the search, before the role filter.
1211
+ roleCounts = Object.fromEntries(USER_ROLE_FILTERS.map((r) => [r, searched.filter((u) => (r === 'blocked' ? u.blocked : u.role === r)).length])),
1212
+ sorted = searched
1213
+ .filter((u) => !role || (role === 'blocked' ? u.blocked : u.role === role))
1214
+ .sort((a, b) => {
1215
+ const va = value(a),
1216
+ vb = value(b);
1217
+ if (va == null || vb == null){
1218
+ return (va == null) - (vb == null);
1219
+ }
1220
+ // Names naturally (dev2 before dev10); other values as they are.
1221
+ const cmp = typeof va === 'string' ? va.localeCompare(vb, undefined, { numeric: true }) : (va < vb ? -1 : va > vb ? 1 : 0);
1222
+ return (dir === 'asc' ? 1 : -1) * cmp || a.username.localeCompare(b.username, undefined, { numeric: true });
1223
+ }),
1224
+ pagination = listPrefs.paginate(sorted.length, size, req.query.page),
1225
+ pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
1226
+ res.status(extra.status || 200).render('admin/users', {
1227
+ title: 'Users',
1228
+ active: 'users',
1229
+ users: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
1230
+ total: searched.length,
1231
+ roleFilter: role,
1232
+ roleFilters: USER_ROLE_FILTERS,
1233
+ roleCounts,
1234
+ list: { ...view, pagination, pageUrl, q },
1235
+ q,
1236
+ me: req.session.user,
1237
+ roles: services.adminUsers.ROLES,
1238
+ roleHelp: {
1239
+ user: 'Agent only: submits jobs, reads their own results; no dashboard',
1240
+ maintainer: 'Agent + the dashboard, without Users, Settings and CI tokens; sees and cancels every job',
1241
+ admin: 'Everything'
1242
+ },
1243
+ roleTone: { user: 'secondary', maintainer: 'info', admin: 'primary' },
1244
+ events: services.adminUsers.recentEvents(30),
1245
+ ...extra
1246
+ });
1247
+ }
1248
+
1249
+ const userAction = (fn) => (req, res) => {
1250
+ const by = req.session.user.username,
1251
+ actorId = req.session.user.id;
1252
+ try {
1253
+ const result = fn(req, { by, actorId });
1254
+ if (result?.secret){
1255
+ return renderUsers(req, res, { secret: result.secret });
1256
+ }
1257
+ if (result?.message){
1258
+ flash(req, 'success', result.message);
1259
+ }
1260
+ }
1261
+ catch (err){
1262
+ flash(req, 'danger', err.message);
1263
+ }
1264
+ res.redirect(returnTo(req, '/admin/users')); // the same filter, sort and page
1265
+ };
1266
+
1267
+ router.get('/admin/users', requireAdminRole, (req, res) => renderUsers(req, res));
1268
+
1269
+ router.post('/admin/users', requireAdminRole, userAction((req, ctx) => {
1270
+ const { username, email, role, firstName, lastName } = req.body,
1271
+ created = services.adminUsers.create({ username, email, role, firstName, lastName, requireEmail: true }, ctx),
1272
+ key = req.body.issueKey === '1' ? services.adminUsers.issueKey(created.id, ctx) : null;
1273
+ if (!key && !created.tempPassword){
1274
+ return { message: `User ${created.username} created.` };
1275
+ }
1276
+ return { secret: { title: `User ${created.username} created`, key, password: created.tempPassword, username: created.username } };
1277
+ }));
1278
+
1279
+ router.post('/admin/users/:id', requireAdminRole, userAction((req, ctx) => {
1280
+ const { username, email, role, firstName, lastName } = req.body,
1281
+ { user, tempPassword } = services.adminUsers.update(req.params.id, { username, email, role, firstName, lastName }, ctx);
1282
+ return tempPassword
1283
+ ? { secret: { title: `${user.username} can now use the dashboard`, password: tempPassword, username: user.username } }
1284
+ : { message: `Saved ${user.username}.` };
1285
+ }));
1286
+
1287
+ router.post('/admin/users/:id/block', requireAdminRole, userAction((req, ctx) => {
1288
+ const u = services.adminUsers.setBlocked(req.params.id, true, ctx);
1289
+ return { message: `Blocked ${u.username}: no sign-in, and their access key stops working.` };
1290
+ }));
1291
+
1292
+ router.post('/admin/users/:id/unblock', requireAdminRole, userAction((req, ctx) => {
1293
+ const u = services.adminUsers.setBlocked(req.params.id, false, ctx);
1294
+ return { message: `Unblocked ${u.username}.` };
1295
+ }));
1296
+
1297
+ router.post('/admin/users/:id/reset-password', requireAdminRole, userAction((req, ctx) => {
1298
+ const u = services.adminUsers.getById(req.params.id),
1299
+ password = services.adminUsers.adminResetPassword(req.params.id, ctx);
1300
+ return { secret: { title: `New temporary password for ${u.username}`, password, username: u.username } };
1301
+ }));
1302
+
1303
+ router.post('/admin/users/:id/key', requireAdminRole, userAction((req, ctx) => {
1304
+ const u = services.adminUsers.getById(req.params.id);
1305
+ if (!u){
1306
+ throw Object.assign(new Error('Unknown user'), { status: 404 });
1307
+ }
1308
+ const current = services.adminUsers.keyOf(u.id),
1309
+ hadKey = Boolean(current && !current.revoked_at),
1310
+ key = services.adminUsers.issueKey(u.id, ctx),
1311
+ title = hadKey ? `New access key for ${u.username} — the old one has stopped working` : `Access key for ${u.username}`;
1312
+ return { secret: { title, key, username: u.username } };
1313
+ }));
1314
+
1315
+ router.post('/admin/users/:id/key/revoke', requireAdminRole, userAction((req, ctx) => {
1316
+ const u = services.adminUsers.getById(req.params.id);
1317
+ services.adminUsers.revokeKey(req.params.id, ctx);
1318
+ return { message: `Revoked the access key of ${u?.username}.` };
1319
+ }));
1320
+
1321
+ router.post('/admin/users/:id/delete', requireAdminRole, userAction((req, ctx) => {
1322
+ const u = services.adminUsers.getById(req.params.id);
1323
+ services.adminUsers.remove(req.params.id, ctx);
1324
+ return { message: `Deleted ${u?.username}. Their jobs stay, listed under their name.` };
1325
+ }));
1326
+
1147
1327
  router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
1148
1328
 
1149
1329
  router.post('/admin/agents', requireAdminRole, (req, res) => {
1150
- const { token } = services.agents.create({ name: req.body.name, kind: req.body.kind });
1330
+ // CI tokens only; people get their key from their user (§10.3).
1331
+ const { token } = services.agents.create({ name: req.body.name, kind: 'ci' });
1151
1332
  renderAgents(req, res, { newToken: token });
1152
1333
  });
1153
1334
 
@@ -37,7 +37,7 @@ test('a newly created user gets profile defaults (UTC, auto theme, 60min timeout
37
37
 
38
38
  test('updateProfile only touches the fields passed, leaving the rest as-is', () => {
39
39
  const adminUsers = buildService(),
40
- { id } = adminUsers.create({ username: 'bob', password: 'hunter2', role: 'viewer' });
40
+ { id } = adminUsers.create({ username: 'bob', password: 'hunter2', role: 'maintainer' });
41
41
 
42
42
  adminUsers.updateProfile(id, { firstName: 'Bob', lastName: 'Builder', timezone: 'America/New_York' });
43
43
  const afterFirst = adminUsers.getById(id);
@@ -54,7 +54,7 @@ test('updateProfile only touches the fields passed, leaving the rest as-is', ()
54
54
  test('renaming a user to a username already held by someone else is rejected (409)', () => {
55
55
  const adminUsers = buildService();
56
56
  adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
57
- const { id: bobId } = adminUsers.create({ username: 'bob', password: 'x', role: 'viewer' });
57
+ const { id: bobId } = adminUsers.create({ username: 'bob', password: 'x', role: 'maintainer' });
58
58
 
59
59
  assert.throws(() => adminUsers.updateProfile(bobId, { username: 'alice' }), (err) => err.status === 409);
60
60
  });
@@ -91,14 +91,14 @@ test('resetPassword creates the user as admin when the username does not exist y
91
91
 
92
92
  test('resetPassword on an existing user changes only the password, not the role', async () => {
93
93
  const adminUsers = buildService();
94
- adminUsers.create({ username: 'carol', password: 'oldpass', role: 'viewer' });
94
+ adminUsers.create({ username: 'carol', password: 'oldpass', role: 'maintainer' });
95
95
 
96
96
  adminUsers.resetPassword({ username: 'carol', password: 'newpass' });
97
97
 
98
98
  assert.equal(await adminUsers.verify('carol', 'oldpass'), null);
99
99
  const user = await adminUsers.verify('carol', 'newpass');
100
100
  assert.ok(user);
101
- assert.equal(user.role, 'viewer'); // not silently promoted to admin
101
+ assert.equal(user.role, 'maintainer'); // not silently promoted to admin
102
102
  });
103
103
 
104
104
  test('changePassword succeeds with the correct current password', async () => {
@@ -35,7 +35,7 @@ async function start(t){
35
35
  await new Promise((r) => server.on('listening', r));
36
36
  const base = `http://127.0.0.1:${server.address().port}`;
37
37
  services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
38
- services.adminUsers.create({ username: 'view', password: 'pw', role: 'viewer' });
38
+ services.adminUsers.create({ username: 'view', password: 'pw', role: 'maintainer' });
39
39
  const login = async (u) => (await fetch(`${base}/login`, {
40
40
  method: 'POST', body: new URLSearchParams({ username: u, password: 'pw' }), redirect: 'manual'
41
41
  })).headers.get('set-cookie').split(';')[0];
@@ -57,12 +57,12 @@ test('Groups tab: in effect at once, sent to the Client as its next config revis
57
57
  }),
58
58
  page = async () => (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text();
59
59
 
60
- assert.equal((await save(viewer, [nightly.id])).status, 403);
60
+ assert.equal((await save(viewer, [nightly.id])).status, 302); // a maintainer may (§10.3)
61
61
  assert.equal((await save(admin, [nightly.id, pr.id])).status, 302);
62
62
  const r = services.registry.get(id);
63
63
  assert.deepEqual(r.group_ids.sort(), [nightly.id, pr.id].sort()); // scheduling follows now
64
64
  const pending = services.registry.pendingConfig(id);
65
- assert.equal(pending.revision, 1);
65
+ assert.equal(pending.revision, 2); // the maintainer's save was revision 1
66
66
  assert.deepEqual(pending.file.groups.sort(), [nightly.id, pr.id].sort()); // the Client writes it to its file
67
67
  assert.match(await page(), /Groups of lab-sw-01 saved: 2 group\(s\) — in effect now/);
68
68