@andrian.yablonskyy/thub-coordinator 1.0.43 → 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.
@@ -10,40 +10,42 @@
10
10
  p.small.
11
11
  In CI you don't need a global install: #[code npx -y @andrian.yablonskyy/thub-agent run …].
12
12
 
13
- h3.h6 Get a token
13
+ h3.h6 Get your access key
14
14
  p.small.
15
- An admin registers an agent on #[a(href="/admin/agents") Agents] (or with #[code thub-admin agent add]) and passes you
16
- its token, which is shown only once. The agent's #[strong kind] decides the job id prefix, the default priority and which jobs it can see:
15
+ Every person has a user with one #[strong access key]. An admin creates you on #[a(href="/admin/users") Users]
16
+ (or with #[code thub-admin user add]) and hands you the key, which is shown only once. Maintainers and admins can also
17
+ create or replace their own on their #[a(href="/profile") profile]. Your #[strong role] decides what you can do:
17
18
  table.table.table-sm.small.w-auto
18
19
  thead
19
20
  tr
20
- th Kind
21
- th For
22
- th Job ids
23
- th Default priority
24
- th Can see
21
+ th Role
22
+ th Agent
23
+ th Dashboard
25
24
  tbody
26
25
  tr
27
- td: code ci
28
- td Pipelines
29
- td: code A-00001…
30
- td 50
31
- td every job (details, log)
26
+ td: strong User
27
+ td submits jobs, sees and cancels only their own
28
+ td no
32
29
  tr
33
- td: code cli
34
- td Developers
35
- td: code M-00001…
36
- td 60 (so a busy pipeline doesn't starve them)
37
- td only the jobs it submitted
30
+ td: strong Maintainer
31
+ td submits jobs, sees and cancels every job
32
+ td yes, without Users, Settings and CI tokens
33
+ tr
34
+ td: strong Admin
35
+ td like a Maintainer
36
+ td everything
37
+ p.small.
38
+ Pipelines use a #[strong CI token] instead (#[a(href="/admin/agents") CI tokens], admins): their jobs get A- ids and they
39
+ read every job. People's jobs get M- ids.
38
40
 
39
41
  h3.h6 Configure
40
42
  +code('Save the settings once').
41
43
  thub config set url #{coordinatorUrl}
42
- thub config set token agt_…
44
+ thub config set key thk_…
43
45
  thub config set user "Your Name" # optional: job owner label
44
46
  thub config set group <groupId> # optional: default --group
45
47
  p.small.
46
- These are saved in #[code ~/.config/thub/agent.json]. Order of precedence: flags (#[code --url], #[code --token]) →
48
+ These are saved in #[code ~/.config/thub/agent.json]. Order of precedence: flags (#[code --url], #[code --key]) →
47
49
  environment → config file.
48
50
  table.table.table-sm.small
49
51
  thead
@@ -55,8 +57,8 @@
55
57
  td: code THUB_URL
56
58
  td Coordinator URL, e.g. #[code #{coordinatorUrl}]
57
59
  tr
58
- td: code THUB_TOKEN
59
- td Agent token. Keep it in your CI's secret store.
60
+ td: code THUB_KEY
61
+ td Your access key, or a CI token in a pipeline. Keep it in your CI's secret store. (#[code THUB_TOKEN], #[code --token] and a saved #[code token] still work: the old names.)
60
62
  tr
61
63
  td: code THUB_GROUP
62
64
  td Default #[code --group]
@@ -66,7 +68,10 @@
66
68
  tr
67
69
  td: code THUB_NO_SELF_UPDATE=1
68
70
  td Don't install admin-requested Agent updates on this machine
69
- +code('Check the connection').
71
+ +code('Check the connection and your key').
72
+ thub whoami # jane (user) <jane@example.com>
73
+ thub key show # your key's last characters, created, last used
74
+ thub key rotate # replace it — the old one stops at once; saved to the config file if it came from there
70
75
  thub resources # lists the Clients and their status
71
76
  thub jobs --mine # your recent jobs
72
77
  p.small.mb-0.
@@ -1,8 +1,8 @@
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]-kind agent on #[a(href="/admin/agents") Agents] and store its token as the repository secret
5
- #[code THUB_AGENT_TOKEN].
4
+ Create a #[strong CI token] on #[a(href="/admin/agents") CI tokens] (admins) and store it as the repository secret
5
+ #[code THUB_CI_TOKEN].
6
6
  +code('.github/workflows/firmware.yml').
7
7
  name: firmware
8
8
  on: [push, pull_request]
@@ -27,7 +27,7 @@
27
27
  runs-on: ubuntu-latest
28
28
  env:
29
29
  THUB_URL: #{coordinatorUrl}
30
- THUB_TOKEN: ${{ secrets.THUB_AGENT_TOKEN }}
30
+ THUB_KEY: ${{ secrets.THUB_CI_TOKEN }}
31
31
  steps:
32
32
  - run: |
33
33
  npx -y @andrian.yablonskyy/thub-agent run \
@@ -41,4 +41,4 @@
41
41
  li The step's exit code is the verdict (#[a(href="#agent-cli") exit codes]), so the workflow fails exactly when the tests do.
42
42
  li If the workflow is canceled, the Agent (in #[code --wait] mode) cancels the TestHub job, so abandoned runs don't hold hardware.
43
43
  li Downloads carry no credentials. Publish the image to a URL the Client can read, or fetch it in #[code --command] with a token passed as #[code --env].
44
- li Other CI systems (GitLab CI, Jenkins, Azure Pipelines) work the same way: install Node.js, set #[code THUB_URL]/#[code THUB_TOKEN] and run #[code thub run … --wait].
44
+ li Other CI systems (GitLab CI, Jenkins, Azure Pipelines) work the same way: install Node.js, set #[code THUB_URL]/#[code THUB_KEY] and run #[code thub run … --wait].
@@ -114,10 +114,11 @@
114
114
  p.small.
115
115
  #[code thub-admin] works directly on the database. Run it on the Coordinator host as the service user.
116
116
  +code('Coordinator host').
117
- thub-admin create-admin alice 's3cret' --role admin # or --role viewer (read-only dashboard)
117
+ thub-admin user add alice --email alice@example.com --role admin --password 's3cret'
118
+ thub-admin user add bob --email bob@example.com --key # role user (Agent only); prints his key once
119
+ thub-admin user list | block | unblock | role | password | key | delete …
118
120
  thub-admin join-key generate # → clientJoinKey here, joinKey on every Client
119
- thub-admin agent add ci-firmware --kind ci # CI token: job ids A-00001…
120
- thub-admin agent add alice-laptop --kind cli # developer token: job ids M-00001…
121
+ thub-admin agent add ci-firmware # CI token for a pipeline: job ids A-00001…
121
122
  thub-admin group add ci-nightly --comment "nightly pool" # → a group id for thub run --group
122
123
  thub-admin resource maintenance &lt;resourceId> --on
123
124
  thub-admin jobs reset --yes # cancel every active job
@@ -68,7 +68,7 @@
68
68
  td: code.text-nowrap= v[1]
69
69
  td= v[2]
70
70
  p.small.
71
- #[code --url], #[code --token], #[code --wait], #[code --detach], #[code --json] and #[code --dry-run] only affect the Agent and
71
+ #[code --url], #[code --key], #[code --wait], #[code --detach], #[code --json] and #[code --dry-run] only affect the Agent and
72
72
  have no variable. The Client's own git commands also get #[code GIT_TERMINAL_PROMPT=0] and #[code GIT_ALLOW_PROTOCOL=http:https:ssh:git].
73
73
 
74
74
  h3.h6 Your own variables: #[code --env]
@@ -37,7 +37,7 @@
37
37
 
38
38
  h3.h6 How a job flows
39
39
  ol.small
40
- li #[code thub run …] submits a job spec (type, labels, command, inputs) with an agent token.
40
+ li #[code thub run …] submits a job spec (type, labels, command, inputs) with your access key (or a pipeline's CI token).
41
41
  li The Coordinator queues it (#[code QUEUED]) and the scheduler assigns it to an idle Client whose type, labels and group match.
42
42
  li The Client accepts it, clones #[code --git-repo], downloads #[code --download-file] files and prepares the DUT (#[code PREPARING]).
43
43
  li It runs #[code --command] with #[code sh -c], and output streams live to the Agent and the dashboard (#[code RUNNING]).
@@ -9,7 +9,7 @@
9
9
  thub-admin join-key generate # → put it in clientJoinKey
10
10
  nano ~/.config/thub/coordinator.json # publicUrl, clientJoinKey
11
11
  sudo systemctl restart thub-coordinator
12
- thub-admin create-admin admin 'a-strong-password'
12
+ thub-admin user add admin --email you@example.com --role admin --password 'a-strong-password'
13
13
  li
14
14
  strong Install a Client
15
15
  | on each lab machine, with the same join key (#[a(href="#client-setup") Client setup]):
@@ -20,16 +20,16 @@
20
20
  sudo systemctl enable --now thub-client@client
21
21
  | It shows up on #[a(href="/resources") Resources] by itself, usually within 10 s.
22
22
  li
23
- strong Issue an agent token
24
- | on #[a(href="/admin/agents") Agents] (admins), or on the Coordinator host with
25
- | #[code thub-admin agent add &lt;name&gt; --kind ci|cli]. The token is shown once.
23
+ strong Create users and their access keys
24
+ | on #[a(href="/admin/users") Users] (admins), or on the Coordinator host with
25
+ | #[code thub-admin user add &lt;name&gt; --email &lt;email&gt; --key]. The key is shown once.
26
26
  li
27
27
  strong Install and configure the Agent
28
28
  | (#[a(href="#agent-setup") Agent setup]):
29
29
  +code('Developer machine').
30
30
  npm i -g @andrian.yablonskyy/thub-agent
31
31
  thub config set url #{coordinatorUrl}
32
- thub config set token agt_…
32
+ thub config set key thk_…
33
33
  li
34
34
  strong Run a job
35
35
  | (#[a(href="#agent-cli") Agent CLI reference]):
@@ -20,6 +20,7 @@
20
20
  ['Where are a job\'s test reports and output files?', 'The Coordinator keeps only the log and the result (verdict, exit code, JUnit test counts). Files the job creates stay in its workspace on the Client and are deleted when the job ends. To keep them, publish them from `--command`, e.g. `curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/report.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/report.xml"` with `--env ART_TOKEN`. To show them on the job page and in `thub status`, list them in `$THUB_ARTIFACTS_FILE` as `[{"name", "size", "link", "timestamp"}]` — only that metadata is kept. The full log is always available from the job page\'s Raw button.'],
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
+ ['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.'],
23
24
  ['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
24
25
  ]
25
26
  const withCode = (text) => text
package/views/index.pug CHANGED
@@ -42,7 +42,7 @@ block content
42
42
  each r in resources
43
43
  +resourceCard(r, groupsById, '/')
44
44
 
45
- if user && user.role === 'admin'
45
+ if can.operate
46
46
  +removeResourceModal()
47
47
 
48
48
  block scripts
package/views/layout.pug CHANGED
@@ -21,7 +21,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
21
21
  data-bs-title=`Coordinator v${coordinatorVersion} · thub-common v${commonVersion} (validates job specs)`
22
22
  )
23
23
  | ver. #{coordinatorVersion}
24
- if user && user.role !== 'admin' && updates.coordinatorUpdate
24
+ if user && !can.admin && updates.coordinatorUpdate
25
25
  span.badge.text-bg-warning.ms-1(
26
26
  data-bs-toggle="tooltip"
27
27
  data-bs-placement="bottom"
@@ -33,7 +33,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
33
33
  //- Below lg the menu drops down over the page (thub.css .thub-nav,
34
34
  //- closed by public/js/actions.js): pages as a two-column grid with
35
35
  //- icons, then the tools in a row, then the account row.
36
- - const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/resources', 'resources', 'Resources', 'hdd-rack'], ['/groups', 'groups', 'Groups', 'collection'], ['/jobs', 'jobs', 'Jobs', 'list-task']].concat(user.role === 'admin' ? [['/admin/agents', 'agents', 'Agents', 'person-badge'], ['/admin/settings', 'settings', 'Settings', 'gear']] : []) : []
36
+ - const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/resources', 'resources', 'Resources', 'hdd-rack'], ['/groups', 'groups', 'Groups', 'collection'], ['/jobs', 'jobs', 'Jobs', 'list-task']].concat(user.role === 'admin' ? [['/admin/users', 'users', 'Users', 'people'], ['/admin/agents', 'agents', 'CI tokens', 'key'], ['/admin/settings', 'settings', 'Settings', 'gear']] : []) : []
37
37
  ul.navbar-nav.me-auto.thub-nav-pages
38
38
  each pg in pages
39
39
  li.nav-item
@@ -59,7 +59,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
59
59
  )
60
60
  i.bi.bi-question-circle.me-1
61
61
  | Help
62
- if user && user.role === 'admin'
62
+ if user && can.operate
63
63
  li.nav-item.d-flex.align-items-center.me-2
64
64
  form(method="post" action="/updates/check" data-submit-busy)
65
65
  input(type="hidden" name="returnTo" value=currentPath)
@@ -71,7 +71,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
71
71
  data-bs-title=updates.checkedAt ? `Check for updates now — the Coordinator and connected resources (last checked ${fmtDate(updates.checkedAt)})` : 'Check for updates now — the Coordinator and connected resources'
72
72
  )
73
73
  i.bi.bi-arrow-repeat
74
- if user && user.role === 'admin' && updates.coordinatorUpdate
74
+ if user && can.admin && updates.coordinatorUpdate
75
75
  li.nav-item.d-flex.align-items-center.me-3
76
76
  if updates.coordinatorPending
77
77
  //- public/js/update-watch.js polls /updates/status and
@@ -118,7 +118,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
118
118
  href="/profile"
119
119
  data-bs-toggle="tooltip"
120
120
  data-bs-placement="bottom"
121
- data-bs-title=`Profile settings (${user.role})`
121
+ data-bs-title=`Profile settings (${roleLabel(user.role)})`
122
122
  )
123
123
  if user.avatarPath
124
124
  img.rounded-circle.me-2(src=user.avatarPath width="24" height="24" style="object-fit: cover;")
@@ -126,7 +126,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
126
126
  span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2(style="width: 24px; height: 24px; font-size: 12px;")
127
127
  = ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
128
128
  span= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
129
- span.small.text-body-secondary.ms-2.d-lg-none= `${user.role} · profile`
129
+ span.small.text-body-secondary.ms-2.d-lg-none= `${roleLabel(user.role)} · profile`
130
130
  li.nav-item.d-flex.align-items-center
131
131
  form(action="/logout" method="post")
132
132
  button.btn.btn-sm.btn-outline-danger(
@@ -194,6 +194,6 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
194
194
  script(src="/js/actions.js")
195
195
  if user && liveTopics
196
196
  script(src="/js/live.js")
197
- if user && user.role === 'admin' && updates.coordinatorPending
197
+ if user && can.admin && updates.coordinatorPending
198
198
  script(src="/js/update-watch.js")
199
199
  block scripts
@@ -106,7 +106,7 @@ mixin configTabItems(r)
106
106
  //- at once.
107
107
  mixin groupsPane(r, returnTo, groupsById)
108
108
  - const p = `cfg-${r.id}`
109
- - const canEdit = user.role === 'admin'
109
+ - const canEdit = can.operate
110
110
  - const pending = r.config_import && r.config_import.revision > r.config_applied_revision && r.config_import.fields && r.config_import.fields.groups
111
111
  - const all = Object.values(groupsById).sort((a, b) => a.name.localeCompare(b.name))
112
112
  - const unknown = r.group_ids.filter((id) => !groupsById[id])
@@ -163,7 +163,7 @@ mixin groupsPane(r, returnTo, groupsById)
163
163
  mixin configPanes(r, returnTo, groupsById)
164
164
  - const p = `cfg-${r.id}`
165
165
  - const current = r.config_desired || r.client_config
166
- - const canEdit = user.role === 'admin'
166
+ - const canEdit = can.operate
167
167
  if r.type !== 'hw'
168
168
  //- no device tabs
169
169
  else if !current
@@ -25,22 +25,24 @@ mixin resourceActions(r, returnTo)
25
25
  data-bs-title="Take this resource out of rotation for maintenance — the scheduler will skip it"
26
26
  )
27
27
  i.bi.bi-tools
28
- form.d-inline.ms-1(
29
- method="post"
30
- action=`/resources/${r.id}/rotate-token`
31
- data-confirm=`Issue a new token for ${r.name}? Its current token stops working at once, and the Client won't reconnect until it has the new one.`
32
- data-confirm-title="Rotate resource token?"
33
- data-confirm-ok="Rotate token"
34
- data-confirm-tone="warning"
35
- )
36
- input(type="hidden" name="returnTo" value=returnTo)
37
- button.btn.btn-sm.btn-outline-warning(
38
- type="submit"
39
- aria-label="Rotate token"
40
- data-bs-toggle="tooltip"
41
- data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
28
+ //- Credentials: admins only (§10.3).
29
+ if can.admin
30
+ form.d-inline.ms-1(
31
+ method="post"
32
+ action=`/resources/${r.id}/rotate-token`
33
+ data-confirm=`Issue a new token for ${r.name}? Its current token stops working at once, and the Client won't reconnect until it has the new one.`
34
+ data-confirm-title="Rotate resource token?"
35
+ data-confirm-ok="Rotate token"
36
+ data-confirm-tone="warning"
42
37
  )
43
- i.bi.bi-arrow-repeat
38
+ input(type="hidden" name="returnTo" value=returnTo)
39
+ button.btn.btn-sm.btn-outline-warning(
40
+ type="submit"
41
+ aria-label="Rotate token"
42
+ data-bs-toggle="tooltip"
43
+ data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
44
+ )
45
+ i.bi.bi-arrow-repeat
44
46
  +removeResourceButton(r, returnTo)
45
47
 
46
48
  //- Remove: a plain confirm when the resource is idle; with a running job,
@@ -188,7 +190,7 @@ mixin uptimeRows(r, returnTo)
188
190
  span.text-body-secondary.small.ms-1 (at last heartbeat)
189
191
  else
190
192
  span.text-body-secondary —
191
- if user.role === 'admin'
193
+ if can.operate
192
194
  +cancelActivityButton(r, returnTo)
193
195
 
194
196
  //- Scheduled host reboot (cron, in the Client host's local time): what's
@@ -226,7 +228,7 @@ mixin rebootScheduleRow(r, returnTo)
226
228
  data-bs-toggle="tooltip"
227
229
  data-bs-title="This host has no reboot helper (thub-client-reboot.path), so the schedule can't reboot it — reinstall the Client as root: sudo npm i -g @andrian.yablonskyy/thub-client"
228
230
  ) can't reboot
229
- if user.role === 'admin'
231
+ if can.operate
230
232
  form.d-flex.gap-1.mt-1(
231
233
  method="post"
232
234
  action=`/resources/${r.id}/reboot-schedule`
@@ -338,7 +340,7 @@ mixin usbScanPane(r)
338
340
  - const importWhy = r.type !== 'hw' ? 'Only HW Clients have USB device lists to import into' : !(r.config_desired || r.client_config) ? 'This Client is too old to report its config — update it first' : null
339
341
  div(data-usb-scan=`/resources/${r.id}/usb-scan` data-pending=r.usb_scan_request_id ? '1' : null data-requested-at=r.usb_scan_requested_at)
340
342
  .d-flex.align-items-center.gap-2.mb-2
341
- if user.role === 'admin'
343
+ if can.operate
342
344
  if offline
343
345
  span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Offline — a Client can only be scanned while it's connected")
344
346
  button.btn.btn-sm.btn-outline-primary(type="button" disabled style="pointer-events: none;")
@@ -366,7 +368,7 @@ mixin usbScanPane(r)
366
368
  if scan
367
369
  = `lsusb -tvv at ${fmtDate(scan.at)}`
368
370
  else
369
- | Not scanned yet#{user.role === 'admin' ? ' — press Refresh to run lsusb -tvv on the Client' : ''}.
371
+ | Not scanned yet#{can.operate ? ' — press Refresh to run lsusb -tvv on the Client' : ''}.
370
372
  .alert.alert-danger.py-2.small(data-usb-error class=scan && scan.error ? null : 'd-none')= scan && scan.error || ''
371
373
  pre.bg-body-tertiary.p-2.rounded.small.mb-0(data-usb-output style="max-height: 360px; overflow: auto;" class=scan && scan.output ? null : 'd-none')= scan && scan.output || ''
372
374
 
@@ -453,7 +455,7 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
453
455
  data-bs-toggle="tooltip"
454
456
  data-bs-title="Renamed on the dashboard; this is the name the Client itself reports"
455
457
  )= `(Client: ${r.reported_name})`
456
- if user.role === 'admin'
458
+ if can.operate
457
459
  button.btn.btn-sm.btn-link.p-0.ms-2.align-baseline(
458
460
  type="button"
459
461
  data-bs-toggle="collapse"
@@ -513,7 +515,7 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
513
515
  dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
514
516
  dt.col-sm-4 Last heartbeat
515
517
  dd.col-sm-8= fmtDate(r.last_heartbeat_at, 'never')
516
- if user.role === 'admin'
518
+ if can.operate
517
519
  dt.col-sm-4 Actions
518
520
  dd.col-sm-8.mb-0
519
521
  +resourceActions(r, returnTo)
@@ -25,7 +25,7 @@ mixin versionCell(current, latest, updateTo)
25
25
  //- an update). Agents table, Resources table, resource card.
26
26
  mixin versionWithUpdate(current, latest, updateTo, action, returnTo, canUpdate = true, holding = false)
27
27
  +versionCell(current, latest, updateTo)
28
- if user.role === 'admin' && canUpdate && (updateTo || holding || (current && isNewer(latest, current)))
28
+ if can.operate && canUpdate && (updateTo || holding || (current && isNewer(latest, current)))
29
29
  +updateButton(action, updateTo || (holding ? true : null), returnTo)
30
30
 
31
31
  //- Latest published version of `app` (agent | client), "check now" and,
@@ -40,7 +40,7 @@ mixin updatesToolbar(app, label, returnTo)
40
40
  = ` · checked ${fmtDate(updates.checkedAt)}`
41
41
  if updates.error
42
42
  i.bi.bi-exclamation-triangle.text-warning.ms-1(data-bs-toggle="tooltip" data-bs-title=updates.error)
43
- if user.role === 'admin'
43
+ if can.operate
44
44
  form.d-inline(method="post" action="/updates/check")
45
45
  input(type="hidden" name="returnTo" value=returnTo)
46
46
  button.btn.btn-sm.btn-outline-secondary(type="submit")
package/views/profile.pug CHANGED
@@ -3,6 +3,24 @@ extends layout
3
3
  block content
4
4
  h1.h4.mb-3 Profile settings
5
5
 
6
+ if user.mustChangePassword
7
+ .alert.alert-warning
8
+ i.bi.bi-shield-exclamation.me-1
9
+ | You signed in with a temporary password — choose your own below before using the dashboard.
10
+
11
+ if secret
12
+ //- Shown once (§10.3): nothing keeps it.
13
+ .alert.alert-warning(role="alert")
14
+ h2.h6.alert-heading
15
+ i.bi.bi-shield-lock.me-1
16
+ = secret.title
17
+ .input-group.input-group-sm.mb-2
18
+ span.input-group-text Access key
19
+ input.form-control.font-monospace(type="text" value=secret.key readonly aria-label="Access key")
20
+ button.btn.btn-outline-secondary(type="button" data-copy=secret.key aria-label="Copy access key")
21
+ i.bi.bi-clipboard
22
+ p.small.mb-0 Shown only now. Save it with #[code thub config set key …] (or use #[code THUB_KEY] / #[code --key]).
23
+
6
24
  .row
7
25
  .col-md-6
8
26
  form(method="post" action="/profile" enctype="multipart/form-data")
@@ -21,6 +39,12 @@ block content
21
39
  label.form-label(for="username") Username
22
40
  input#username.form-control(type="text" name="username" value=user.username required)
23
41
 
42
+ .mb-3
43
+ label.form-label(for="email") Email
44
+ input#email.form-control(type="email" name="email" value=user.email required)
45
+ if !user.email
46
+ .form-text.text-warning Every user needs an email — please add yours.
47
+
24
48
  .row
25
49
  .col-6.mb-3
26
50
  label.form-label(for="firstName") First name
@@ -58,6 +82,38 @@ block content
58
82
  data-set-theme=t
59
83
  )= t.charAt(0).toUpperCase() + t.slice(1)
60
84
 
85
+ //- Your access key for the Agent (§10.3); also: thub key show|rotate.
86
+ .card.mb-3
87
+ .card-body
88
+ h6.card-subtitle.mb-3.text-body-secondary My access key
89
+ if accessKey
90
+ p.small.mb-2
91
+ | #[code= `…${accessKey.token_hint || '????'}`] · created #{fmtDate(accessKey.token_created_at || accessKey.created_at)} · last used #{fmtDate(accessKey.last_used_at, 'never')}
92
+ else
93
+ p.small.text-body-secondary.mb-2 None yet — create one to use the Agent (thub).
94
+ .d-flex.gap-2
95
+ form(
96
+ method="post"
97
+ action="/profile/key"
98
+ data-confirm=accessKey ? 'Replace your access key? The current one stops working at once — everywhere you use it.' : 'Create your access key?'
99
+ data-confirm-title=accessKey ? 'Replace access key?' : 'Create access key?'
100
+ data-confirm-ok=accessKey ? 'Replace key' : 'Create key'
101
+ data-confirm-tone="primary"
102
+ )
103
+ button.btn.btn-sm.btn-outline-primary(type="submit")
104
+ i.bi.bi-key.me-1
105
+ = accessKey ? 'Replace key' : 'Create key'
106
+ if accessKey
107
+ form(
108
+ method="post"
109
+ action="/profile/key/revoke"
110
+ data-confirm="Revoke your access key? The Agent stops working for you until you create a new one."
111
+ data-confirm-title="Revoke access key?"
112
+ data-confirm-ok="Revoke key"
113
+ )
114
+ button.btn.btn-sm.btn-outline-warning(type="submit") Revoke
115
+ .form-text.mt-2 From the command line: #[code thub key show] and #[code thub key rotate].
116
+
61
117
  .card
62
118
  .card-body
63
119
  h6.card-subtitle.mb-3.text-body-secondary Change password
@@ -30,7 +30,7 @@ block content
30
30
  th Groups
31
31
  th Busy source / reason
32
32
  +sortHeader(list, 'heartbeat', 'Last heartbeat', 'desc')
33
- if user.role === 'admin'
33
+ if can.operate
34
34
  th Actions
35
35
  tbody(data-live="rows")
36
36
  each r in resources
@@ -50,14 +50,14 @@ block content
50
50
  span.text-body-secondary —
51
51
  td= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
52
52
  td= fmtDate(r.last_heartbeat_at, 'never')
53
- if user.role === 'admin'
53
+ if can.operate
54
54
  td
55
55
  +resourceActions(r, list.pageUrl())
56
56
 
57
57
  each r in resources
58
58
  +resourceCard(r, groupsById, list.pageUrl())
59
59
 
60
- if user && user.role === 'admin'
60
+ if can.operate
61
61
  +removeResourceModal()
62
62
 
63
63
  block scripts