@andrian.yablonskyy/thub-coordinator 1.1.1 → 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/README.md +1 -1
- package/package.json +1 -1
- package/src/auth.js +3 -1
- package/src/web/routes.js +92 -44
- package/systemd/thub-coordinator.service +4 -0
- package/test/systemd-unit.test.js +25 -0
- package/test/users.test.js +120 -3
- package/views/admin/users.pug +17 -17
- package/views/help/_ci.pug +1 -1
- package/views/help/_client-setup.pug +4 -1
- package/views/help/_coordinator.pug +8 -0
- package/views/help/_quick-start.pug +1 -1
- package/views/help/_troubleshooting.pug +1 -0
- package/views/layout.pug +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ This gives you two global commands: `thub-coordinator` (the server itself) and `
|
|
|
14
14
|
|
|
15
15
|
- `~/.config/thub/coordinator.json` with every default option (see Configuration below), if it doesn't exist yet.
|
|
16
16
|
- `~/var/lib/thub` (the `dataDir`: SQLite DB, uploaded artifacts, avatars) with its `artifacts/`, `avatars/` and `work/` subdirectories.
|
|
17
|
-
- On Linux as root: `/etc/systemd/system/thub-coordinator.service`, enabled and started, so the Coordinator runs now and on every boot. A re-install/upgrade restarts it on the new code.
|
|
17
|
+
- On Linux as root: `/etc/systemd/system/thub-coordinator.service` (`ProtectSystem=strict` with a private writable `/tmp`, which SQLite needs), enabled and started, so the Coordinator runs now and on every boot. A re-install/upgrade restarts it on the new code.
|
|
18
18
|
|
|
19
19
|
Under `sudo`, `~` means the home of the user who ran `sudo` (`SUDO_USER`), not `/root`. That user owns the config and data and the service runs as them. Without root the config and directories are still created; the systemd step is skipped.
|
|
20
20
|
|
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.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):
|
|
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',
|
|
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.';
|
|
@@ -1189,26 +1189,49 @@ function createWebRouter({ services, config }){
|
|
|
1189
1189
|
// the page the action returns — never stored anywhere.
|
|
1190
1190
|
// Searched (?q=), filtered by role (?role=user|maintainer|admin|blocked),
|
|
1191
1191
|
// sorted and paged like the other lists (§10.1).
|
|
1192
|
-
const USER_ROLE_FILTERS = ['
|
|
1192
|
+
const USER_ROLE_FILTERS = ['admin', 'maintainer', 'user', 'blocked'],
|
|
1193
1193
|
ROLE_ORDER = { user: 0, maintainer: 1, admin: 2 },
|
|
1194
|
+
// What the User column shows: first and last name, else the username.
|
|
1195
|
+
userDisplayName = (u) => `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.username,
|
|
1194
1196
|
USER_SORT_VALUE = {
|
|
1195
|
-
username: (u) => u.
|
|
1197
|
+
username: (u) => userDisplayName(u).toLowerCase(),
|
|
1196
1198
|
role: (u) => ROLE_ORDER[u.role],
|
|
1197
1199
|
status: (u) => (u.blocked ? 1 : 0),
|
|
1198
1200
|
used: (u) => u.key?.lastUsedAt || null
|
|
1199
|
-
}
|
|
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
|
+
}
|
|
1200
1219
|
|
|
1201
1220
|
function renderUsers(req, res, extra = {}){
|
|
1202
|
-
const
|
|
1221
|
+
const me = req.session.user,
|
|
1222
|
+
filters = userFiltersFor(me),
|
|
1223
|
+
q = search.normalizeQuery(req.query.q),
|
|
1203
1224
|
terms = search.parseTerms(q),
|
|
1204
|
-
role =
|
|
1225
|
+
role = filters.includes(req.query.role) ? req.query.role : undefined,
|
|
1205
1226
|
view = listView(req, res, 'users', { q, role }),
|
|
1206
1227
|
{ sort, dir, size } = view.prefs,
|
|
1207
1228
|
value = USER_SORT_VALUE[sort],
|
|
1208
|
-
|
|
1229
|
+
visible = services.adminUsers.list().filter((u) => managesUser(me, u)),
|
|
1230
|
+
visibleIds = new Set(visible.map((u) => u.id)),
|
|
1231
|
+
searched = visible
|
|
1209
1232
|
.filter((u) => search.matches([u.username, u.email, u.firstName, u.lastName, u.role, u.blocked ? 'blocked' : 'active'], terms)),
|
|
1210
1233
|
// Counts for the filter buttons: within the search, before the role filter.
|
|
1211
|
-
roleCounts = Object.fromEntries(
|
|
1234
|
+
roleCounts = Object.fromEntries(filters.map((r) => [r, searched.filter((u) => (r === 'blocked' ? u.blocked : u.role === r)).length])),
|
|
1212
1235
|
sorted = searched
|
|
1213
1236
|
.filter((u) => !role || (role === 'blocked' ? u.blocked : u.role === role))
|
|
1214
1237
|
.sort((a, b) => {
|
|
@@ -1229,45 +1252,64 @@ function createWebRouter({ services, config }){
|
|
|
1229
1252
|
users: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
|
|
1230
1253
|
total: searched.length,
|
|
1231
1254
|
roleFilter: role,
|
|
1232
|
-
roleFilters:
|
|
1255
|
+
roleFilters: filters,
|
|
1256
|
+
userDisplayName,
|
|
1233
1257
|
roleCounts,
|
|
1234
1258
|
list: { ...view, pagination, pageUrl, q },
|
|
1235
1259
|
q,
|
|
1236
|
-
me
|
|
1237
|
-
roles
|
|
1260
|
+
me,
|
|
1261
|
+
// The roles this admin or maintainer may give.
|
|
1262
|
+
roles: isAdmin(me) ? services.adminUsers.ROLES : ['user'],
|
|
1238
1263
|
roleHelp: {
|
|
1239
1264
|
user: 'Agent only: submits jobs, reads their own results; no dashboard',
|
|
1240
|
-
maintainer: 'Agent + the dashboard
|
|
1265
|
+
maintainer: 'Agent + the dashboard without Settings; manages Agent-only users and CI tokens; sees and cancels every job',
|
|
1241
1266
|
admin: 'Everything'
|
|
1242
1267
|
},
|
|
1243
1268
|
roleTone: { user: 'secondary', maintainer: 'info', admin: 'primary' },
|
|
1244
|
-
events: services.adminUsers.recentEvents(30),
|
|
1269
|
+
events: isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds),
|
|
1245
1270
|
...extra
|
|
1246
1271
|
});
|
|
1247
1272
|
}
|
|
1248
1273
|
|
|
1249
1274
|
const userAction = (fn) => (req, res) => {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
+
}
|
|
1256
1291
|
}
|
|
1257
|
-
|
|
1258
|
-
flash(req, '
|
|
1292
|
+
catch (err){
|
|
1293
|
+
flash(req, 'danger', err.message);
|
|
1259
1294
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
+
};
|
|
1266
1302
|
|
|
1267
|
-
|
|
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));
|
|
1268
1306
|
|
|
1269
|
-
router.post('/admin/users',
|
|
1270
|
-
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
|
|
1271
1313
|
created = services.adminUsers.create({ username, email, role, firstName, lastName, requireEmail: true }, ctx),
|
|
1272
1314
|
key = req.body.issueKey === '1' ? services.adminUsers.issueKey(created.id, ctx) : null;
|
|
1273
1315
|
if (!key && !created.tempPassword){
|
|
@@ -1276,31 +1318,36 @@ function createWebRouter({ services, config }){
|
|
|
1276
1318
|
return { secret: { title: `User ${created.username} created`, key, password: created.tempPassword, username: created.username } };
|
|
1277
1319
|
}));
|
|
1278
1320
|
|
|
1279
|
-
router.post('/admin/users/:id',
|
|
1280
|
-
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
|
|
1281
1327
|
{ user, tempPassword } = services.adminUsers.update(req.params.id, { username, email, role, firstName, lastName }, ctx);
|
|
1282
1328
|
return tempPassword
|
|
1283
1329
|
? { secret: { title: `${user.username} can now use the dashboard`, password: tempPassword, username: user.username } }
|
|
1284
1330
|
: { message: `Saved ${user.username}.` };
|
|
1285
1331
|
}));
|
|
1286
1332
|
|
|
1287
|
-
router.post('/admin/users/:id/block',
|
|
1333
|
+
router.post('/admin/users/:id/block', userAction((req, ctx) => {
|
|
1334
|
+
notOwnAccount(req, ctx, 'block');
|
|
1288
1335
|
const u = services.adminUsers.setBlocked(req.params.id, true, ctx);
|
|
1289
1336
|
return { message: `Blocked ${u.username}: no sign-in, and their access key stops working.` };
|
|
1290
1337
|
}));
|
|
1291
1338
|
|
|
1292
|
-
router.post('/admin/users/:id/unblock',
|
|
1339
|
+
router.post('/admin/users/:id/unblock', userAction((req, ctx) => {
|
|
1293
1340
|
const u = services.adminUsers.setBlocked(req.params.id, false, ctx);
|
|
1294
1341
|
return { message: `Unblocked ${u.username}.` };
|
|
1295
1342
|
}));
|
|
1296
1343
|
|
|
1297
|
-
router.post('/admin/users/:id/reset-password',
|
|
1344
|
+
router.post('/admin/users/:id/reset-password', userAction((req, ctx) => {
|
|
1298
1345
|
const u = services.adminUsers.getById(req.params.id),
|
|
1299
1346
|
password = services.adminUsers.adminResetPassword(req.params.id, ctx);
|
|
1300
1347
|
return { secret: { title: `New temporary password for ${u.username}`, password, username: u.username } };
|
|
1301
1348
|
}));
|
|
1302
1349
|
|
|
1303
|
-
router.post('/admin/users/:id/key',
|
|
1350
|
+
router.post('/admin/users/:id/key', userAction((req, ctx) => {
|
|
1304
1351
|
const u = services.adminUsers.getById(req.params.id);
|
|
1305
1352
|
if (!u){
|
|
1306
1353
|
throw Object.assign(new Error('Unknown user'), { status: 404 });
|
|
@@ -1312,27 +1359,28 @@ function createWebRouter({ services, config }){
|
|
|
1312
1359
|
return { secret: { title, key, username: u.username } };
|
|
1313
1360
|
}));
|
|
1314
1361
|
|
|
1315
|
-
router.post('/admin/users/:id/key/revoke',
|
|
1362
|
+
router.post('/admin/users/:id/key/revoke', userAction((req, ctx) => {
|
|
1316
1363
|
const u = services.adminUsers.getById(req.params.id);
|
|
1317
1364
|
services.adminUsers.revokeKey(req.params.id, ctx);
|
|
1318
1365
|
return { message: `Revoked the access key of ${u?.username}.` };
|
|
1319
1366
|
}));
|
|
1320
1367
|
|
|
1321
|
-
router.post('/admin/users/:id/delete',
|
|
1368
|
+
router.post('/admin/users/:id/delete', userAction((req, ctx) => {
|
|
1369
|
+
notOwnAccount(req, ctx, 'delete');
|
|
1322
1370
|
const u = services.adminUsers.getById(req.params.id);
|
|
1323
1371
|
services.adminUsers.remove(req.params.id, ctx);
|
|
1324
1372
|
return { message: `Deleted ${u?.username}. Their jobs stay, listed under their name.` };
|
|
1325
1373
|
}));
|
|
1326
1374
|
|
|
1327
|
-
router.get('/admin/agents',
|
|
1375
|
+
router.get('/admin/agents', (req, res) => renderAgents(req, res));
|
|
1328
1376
|
|
|
1329
|
-
router.post('/admin/agents',
|
|
1377
|
+
router.post('/admin/agents', (req, res) => {
|
|
1330
1378
|
// CI tokens only; people get their key from their user (§10.3).
|
|
1331
1379
|
const { token } = services.agents.create({ name: req.body.name, kind: 'ci' });
|
|
1332
1380
|
renderAgents(req, res, { newToken: token });
|
|
1333
1381
|
});
|
|
1334
1382
|
|
|
1335
|
-
router.post('/admin/agents/:id/rename',
|
|
1383
|
+
router.post('/admin/agents/:id/rename', (req, res) => {
|
|
1336
1384
|
try {
|
|
1337
1385
|
const before = services.agents.get(req.params.id)?.name,
|
|
1338
1386
|
agent = services.agents.rename(req.params.id, req.body.name);
|
|
@@ -1346,7 +1394,7 @@ function createWebRouter({ services, config }){
|
|
|
1346
1394
|
res.redirect(returnTo(req, '/admin/agents'));
|
|
1347
1395
|
});
|
|
1348
1396
|
|
|
1349
|
-
router.post('/admin/agents/:id/revoke',
|
|
1397
|
+
router.post('/admin/agents/:id/revoke', (req, res) => {
|
|
1350
1398
|
services.agents.revoke(req.params.id);
|
|
1351
1399
|
res.redirect(returnTo(req, '/admin/agents'));
|
|
1352
1400
|
});
|
|
@@ -16,6 +16,10 @@ Restart=always
|
|
|
16
16
|
RestartSec=2
|
|
17
17
|
NoNewPrivileges=yes
|
|
18
18
|
ProtectSystem=strict
|
|
19
|
+
# A private, writable /tmp: ProtectSystem=strict makes the shared one
|
|
20
|
+
# read-only, and SQLite needs temp files for big sorts (a migration that
|
|
21
|
+
# rebuilds a table and its index) — without them: SQLITE_IOERR_GETTEMPPATH.
|
|
22
|
+
PrivateTmp=yes
|
|
19
23
|
ReadWritePaths=/var/lib/thub/var/lib/thub
|
|
20
24
|
|
|
21
25
|
[Install]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/systemd-unit.test.js
|
|
3
|
+
* @description Tests: the shipped systemd unit lets SQLite write its temp files
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
path = require('node:path');
|
|
20
|
+
|
|
21
|
+
test('systemd unit: ProtectSystem=strict comes with a private writable /tmp (SQLITE_IOERR_GETTEMPPATH)', () => {
|
|
22
|
+
const unit = fs.readFileSync(path.join(__dirname, '../systemd/thub-coordinator.service'), 'utf8');
|
|
23
|
+
assert.match(unit, /^ProtectSystem=strict$/m);
|
|
24
|
+
assert.match(unit, /^PrivateTmp=yes$/m);
|
|
25
|
+
});
|
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);
|
|
@@ -245,7 +246,7 @@ test('Users page: quick filter by role with counts, sorting and pages; role desc
|
|
|
245
246
|
method: 'POST', body: new URLSearchParams({ username: 'root', password: 'pw' }), redirect: 'manual'
|
|
246
247
|
})).headers.get('set-cookie').split(';')[0],
|
|
247
248
|
page = (q) => fetch(`${base}/admin/users${q}`, { headers: { cookie } }).then((r) => r.text()),
|
|
248
|
-
names = (html) => [...html.matchAll(/<
|
|
249
|
+
names = (html) => [...html.matchAll(/<a class="fw-medium" href="#editUser-[^"]+"[^>]*>([^<]+)/g)].map((m) => m[1]),
|
|
249
250
|
|
|
250
251
|
users = await page('?role=user&size=10');
|
|
251
252
|
assert.match(users, /Showing 1–10 of 12 users/); // before the two below are added
|
|
@@ -259,6 +260,9 @@ test('Users page: quick filter by role with counts, sorting and pages; role desc
|
|
|
259
260
|
assert.match(users, /href="\/admin\/users\?role=user&size=10&sort=username&dir=asc&page=2"/); // pages keep the filter
|
|
260
261
|
assert.deepEqual(names(await page('?role=blocked')), ['mo']);
|
|
261
262
|
assert.match(users, />Maintainer<span class="badge/); // filter buttons
|
|
263
|
+
const filterNav = users.slice(users.indexOf('aria-label="Filter by role"'), users.indexOf('</nav>', users.indexOf('aria-label="Filter by role"')));
|
|
264
|
+
assert.deepEqual([...filterNav.matchAll(/<a [^>]*>([^<]+)</g)].map((m) => m[1].trim()), ['All', 'Admin', 'Maintainer', 'User', 'Blocked']);
|
|
265
|
+
assert.doesNotMatch(users, /d1@example\.com<\/div>|>Edit<\/button>/); // the name only; it opens Edit
|
|
262
266
|
assert.match(users, /href="[^"]*role=maintainer/); // …while values stay lowercase
|
|
263
267
|
assert.deepEqual(names(await page('?role=admin&size=all')), ['root']);
|
|
264
268
|
assert.deepEqual(names(await page('?role=&sort=role&dir=desc&size=all')).slice(0, 2), ['root', 'mo']); // admin, maintainer, users…
|
|
@@ -267,3 +271,116 @@ test('Users page: quick filter by role with counts, sorting and pages; role desc
|
|
|
267
271
|
assert.match(users, /<option value="user" selected="selected" data-help="Agent only[^"]*">User<\/option>/);
|
|
268
272
|
assert.match(users, /<div class="form-text" id="new-role-help" aria-live="polite">Agent only/);
|
|
269
273
|
});
|
|
274
|
+
|
|
275
|
+
test('Users page: the User column shows first and last name, sorts by it, and opens Edit', async (t) => {
|
|
276
|
+
const { base, services } = await start(t),
|
|
277
|
+
u = services.adminUsers;
|
|
278
|
+
u.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' });
|
|
279
|
+
const zed = u.create({ username: 'aaa', email: 'a@example.com', role: 'user', firstName: 'Zed', lastName: 'Young' });
|
|
280
|
+
u.create({ username: 'zzz', email: 'z@example.com', role: 'user', firstName: 'Ann' });
|
|
281
|
+
const cookie = (await fetch(`${base}/login`, {
|
|
282
|
+
method: 'POST', body: new URLSearchParams({ username: 'root', password: 'pw' }), redirect: 'manual'
|
|
283
|
+
})).headers.get('set-cookie').split(';')[0],
|
|
284
|
+
html = await (await fetch(`${base}/admin/users?sort=username&dir=asc&size=all`, { headers: { cookie } })).text(),
|
|
285
|
+
cells = [...html.matchAll(/<a class="fw-medium" href="#editUser-([^"]+)"[^>]*>([^<]+)/g)];
|
|
286
|
+
assert.deepEqual(cells.map((m) => m[2]), ['Ann', 'root', 'Zed Young']); // no name → the username
|
|
287
|
+
assert.equal(cells[2][1], zed.id);
|
|
288
|
+
assert.match(html, new RegExp(`id="editUser-${zed.id}"`)); // the modal it opens
|
|
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
|
+
});
|
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)
|
|
@@ -68,17 +69,10 @@ block content
|
|
|
68
69
|
tbody
|
|
69
70
|
each u in users
|
|
70
71
|
tr(class=u.blocked ? 'opacity-50' : '')
|
|
72
|
+
//- Just the name (the username until one is set); it opens Edit,
|
|
73
|
+
//- where the username, email and the rest are.
|
|
71
74
|
td
|
|
72
|
-
.fw-medium
|
|
73
|
-
= u.username
|
|
74
|
-
if u.id === me.id
|
|
75
|
-
span.badge.text-bg-light.border.ms-1 you
|
|
76
|
-
if u.firstName || u.lastName
|
|
77
|
-
.small= `${u.firstName || ''} ${u.lastName || ''}`.trim()
|
|
78
|
-
if u.email
|
|
79
|
-
.small.text-body-secondary= u.email
|
|
80
|
-
else
|
|
81
|
-
span.badge.text-bg-warning(data-bs-toggle="tooltip" data-bs-title="Every user needs an email — add it with Edit") no email
|
|
75
|
+
a.fw-medium(href=`#editUser-${u.id}` data-bs-toggle="modal" role="button")= userDisplayName(u)
|
|
82
76
|
td
|
|
83
77
|
span.badge(class=`text-bg-${roleTone[u.role]}` data-bs-toggle="tooltip" data-bs-title=roleHelp[u.role])= roleLabel(u.role)
|
|
84
78
|
td
|
|
@@ -98,8 +92,7 @@ block content
|
|
|
98
92
|
else
|
|
99
93
|
span.text-body-secondary none
|
|
100
94
|
td.text-end.text-nowrap
|
|
101
|
-
|
|
102
|
-
form.d-inline.ms-1(
|
|
95
|
+
form.d-inline(
|
|
103
96
|
method="post"
|
|
104
97
|
action=`/admin/users/${u.id}/key`
|
|
105
98
|
data-confirm=u.key ? `Issue a new access key for ${u.username}? Their current key stops working at once.` : `Create an access key for ${u.username}?`
|
|
@@ -161,7 +154,8 @@ block content
|
|
|
161
154
|
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Delete" data-bs-toggle="tooltip" data-bs-title="Delete the user")
|
|
162
155
|
i.bi.bi-trash
|
|
163
156
|
|
|
164
|
-
|
|
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}`)
|
|
165
159
|
.modal-dialog
|
|
166
160
|
.modal-content
|
|
167
161
|
form(method="post" action=`/admin/users/${u.id}`)
|
|
@@ -185,8 +179,14 @@ block content
|
|
|
185
179
|
input.form-control(id=`u-${u.id}-last` name="lastName" value=u.lastName || '')
|
|
186
180
|
.col-12
|
|
187
181
|
label.form-label(for=`u-${u.id}-role`) Role
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
190
190
|
.form-text Making them a maintainer or admin gives them a temporary password for the dashboard.
|
|
191
191
|
.modal-footer
|
|
192
192
|
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
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
|
|
@@ -103,7 +103,10 @@
|
|
|
103
103
|
p.small.
|
|
104
104
|
SW Clients have no settings of their own: they run whatever DUT image a job brings (#[code --docker-image]).
|
|
105
105
|
Admins can also edit an HW Client's devices from its resource card (#[strong ST-Link | UART | DUT USB]
|
|
106
|
-
tabs).
|
|
106
|
+
tabs). The tabs show the config the Client runs, as it reported it at its last start, or a saved
|
|
107
|
+
change it hasn't applied yet. #[strong Connected USB devices → Import to config] fills them from the host's #[code lsusb].
|
|
108
|
+
There's no ST-Link serial field: the job's #[code --command] picks the probe
|
|
109
|
+
(#[code $THUB_DUT_STLINK_<n>]), and the Client reads each serial from its udev symlink.
|
|
107
110
|
|
|
108
111
|
h3.h6 Several DUT slots on one machine
|
|
109
112
|
+code('One instance per slot').
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
#[code ~/.config/thub/coordinator.json] (with a random #[code sessionSecret]) and
|
|
14
14
|
#[code ~/var/lib/thub] (database, avatars). It installs, enables and starts
|
|
15
15
|
#[code thub-coordinator.service], plus the root helper behind the navbar's #[strong Update app] button.
|
|
16
|
+
The unit uses #[code ProtectSystem=strict] with #[code PrivateTmp=yes], so the data directory and a
|
|
17
|
+
private #[code /tmp] (SQLite's temp files) are the only places the service can write.
|
|
16
18
|
|
|
17
19
|
h3.h6 2. Configure
|
|
18
20
|
p.small.
|
|
@@ -123,6 +125,12 @@
|
|
|
123
125
|
thub-admin resource maintenance <resourceId> --on
|
|
124
126
|
thub-admin jobs reset --yes # cancel every active job
|
|
125
127
|
thub-admin jobs clean --yes --before "01/09/2026 00:00:00"
|
|
128
|
+
p.small.
|
|
129
|
+
On #[a(href="/admin/users") Users], click a name to edit that user. The quick filter is
|
|
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].
|
|
126
134
|
+note('warning').
|
|
127
135
|
Forgot the admin password? Start the Coordinator once with #[code THUB_BOOTSTRAP_ADMIN_PASSWORD=…]
|
|
128
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 <name> --email <email> --key]. The key is shown once.
|
|
26
26
|
li
|
|
27
27
|
strong Install and configure the Agent
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
['429 Too Many Requests', 'A caller went over `rateLimit.requestsPerMinute` (per agent or resource token, per dashboard user, else per IP; default 500). The Agent and Client wait for `Retry-After` and retry by themselves; a script calling the API should do the same. Raise the limit in the config file or on Settings if your normal traffic needs more (a Client streaming logs makes up to ~240 requests a minute). Too many sign-in attempts from one address: wait a minute (`rateLimit.loginPerMinute`).'],
|
|
22
22
|
['Sign-in says “Too many failed sign-ins”', 'After repeated wrong passwords the Coordinator makes each further attempt wait, doubling each time: per address from the 5th failure (up to 15 min), per username from the 10th from any address (up to 5 min). Wait the time shown, or sign in from another address; a successful sign-in clears it, and an hour without failures forgets it. The Coordinator never tells whether a username exists.'],
|
|
23
23
|
['Agent says “Invalid or revoked access key” or “… is blocked”', 'The key was replaced (`thub key rotate`, an admin, your profile) or revoked, or your user is blocked. Ask an admin for a new key (Users), or create one on your profile if you can sign in. Then `thub config set key <key>`; `thub whoami` checks it.'],
|
|
24
|
+
['The Coordinator won\'t start after an upgrade: `SqliteError: disk I/O error` (`SQLITE_IOERR_GETTEMPPATH`)', 'Its systemd unit is older than 1.1.1: it has `ProtectSystem=strict` without `PrivateTmp=yes`, so SQLite has no writable temp directory for a large migration. The migration rolls back, so no data is lost. Install the latest version with `sudo npm i -g @andrian.yablonskyy/thub-coordinator` (the dashboard can\'t update a Coordinator that\'s down), or run `sudo systemctl edit thub-coordinator`, add `[Service]` and `PrivateTmp=yes`, then `sudo systemctl restart thub-coordinator`.'],
|
|
24
25
|
['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
|
|
25
26
|
]
|
|
26
27
|
const withCode = (text) => text
|
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
|