@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
|
@@ -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&size=10&sort=username&dir=asc&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
|
+
});
|
package/test/views.test.js
CHANGED
|
@@ -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: '
|
|
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',
|
package/views/admin/agents.pug
CHANGED
|
@@ -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
|
|
8
|
-
button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal")
|
|
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
|
|
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
|
|
98
|
+
input.form-control(type="text" name="name" placeholder="ci-firmware" required)
|
|
99
99
|
.mb-3
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 <jobId> Cancel a job
|
|
7
7
|
thub resources [--json] Clients and their status
|
|
8
8
|
thub jobs [--mine] [--state <s>] [--json] Recent jobs (a cli token: only its own)
|
|
9
|
-
thub config set <url|
|
|
9
|
+
thub config set <url|key|group|user> <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 <x.y.z>] Update with npm i -g
|
|
12
14
|
thub --version
|
|
13
|
-
Global: --url <url> --
|
|
15
|
+
Global: --url <url> --key <key> (override THUB_URL / THUB_KEY / config file)
|
|
14
16
|
|
|
15
17
|
h3.h6 #[code thub run] options
|
|
16
18
|
-
|