@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/bin/thub-admin.js +98 -7
- package/package.json +2 -2
- package/public/css/thub.css +108 -0
- package/public/js/actions.js +33 -0
- package/public/js/tooltips.js +5 -0
- package/src/api/agent.js +36 -9
- package/src/auth.js +39 -6
- package/src/db/migrations/026_users.sql +74 -0
- package/src/server.js +1 -1
- package/src/services/admin-users.js +291 -39
- package/src/services/agents.js +1 -1
- package/src/services/list-prefs.js +4 -0
- package/src/web/routes.js +213 -32
- package/test/admin-users.test.js +4 -4
- package/test/groups-tab.test.js +3 -3
- package/test/users.test.js +269 -0
- package/test/views.test.js +3 -1
- package/views/admin/agents.pug +7 -8
- package/views/admin/users.pug +249 -0
- package/views/help/_agent-cli.pug +4 -2
- package/views/help/_agent-setup.pug +28 -23
- package/views/help/_ci.pug +4 -4
- package/views/help/_coordinator.pug +4 -3
- package/views/help/_env.pug +1 -1
- package/views/help/_overview.pug +1 -1
- package/views/help/_quick-start.pug +5 -5
- package/views/help/_troubleshooting.pug +1 -0
- package/views/index.pug +1 -1
- package/views/layout.pug +19 -22
- package/views/mixins/client-config.pug +2 -2
- package/views/mixins/resource.pug +23 -21
- package/views/mixins/updates.pug +2 -2
- package/views/profile.pug +56 -0
- package/views/resources/list.pug +3 -3
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
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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:
|
|
900
|
+
canManage: true,
|
|
863
901
|
// For the Reset queue / Clean up database confirmation modals.
|
|
864
|
-
jobCounts:
|
|
865
|
-
cleanupInfo:
|
|
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',
|
|
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',
|
|
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:
|
|
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',
|
|
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:
|
|
1043
|
+
canManage: true
|
|
1006
1044
|
});
|
|
1007
1045
|
});
|
|
1008
1046
|
|
|
1009
|
-
router.post('/groups',
|
|
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',
|
|
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',
|
|
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: '
|
|
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
|
-
|
|
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
|
|
package/test/admin-users.test.js
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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: '
|
|
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, '
|
|
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 () => {
|
package/test/groups-tab.test.js
CHANGED
|
@@ -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: '
|
|
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,
|
|
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,
|
|
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
|
|