@andrian.yablonskyy/thub-coordinator 1.0.43 → 1.1.1

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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * @file packages/coordinator/test/users.test.js
3
+ * @description Tests: user management (§10.3) — migration, roles, blocking, the last admin, access keys, the Agent API and the dashboard
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
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ { spawnSync } = require('node:child_process'),
22
+ Database = require('better-sqlite3'),
23
+ { openDb } = require('../src/db'),
24
+ { hashToken } = require('../src/services/tokens'),
25
+ { loadConfig } = require('../src/config'),
26
+ { buildServices, createApp } = require('../src/server');
27
+
28
+ const MIGRATIONS = path.join(__dirname, '..', 'src', 'db', 'migrations');
29
+
30
+ test('migration: dashboard accounts and developer tokens become users; every existing key keeps working', () => {
31
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-mig-')), 'thub.db'),
32
+ old = new Database(file);
33
+ // The schema just before user management.
34
+ old.exec('CREATE TABLE schema_migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
35
+ for (const f of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith('.sql') && n < '026').sort()){
36
+ old.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8'));
37
+ old.prepare('INSERT INTO schema_migrations VALUES (?, ?)').run(f, 'x');
38
+ }
39
+ const t = '2026-01-01T00:00:00.000Z',
40
+ addAccount = (id, username, role) => old
41
+ .prepare('INSERT INTO admin_users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)')
42
+ .run(id, username, 'salt:hash', role, t),
43
+ addAgent = (id, name, kind, token, revoked = null, created = t) => old.prepare(
44
+ 'INSERT INTO agents (id, name, kind, token_hash, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)'
45
+ ).run(id, name, kind, hashToken(token), created, revoked);
46
+ addAccount('usr_a', 'admin', 'admin');
47
+ addAccount('usr_v', 'vera', 'viewer');
48
+ addAgent('agt_aaaaaaaa-1', 'alice', 'cli', 'agt_key-alice');
49
+ addAgent('agt_bbbbbbbb-2', 'alice', 'cli', 'agt_key-alice-2', null, '2026-02-01T00:00:00.000Z'); // same name, later
50
+ addAgent('agt_cccccccc-3', 'admin', 'cli', 'agt_key-taken'); // clashes with a dashboard account
51
+ addAgent('agt_dddddddd-4', 'bob', 'cli', 'agt_key-bob', '2026-03-01T00:00:00.000Z'); // revoked
52
+ addAgent('agt_eeeeeeee-5', 'pipeline', 'ci', 'agt_key-ci');
53
+ old.close();
54
+
55
+ const db = openDb(file),
56
+ users = Object.fromEntries(db.prepare('SELECT * FROM users').all().map((u) => [u.username, u]));
57
+ assert.equal(users.admin.role, 'admin');
58
+ assert.equal(users.vera.role, 'maintainer'); // viewer → maintainer
59
+ assert.equal(users.alice.role, 'user');
60
+ assert.ok(users['alice-bbbbbb'], 'a second "alice" gets a suffix');
61
+ assert.ok(users['admin-cccccc'], 'a token named like a dashboard account gets a suffix');
62
+ assert.equal(users.bob.blocked_at, '2026-03-01T00:00:00.000Z'); // revoked → blocked, kept for history
63
+ assert.equal(users.alice.password_hash, null); // no dashboard
64
+ // The keys: still the same agents rows, now owned by their users; CI stays a CI token.
65
+ const owner = (token) => db.prepare('SELECT a.kind, u.username FROM agents a LEFT JOIN users u ON u.id = a.user_id WHERE token_hash = ?')
66
+ .get(hashToken(token));
67
+ assert.deepEqual({ ...owner('agt_key-alice') }, { kind: 'cli', username: 'alice' });
68
+ assert.deepEqual({ ...owner('agt_key-ci') }, { kind: 'ci', username: null });
69
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM sqlite_master WHERE name = \'admin_users\'').get().n, 0);
70
+ });
71
+
72
+ function start(t, extra = {}){
73
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-users-')),
74
+ file = path.join(dir, 'coordinator.json');
75
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 }, rateLimit: { loginPerMinute: 0 }, ...extra }));
76
+ const config = loadConfig(file),
77
+ services = buildServices(config),
78
+ server = createApp(config, services).listen(0, '127.0.0.1');
79
+ t.after(() => new Promise((r) => {
80
+ server.closeAllConnections?.();
81
+ server.close(r);
82
+ }));
83
+ return new Promise((r) => server.on('listening', () => r({ base: `http://127.0.0.1:${server.address().port}`, services, file })));
84
+ }
85
+
86
+ test('users: temporary passwords, the last admin can\'t be locked out, delete keeps job history', async (t) => {
87
+ const { services } = await start(t),
88
+ u = services.adminUsers,
89
+ root = u.create({ username: 'root', email: 'root@example.com', role: 'admin', password: 'pw' }),
90
+ ops = u.create({ username: 'ops', email: 'ops@example.com', role: 'maintainer' }),
91
+ dev = u.create({ username: 'dev', email: 'dev@example.com', role: 'user' });
92
+
93
+ assert.ok(ops.tempPassword); // maintainers get one to change
94
+ assert.equal(u.getById(ops.id).mustChangePassword, true);
95
+ assert.equal(dev.tempPassword, null); // a user has no dashboard password
96
+ assert.throws(() => u.create({ username: 'x', role: 'user', requireEmail: true }), /Email/);
97
+ assert.throws(() => u.create({ username: 'y', email: 'ROOT@example.com', role: 'user' }), /already used/);
98
+ assert.throws(() => u.create({ username: 'bad name', email: 'z@example.com', role: 'user' }), /Username/);
99
+
100
+ // The only admin: not blockable, demotable or deletable — not even by themselves.
101
+ assert.throws(() => u.setBlocked(root.id, true, { actorId: ops.id }), /last active admin/);
102
+ assert.throws(() => u.update(root.id, { role: 'maintainer' }, { actorId: ops.id }), /last active admin/);
103
+ assert.throws(() => u.remove(root.id, { actorId: root.id }), /your own account/);
104
+ // With a second admin, the first can go — by someone else.
105
+ u.update(ops.id, { role: 'admin' }, { actorId: root.id });
106
+ u.setBlocked(root.id, true, { actorId: ops.id });
107
+ assert.equal(u.getById(root.id).blocked, true);
108
+
109
+ // Delete: the account goes, the key row stays (revoked) under the name.
110
+ const key = u.issueKey(dev.id),
111
+ keyRow = u.keyOf(dev.id);
112
+ assert.ok(key.startsWith('thk_'));
113
+ u.remove(dev.id, { actorId: ops.id });
114
+ const kept = services.db.prepare('SELECT * FROM agents WHERE id = ?').get(keyRow.id);
115
+ assert.equal(kept.name, 'dev');
116
+ assert.ok(kept.revoked_at);
117
+ assert.equal(kept.user_id, null);
118
+ // The audit log has it.
119
+ assert.ok(u.recentEvents().some((e) => e.type === 'user.deleted' && e.data.username === 'dev'));
120
+ });
121
+
122
+ test('Agent API: a user sees and cancels only their own jobs; maintainers all; blocked keys stop; keys rotate', async (t) => {
123
+ const { base, services } = await start(t),
124
+ u = services.adminUsers,
125
+ alice = u.create({ username: 'alice', email: 'a@example.com', role: 'user' }),
126
+ bob = u.create({ username: 'bob', email: 'b@example.com', role: 'user' }),
127
+ maint = u.create({ username: 'mo', email: 'm@example.com', role: 'maintainer', password: 'pw' }),
128
+ keys = { alice: u.issueKey(alice.id), bob: u.issueKey(bob.id), mo: u.issueKey(maint.id) },
129
+ call = async (key, method, url, body) => {
130
+ const res = await fetch(`${base}/api/v1${url}`, {
131
+ method, headers: { authorization: `Bearer ${key}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body && JSON.stringify(body)
132
+ });
133
+ return { status: res.status, body: await res.json().catch(() => null) };
134
+ };
135
+ services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
136
+ const submit = async (key) => (await call(key, 'POST', '/jobs', { target: { type: 'sw' }, command: './t.sh' })).body.jobId,
137
+ aliceJob = await submit(keys.alice),
138
+ bobJob = await submit(keys.bob);
139
+
140
+ assert.equal((await call(keys.alice, 'GET', `/jobs/${aliceJob}`)).status, 200);
141
+ assert.equal((await call(keys.alice, 'GET', `/jobs/${bobJob}`)).status, 404); // like an unknown id
142
+ assert.deepEqual((await call(keys.alice, 'GET', '/jobs')).body.jobs.map((j) => j.id), [aliceJob]);
143
+ assert.equal((await call(keys.mo, 'GET', `/jobs/${bobJob}`)).status, 200); // a maintainer sees all
144
+ assert.equal((await call(keys.mo, 'POST', `/jobs/${bobJob}/cancel`)).status, 200); // …and cancels any
145
+
146
+ // whoami / key show.
147
+ const me = (await call(keys.alice, 'GET', '/me')).body;
148
+ assert.deepEqual(me.user, { username: 'alice', email: 'a@example.com', role: 'user' });
149
+ assert.equal(me.key.hint, keys.alice.slice(-4));
150
+
151
+ // Rotate: the old key stops at once, the new one works — and the jobs stay hers.
152
+ const rotated = (await call(keys.alice, 'POST', '/me/key/rotate')).body.key;
153
+ assert.equal((await call(keys.alice, 'GET', '/me')).status, 401);
154
+ assert.equal((await call(rotated, 'GET', `/jobs/${aliceJob}`)).status, 200);
155
+
156
+ // Blocked: the key stops; unblocked: it works again.
157
+ u.setBlocked(alice.id, true);
158
+ const blocked = await call(rotated, 'GET', '/me');
159
+ assert.equal(blocked.status, 401);
160
+ assert.match(blocked.body.error, /alice is blocked/);
161
+ u.setBlocked(alice.id, false);
162
+ assert.equal((await call(rotated, 'GET', '/me')).status, 200);
163
+
164
+ // A CI token isn't rotated by itself.
165
+ const ci = services.agents.create({ name: 'pipeline', kind: 'ci' }).token;
166
+ assert.equal((await call(ci, 'POST', '/me/key/rotate')).status, 403);
167
+ assert.equal((await call(ci, 'GET', `/jobs/${bobJob}`)).status, 200); // CI reads every job, as before
168
+ });
169
+
170
+ test('dashboard: only maintainers and admins sign in; security pages are admins\'; blocking signs out at once', async (t) => {
171
+ const { base, services } = await start(t),
172
+ u = services.adminUsers,
173
+ admin = u.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' }),
174
+ maint = u.create({ username: 'mo', email: 'm@example.com', role: 'maintainer', password: 'pw' }),
175
+ temp = u.create({ username: 'newbie', email: 'n@example.com', role: 'maintainer' }),
176
+ signIn = async (username, password) => {
177
+ const res = await fetch(`${base}/login`, { method: 'POST', body: new URLSearchParams({ username, password }), redirect: 'manual' });
178
+ return { status: res.status, location: res.headers.get('location'), cookie: res.headers.get('set-cookie')?.split(';')[0], text: await res.text() };
179
+ },
180
+ get = (cookie, url) => fetch(`${base}${url}`, { headers: { cookie }, redirect: 'manual' });
181
+ u.create({ username: 'dev', email: 'd@example.com', role: 'user', password: 'pw' });
182
+
183
+ const dev = await signIn('dev', 'pw');
184
+ assert.equal(dev.status, 403);
185
+ assert.match(dev.text, /no dashboard access/);
186
+
187
+ const mo = await signIn('mo', 'pw');
188
+ assert.equal((await get(mo.cookie, '/resources')).status, 200);
189
+ for (const url of ['/admin/users', '/admin/settings', '/admin/agents']){
190
+ assert.equal((await get(mo.cookie, url)).status, 403, url);
191
+ }
192
+ const root = await signIn('root', 'pw');
193
+ assert.equal((await get(root.cookie, '/admin/users')).status, 200);
194
+
195
+ // Blocked while signed in: the next click goes to the sign-in page.
196
+ u.setBlocked(maint.id, true, { actorId: admin.id });
197
+ const after = await get(mo.cookie, '/resources');
198
+ assert.equal(after.status, 302);
199
+ assert.equal(after.headers.get('location'), '/login');
200
+ assert.match((await signIn('mo', 'pw')).text, /blocked/);
201
+
202
+ // A temporary password: the profile first, until it's changed.
203
+ const nb = await signIn('newbie', temp.tempPassword);
204
+ assert.equal(nb.location, '/profile');
205
+ assert.equal((await get(nb.cookie, '/jobs')).headers.get('location'), '/profile');
206
+ await fetch(`${base}/profile/password`, {
207
+ method: 'POST', headers: { cookie: nb.cookie }, redirect: 'manual',
208
+ body: new URLSearchParams({ currentPassword: temp.tempPassword, newPassword: 'mine-now', confirmPassword: 'mine-now' })
209
+ });
210
+ assert.equal((await get(nb.cookie, '/jobs')).status, 200);
211
+
212
+ // The Users page creates a user and shows their key once.
213
+ const created = await (await fetch(`${base}/admin/users`, {
214
+ method: 'POST', headers: { cookie: root.cookie },
215
+ body: new URLSearchParams({ username: 'carol', email: 'c@example.com', role: 'user', issueKey: '1' })
216
+ })).text();
217
+ assert.match(created, /User carol created/);
218
+ assert.match(created, /value="thk_[A-Za-z0-9_-]+"/);
219
+ });
220
+
221
+ test('thub-admin user: add, list, block, key — from the Coordinator host', async (t) => {
222
+ const { file } = await start(t),
223
+ run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'thub-admin.js'), 'user', ...args], {
224
+ env: { ...process.env, THUB_COORDINATOR_CONFIG: file }, encoding: 'utf8'
225
+ }),
226
+ added = run('add', 'ops', '--email', 'ops@example.com', '--role', 'maintainer', '--key');
227
+ assert.match(added.stdout, /Created Maintainer "ops"/);
228
+ assert.match(added.stdout, /Temporary password \(shown once/);
229
+ assert.match(added.stdout, /Access key \(shown once\): thk_/);
230
+ assert.match(run('list').stdout, /ops\s+Maintainer\s+active\s+ops@example\.com\s+key: …/);
231
+ assert.match(run('block', 'ops').stdout, /Blocked "ops"/);
232
+ assert.match(run('add', 'x').stderr + run('add', 'x').stdout, /Usage|email/);
233
+ });
234
+
235
+ test('Users page: quick filter by role with counts, sorting and pages; role descriptions under the picker', async (t) => {
236
+ const { base, services } = await start(t),
237
+ u = services.adminUsers;
238
+ u.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' });
239
+ for (let i = 1; i <= 12; i++){
240
+ u.create({ username: `dev${String(i).padStart(2, '0')}`, email: `d${i}@example.com`, role: 'user' });
241
+ }
242
+ const mo = u.create({ username: 'mo', email: 'm@example.com', role: 'maintainer' });
243
+ u.setBlocked(mo.id, true);
244
+ const cookie = (await fetch(`${base}/login`, {
245
+ method: 'POST', body: new URLSearchParams({ username: 'root', password: 'pw' }), redirect: 'manual'
246
+ })).headers.get('set-cookie').split(';')[0],
247
+ page = (q) => fetch(`${base}/admin/users${q}`, { headers: { cookie } }).then((r) => r.text()),
248
+ names = (html) => [...html.matchAll(/<div class="fw-medium">([^<]+)/g)].map((m) => m[1]),
249
+
250
+ users = await page('?role=user&size=10');
251
+ assert.match(users, /Showing 1–10 of 12 users/); // before the two below are added
252
+ assert.deepEqual(names(users).slice(0, 2), ['dev01', 'dev02']);
253
+ u.create({ username: 'dev9', email: 'd9x@example.com', role: 'user' });
254
+ u.create({ username: 'dev100', email: 'd100x@example.com', role: 'user' });
255
+ const natural = names(await page('?role=user&size=all')).filter((n) => ['dev9', 'dev100', 'dev12'].includes(n));
256
+ assert.deepEqual(natural, ['dev9', 'dev12', 'dev100']); // natural order
257
+ assert.ok(names(users).every((n) => n.startsWith('dev')));
258
+ assert.match(users, /aria-current="page"[^>]*>User<span class="badge text-bg-light ms-1">12/); // the active filter, with its count
259
+ assert.match(users, /href="\/admin\/users\?role=user&amp;size=10&amp;sort=username&amp;dir=asc&amp;page=2"/); // pages keep the filter
260
+ assert.deepEqual(names(await page('?role=blocked')), ['mo']);
261
+ assert.match(users, />Maintainer<span class="badge/); // filter buttons
262
+ assert.match(users, /href="[^"]*role=maintainer/); // …while values stay lowercase
263
+ assert.deepEqual(names(await page('?role=admin&size=all')), ['root']);
264
+ assert.deepEqual(names(await page('?role=&sort=role&dir=desc&size=all')).slice(0, 2), ['root', 'mo']); // admin, maintainer, users…
265
+
266
+ // The role picker: names only, the chosen role's description below it.
267
+ assert.match(users, /<option value="user" selected="selected" data-help="Agent only[^"]*">User<\/option>/);
268
+ assert.match(users, /<div class="form-text" id="new-role-help" aria-live="polite">Agent only/);
269
+ });
@@ -39,7 +39,9 @@ test('every view compiles', () => {
39
39
  // mixin or a broken include, so render the whole page.
40
40
  test('help page renders every section with this Coordinator\'s URL', () => {
41
41
  const html = pug.renderFile(path.join(VIEWS, 'help', 'index.pug'), {
42
- user: { username: 'viewer', role: 'viewer', theme: 'auto' },
42
+ user: { username: 'maint', role: 'maintainer', theme: 'auto', canUseDashboard: true },
43
+ can: { operate: true, admin: false },
44
+ roleLabel: (r) => r.charAt(0).toUpperCase() + r.slice(1),
43
45
  updates: {},
44
46
  messages: [],
45
47
  currentPath: '/help',
@@ -4,8 +4,8 @@ include ../mixins/list-controls
4
4
 
5
5
  block content
6
6
  .d-flex.justify-content-between.align-items-center.mb-3
7
- h1.h4.mb-0 Agents (CI / developers)
8
- button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
7
+ h1.h4.mb-0 CI tokens
8
+ button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") New CI token
9
9
  .d-flex.flex-wrap.align-items-center.gap-2.mb-3
10
10
  +updatesToolbar('agent', 'agents', list.pageUrl())
11
11
  .d-flex.flex-wrap.align-items-center.gap-2.mb-3
@@ -90,16 +90,15 @@ block content
90
90
  .modal-content
91
91
  form(method="post" action="/admin/agents")
92
92
  .modal-header
93
- h5.modal-title Register agent
93
+ h5.modal-title New CI token
94
94
  button.btn-close(type="button" data-bs-dismiss="modal")
95
95
  .modal-body
96
96
  .mb-3
97
97
  label.form-label Name
98
- input.form-control(type="text" name="name" placeholder="ci-firmware or jane.dev" required)
98
+ input.form-control(type="text" name="name" placeholder="ci-firmware" required)
99
99
  .mb-3
100
- label.form-label Kind
101
- select.form-select(name="kind")
102
- option(value="ci") ci
103
- option(value="cli") cli
100
+ p.small.text-body-secondary.mb-0.
101
+ For a pipeline: its jobs get A- ids and it can read every job. People use their own access key
102
+ instead (#[a(href="/admin/users") Users]).
104
103
  .modal-footer
105
104
  button.btn.btn-primary(type="submit") Create
@@ -0,0 +1,249 @@
1
+ extends ../layout
2
+ include ../mixins/list-controls
3
+
4
+ //- Users (§10.3, admins). Roles: user — the Agent only; maintainer — the
5
+ //- dashboard without its security features; admin — everything.
6
+ //- Role picker: names in the list, the chosen role's description below it
7
+ //- (kept in step by public/js/actions.js, select[data-describe]).
8
+ mixin roleSelect(id, selected)
9
+ select.form-select(id=id name="role" data-describe=`#${id}-help`)
10
+ each r in roles
11
+ option(value=r selected=r === selected data-help=roleHelp[r])= roleLabel(r)
12
+ .form-text(id=`${id}-help` aria-live="polite")= roleHelp[selected]
13
+
14
+ block content
15
+ .d-flex.justify-content-between.align-items-center.mb-3
16
+ h1.h4.mb-0 Users
17
+ button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addUserModal")
18
+ i.bi.bi-person-plus.me-1
19
+ | New user
20
+
21
+ if secret
22
+ //- Shown once: nothing below keeps it.
23
+ .alert.alert-warning(role="alert")
24
+ h2.h6.alert-heading
25
+ i.bi.bi-shield-lock.me-1
26
+ = secret.title
27
+ p.small.mb-2 Shown only now — copy it and hand it to #{secret.username} securely. It can't be shown again (a new one can be issued).
28
+ if secret.key
29
+ .input-group.input-group-sm.mb-2
30
+ span.input-group-text Access key
31
+ input.form-control.font-monospace(type="text" value=secret.key readonly aria-label="Access key")
32
+ button.btn.btn-outline-secondary(type="button" data-copy=secret.key aria-label="Copy access key")
33
+ i.bi.bi-clipboard
34
+ p.small.mb-2
35
+ | With the Agent: #[code thub config set key …] (or #[code THUB_KEY], or #[code --key]).
36
+ if secret.password
37
+ .input-group.input-group-sm
38
+ span.input-group-text Temporary password
39
+ input.form-control.font-monospace(type="text" value=secret.password readonly aria-label="Temporary password")
40
+ button.btn.btn-outline-secondary(type="button" data-copy=secret.password aria-label="Copy password")
41
+ i.bi.bi-clipboard
42
+ p.small.mt-2.mb-0 They sign in with it and must choose their own password right away.
43
+
44
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-3
45
+ +searchForm(q, 'Search users: name, email, role, blocked', list.urlFor({ q: '' }))
46
+ .ms-auto
47
+ +pageSizePicker(list)
48
+
49
+ //- Quick filter by role (counts within the search).
50
+ nav.btn-group.btn-group-sm.flex-wrap.mb-3(role="group" aria-label="Filter by role")
51
+ a.btn(href=list.urlFor({ role: '' }) class=!roleFilter ? 'btn-secondary' : 'btn-outline-secondary' aria-current=!roleFilter ? 'page' : false)
52
+ | All #[span.badge.text-bg-light.ms-1= total]
53
+ each r in roleFilters
54
+ a.btn(href=list.urlFor({ role: r }) class=roleFilter === r ? 'btn-secondary' : 'btn-outline-secondary' aria-current=roleFilter === r ? 'page' : false)
55
+ = roleLabel(r)
56
+ span.badge.text-bg-light.ms-1= roleCounts[r]
57
+
58
+ +listToolbar(list, roleFilter ? `${roleFilter === 'blocked' ? 'blocked users' : `${roleFilter}s`}` : 'users')
59
+
60
+ table.table.table-hover.align-middle
61
+ thead
62
+ tr
63
+ +sortHeader(list, 'username', 'User')
64
+ +sortHeader(list, 'role', 'Role')
65
+ +sortHeader(list, 'status', 'Status')
66
+ +sortHeader(list, 'used', 'Access key', 'desc')
67
+ th.text-end
68
+ tbody
69
+ each u in users
70
+ tr(class=u.blocked ? 'opacity-50' : '')
71
+ 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
82
+ td
83
+ span.badge(class=`text-bg-${roleTone[u.role]}` data-bs-toggle="tooltip" data-bs-title=roleHelp[u.role])= roleLabel(u.role)
84
+ td
85
+ if u.blocked
86
+ span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=`Since ${fmtDate(u.blockedAt)}`) blocked
87
+ else
88
+ span.badge.text-bg-success active
89
+ if u.mustChangePassword
90
+ span.badge.text-bg-light.border.ms-1(data-bs-toggle="tooltip" data-bs-title="Signs in with a temporary password and must change it") temporary password
91
+ td.small
92
+ if u.key
93
+ code= `…${u.key.hint || '????'}`
94
+ .text-body-secondary
95
+ | last used #{fmtDate(u.key.lastUsedAt, 'never')}
96
+ if u.key.version
97
+ = ` · Agent v${u.key.version}`
98
+ else
99
+ span.text-body-secondary none
100
+ td.text-end.text-nowrap
101
+ button.btn.btn-sm.btn-outline-secondary(type="button" data-bs-toggle="modal" data-bs-target=`#editUser-${u.id}` aria-label=`Edit ${u.username}`) Edit
102
+ form.d-inline.ms-1(
103
+ method="post"
104
+ action=`/admin/users/${u.id}/key`
105
+ 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}?`
106
+ data-confirm-title=u.key ? 'Replace access key?' : 'Create access key?'
107
+ data-confirm-ok=u.key ? 'Replace key' : 'Create key'
108
+ data-confirm-tone="primary"
109
+ )
110
+ input(type="hidden" name="returnTo" value=list.pageUrl())
111
+ button.btn.btn-sm.btn-outline-primary(type="submit" aria-label=u.key ? 'New access key' : 'Create access key' data-bs-toggle="tooltip" data-bs-title=u.key ? 'Issue a new access key (the old one stops working)' : 'Create an access key')
112
+ i.bi.bi-key
113
+ if u.key
114
+ form.d-inline.ms-1(
115
+ method="post"
116
+ action=`/admin/users/${u.id}/key/revoke`
117
+ data-confirm=`Revoke ${u.username}'s access key? Their Agent stops working until they get a new one.`
118
+ data-confirm-title="Revoke access key?"
119
+ data-confirm-ok="Revoke key"
120
+ )
121
+ input(type="hidden" name="returnTo" value=list.pageUrl())
122
+ button.btn.btn-sm.btn-outline-warning(type="submit" aria-label="Revoke access key" data-bs-toggle="tooltip" data-bs-title="Revoke the access key")
123
+ i.bi.bi-key-fill
124
+ if u.canUseDashboard
125
+ form.d-inline.ms-1(
126
+ method="post"
127
+ action=`/admin/users/${u.id}/reset-password`
128
+ data-confirm=`Give ${u.username} a new temporary password? Their current one stops working; they must change it at their next sign-in.`
129
+ data-confirm-title="Reset password?"
130
+ data-confirm-ok="Reset password"
131
+ data-confirm-tone="warning"
132
+ )
133
+ input(type="hidden" name="returnTo" value=list.pageUrl())
134
+ button.btn.btn-sm.btn-outline-secondary(type="submit" aria-label="Reset password" data-bs-toggle="tooltip" data-bs-title="New temporary password")
135
+ i.bi.bi-asterisk
136
+ if u.id !== me.id
137
+ if u.blocked
138
+ form.d-inline.ms-1(method="post" action=`/admin/users/${u.id}/unblock`)
139
+ input(type="hidden" name="returnTo" value=list.pageUrl())
140
+ button.btn.btn-sm.btn-outline-success(type="submit" aria-label="Unblock" data-bs-toggle="tooltip" data-bs-title="Unblock: sign-in and access key work again")
141
+ i.bi.bi-unlock
142
+ else
143
+ form.d-inline.ms-1(
144
+ method="post"
145
+ action=`/admin/users/${u.id}/block`
146
+ data-confirm=`Block ${u.username}? They're signed out at once and their access key stops working. Their jobs keep running.`
147
+ data-confirm-title="Block user?"
148
+ data-confirm-ok="Block"
149
+ )
150
+ input(type="hidden" name="returnTo" value=list.pageUrl())
151
+ button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Block" data-bs-toggle="tooltip" data-bs-title="Block: no sign-in, no access key")
152
+ i.bi.bi-lock
153
+ form.d-inline.ms-1(
154
+ method="post"
155
+ action=`/admin/users/${u.id}/delete`
156
+ data-confirm=`Delete ${u.username}? Their account and access key are gone for good; their jobs stay, listed under their name.`
157
+ data-confirm-title="Delete user?"
158
+ data-confirm-ok="Delete user"
159
+ )
160
+ input(type="hidden" name="returnTo" value=list.pageUrl())
161
+ button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Delete" data-bs-toggle="tooltip" data-bs-title="Delete the user")
162
+ i.bi.bi-trash
163
+
164
+ .modal.text-start(tabindex="-1" id=`editUser-${u.id}` aria-labelledby=`editUserTitle-${u.id}`)
165
+ .modal-dialog
166
+ .modal-content
167
+ form(method="post" action=`/admin/users/${u.id}`)
168
+ input(type="hidden" name="returnTo" value=list.pageUrl())
169
+ .modal-header
170
+ h5.modal-title(id=`editUserTitle-${u.id}`)= `Edit ${u.username}`
171
+ button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
172
+ .modal-body
173
+ .row.g-2
174
+ .col-sm-6
175
+ label.form-label(for=`u-${u.id}-username`) Username
176
+ input.form-control(id=`u-${u.id}-username` name="username" value=u.username required maxlength="64" pattern="[A-Za-z0-9._@+\\-]{1,64}")
177
+ .col-sm-6
178
+ label.form-label(for=`u-${u.id}-email`) Email
179
+ input.form-control(id=`u-${u.id}-email` type="email" name="email" value=u.email required)
180
+ .col-sm-6
181
+ label.form-label(for=`u-${u.id}-first`) First name
182
+ input.form-control(id=`u-${u.id}-first` name="firstName" value=u.firstName || '')
183
+ .col-sm-6
184
+ label.form-label(for=`u-${u.id}-last`) Last name
185
+ input.form-control(id=`u-${u.id}-last` name="lastName" value=u.lastName || '')
186
+ .col-12
187
+ label.form-label(for=`u-${u.id}-role`) Role
188
+ +roleSelect(`u-${u.id}-role`, u.role)
189
+ if !u.canUseDashboard
190
+ .form-text Making them a maintainer or admin gives them a temporary password for the dashboard.
191
+ .modal-footer
192
+ button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
193
+ button.btn.btn-primary(type="submit") Save
194
+ else
195
+ tr
196
+ td.text-body-secondary(colspan="5")= q || roleFilter ? 'No user matches.' : 'No users yet.'
197
+
198
+ h2.h6.mt-4 Recent activity
199
+ if events.length
200
+ ul.list-group.list-group-flush.small.mb-4
201
+ each e in events
202
+ li.list-group-item.px-0
203
+ span.text-body-secondary.me-2= fmtDate(e.ts)
204
+ strong= e.type.replace('user.', '').replace(/_/g, ' ')
205
+ if e.data.username
206
+ = ` ${e.data.username}`
207
+ if e.data.role && e.data.role.from
208
+ = ` — ${roleLabel(e.data.role.from)} → ${roleLabel(e.data.role.to)}`
209
+ else if e.data.role
210
+ = ` (${roleLabel(e.data.role)})`
211
+ if e.data.by
212
+ span.text-body-secondary= ` by ${e.data.by}`
213
+ else
214
+ p.small.text-body-secondary Nothing yet.
215
+
216
+ .modal(tabindex="-1" id="addUserModal" aria-labelledby="addUserTitle")
217
+ .modal-dialog
218
+ .modal-content
219
+ form(method="post" action="/admin/users")
220
+ .modal-header
221
+ h5.modal-title#addUserTitle New user
222
+ button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
223
+ .modal-body
224
+ .row.g-2
225
+ .col-sm-6
226
+ label.form-label(for="new-username") Username
227
+ input.form-control#new-username(name="username" required maxlength="64" pattern="[A-Za-z0-9._@+\\-]{1,64}" placeholder="jane.doe")
228
+ .col-sm-6
229
+ label.form-label(for="new-email") Email
230
+ input.form-control#new-email(type="email" name="email" required placeholder="jane@example.com")
231
+ .col-sm-6
232
+ label.form-label(for="new-first") First name
233
+ input.form-control#new-first(name="firstName")
234
+ .col-sm-6
235
+ label.form-label(for="new-last") Last name
236
+ input.form-control#new-last(name="lastName")
237
+ .col-12
238
+ label.form-label(for="new-role") Role
239
+ +roleSelect('new-role', 'user')
240
+ .col-12
241
+ .form-check.mt-1
242
+ input.form-check-input#new-key(type="checkbox" name="issueKey" value="1" checked)
243
+ label.form-check-label(for="new-key") Create their access key now
244
+ p.small.text-body-secondary.mt-3.mb-0.
245
+ Maintainers and admins get a temporary password, to change at their first sign-in. The key and the password are
246
+ shown once, after Create.
247
+ .modal-footer
248
+ button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
249
+ button.btn.btn-primary(type="submit") Create
@@ -6,11 +6,13 @@
6
6
  thub cancel &lt;jobId> Cancel a job
7
7
  thub resources [--json] Clients and their status
8
8
  thub jobs [--mine] [--state &lt;s>] [--json] Recent jobs (a cli token: only its own)
9
- thub config set &lt;url|token|group|user> &lt;value>
9
+ thub config set &lt;url|key|group|user> &lt;value>
10
+ thub whoami Whose access key this is
11
+ thub key show|rotate Your key's details / replace it (the old one stops at once)
10
12
  thub check-update Compare with the latest published Agent
11
13
  thub self-update [--to &lt;x.y.z>] Update with npm i -g
12
14
  thub --version
13
- Global: --url &lt;url> --token &lt;token> (override THUB_URL / THUB_TOKEN / config file)
15
+ Global: --url &lt;url> --key &lt;key> (override THUB_URL / THUB_KEY / config file)
14
16
 
15
17
  h3.h6 #[code thub run] options
16
18
  -