Compare commits

...

63 Commits

Author SHA1 Message Date
wmantly 60ed6b462f Merge pull request #214 from theta42/feat/permission-edit
feat: edit permission entries (v1.35.0)
2026-08-06 10:40:09 -04:00
wmantly 255835af7a feat: edit permission entries (v1.35.0)
Pull Request Tests / Run Tests (18.x) (push) Successful in 49s
Pull Request Tests / Run Tests (20.x) (push) Successful in 40s
Pull Request Tests / Run Tests (22.x) (push) Successful in 41s
Pull Request Tests / Test Summary (push) Successful in 4s
The Permissions page only offered Delete, so changing a role or scope
meant removing the grant and re-adding it from memory.

A permission's id is derived from (subjectType, subject, scope, domain),
so changing any of those is a different record rather than an update. The
new PUT creates the new grant and removes the superseded one in that
order, so an edit can never leave the old grant behind still conferring
access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:34:11 -04:00
wmantly 954cda5844 Merge pull request #213 from theta42/fix/host-sso-group-suggestions
feat: SSO group autocomplete for per-host SSO allow-lists (v1.34.0)
2026-08-05 18:55:10 -04:00
wmantly 1e38ef8dc5 feat: SSO group autocomplete for per-host SSO allow-lists (v1.34.0)
Pull Request Tests / Run Tests (18.x) (push) Successful in 33s
Pull Request Tests / Run Tests (20.x) (push) Successful in 28s
Pull Request Tests / Run Tests (22.x) (push) Successful in 31s
Pull Request Tests / Test Summary (push) Successful in 3s
The per-host "Allowed groups" field suggested only local groups,
permission subjects and conf.auth maps. None of those can ever match an
SSO-gated host: its allow-list is checked against the `groups` claim the
SSO issues (utils/host_sso.js), so only SSO groups are candidates.

Adds a conf.sso block (url + read-only apiToken, minted by theta-suite's
bootstrap) and a cached /api/group lookup merged into the suggestions.
Degrades silently to the previous local-only list when unset, and never
fails the request.

Authenticates with `Authorization: Bearer <token>` -- the SSO's
`auth-token` header is for browser session UUIDs and rejects a minted
API token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:42:50 -04:00
wmantly bbaa006925 Merge pull request #212 from theta42/fix/version-1.33.0
chore: sync package.json to 1.33.0
2026-08-04 16:52:45 -04:00
wmantly eb9388a4b1 chore: sync package.json + lockfile to v1.33.0 tag
Pull Request Tests / Run Tests (18.x) (push) Successful in 32s
Pull Request Tests / Run Tests (20.x) (push) Successful in 26s
Pull Request Tests / Run Tests (22.x) (push) Successful in 29s
Pull Request Tests / Test Summary (push) Successful in 4s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:50:43 -04:00
wmantly 9dce4b6c24 Merge pull request #211 from theta42/release/v1.33.0
feat: error page, admin-only DNS, navbar active styling (v1.33.0)
2026-08-04 15:09:31 -04:00
wmantly 1bbf593232 feat: error page, admin-only DNS page, navbar active styling (v1.33.0)
Pull Request Tests / Run Tests (18.x) (push) Successful in 30s
Pull Request Tests / Run Tests (20.x) (push) Successful in 26s
Pull Request Tests / Run Tests (22.x) (push) Successful in 33s
Pull Request Tests / Test Summary (push) Successful in 4s
- Add SSO-style error page (views/error.ejs) and render it for browser
  navigation in the error handler (API still returns JSON).
- DNS page admin-only: forceLogin(['admin']) + nav groups ['admin'].
- Navbar: username not underlined; only the active nav link is bold+underlined.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:26:36 -04:00
wmantly 8107755307 Merge pull request #209 from theta42/fix/sync-version-v1.32.0
fix: sync package version to v1.32.0 tag
2026-08-03 21:33:00 -04:00
wmantly 1b8ef1f848 fix: sync package version to v1.32.0 tag
The v1.32.0 release tag was created but nodejs/package.json was left at
1.14.3 (lockfile at 1.13.3), so the deployed app's buildVersion lags its
own release tag and the update-check banner falsely reports a newer
version. Bump the version fields to match the tag.
2026-08-03 21:31:31 -04:00
wmantly eb08b6b5b9 Merge pull request #207 from theta42/refactor/standalone-removal-permissions-groups-v1.32.0
feat(proxy): remove standalone users UI/nav, refactor permissions to list view with live reload, and add dynamic reload to groups v1.32.0
2026-08-03 15:29:04 -04:00
wmantly 63be1f1020 feat(proxy): remove standalone users UI/nav, refactor permissions to list view with live reload, and add dynamic reload to groups v1.32.0
Pull Request Tests / Run Tests (18.x) (push) Successful in 30s
Pull Request Tests / Run Tests (20.x) (push) Successful in 25s
Pull Request Tests / Run Tests (22.x) (push) Successful in 28s
Pull Request Tests / Test Summary (push) Successful in 4s
2026-08-03 15:27:21 -04:00
wmantly 62cdaa2cdd Merge pull request #206 from theta42/fix/bump-version-1.14.3
chore: bump package.json version to 1.14.3
2026-08-03 02:36:46 -04:00
wmantly 13a02e6850 chore: bump package.json version to 1.14.3 2026-08-03 02:35:21 -04:00
wmantly baba3a414f Merge pull request #205 from theta42/feature/v1.14.3-docs-restoration
docs: restore proxy documentation and deployment guide
2026-08-03 02:19:25 -04:00
wmantly d049b2de49 docs: restore full proxy documentation site and DEPLOYMENT.md 2026-08-03 02:18:42 -04:00
wmantly b0e8104790 Merge pull request #203 from theta42/fix/remove-missing-docs
fix: remove DEPLOYMENT.md and docs/ from Docker build context
2026-08-02 12:09:53 -04:00
wmantly 3cc769cc3c fix: remove DEPLOYMENT.md and docs/ from Docker build context
Pull Request Tests / Run Tests (18.x) (push) Successful in 27s
Pull Request Tests / Run Tests (20.x) (push) Successful in 27s
Pull Request Tests / Run Tests (22.x) (push) Successful in 29s
Pull Request Tests / Test Summary (push) Successful in 4s
2026-08-02 11:53:48 -04:00
wmantly 94ff5c76eb docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly d74e3168ed Merge pull request #202 from theta42/release-v1.14.0
Release v1.14.0
2026-08-02 00:39:12 -04:00
wmantly 9029de825c test: add tests for DNS API Keys in OpenBao 2026-08-02 00:34:56 -04:00
wmantly b50a1de76f feat: securely store DNS API keys in OpenBao instead of Redis 2026-08-02 00:26:22 -04:00
wmantly c419249e98 chore: release v1.13.2 2026-08-02 00:16:19 -04:00
wmantly 4aa994121a Merge pull request #201 from theta42/fix/bao-conf-1.0.1
v1.13.1: bump @simpleworkjs/bao-conf to 1.0.1 (fix standalone boot crash)
2026-08-01 12:50:47 -04:00
wmantly 2e92f58750 v1.13.1: bump @simpleworkjs/bao-conf to 1.0.1
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing boot
(.catch -> process.exit(1)) in any deployment without an OpenBao sidecar
(standalone Docker, bare metal). 1.0.1 makes init() fail-soft on a
missing token (warn + continue from CONF_SECRETS). The theta-env stack
is unaffected (it always sets a scoped VAULT_TOKEN).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:48:10 -04:00
wmantly aeccbcbbe9 Merge pull request #200 from theta42/feature/openbao-secrets
v1.13.0: load secrets from OpenBao at boot
2026-08-01 12:34:55 -04:00
wmantly 15b154fc8d v1.13.0: load secrets from OpenBao at boot via @simpleworkjs/bao-conf
bin/www now defers require('../app') until bao-conf.init({ path: 'proxy' })
resolves, so models + createOidcClient see the OpenBao-merged config (the
OIDC clientSecret is captured at require time). Authenticates to OpenBao
with a scoped VAULT_TOKEN (policy proxy), never the root token; fail-soft
to CONF_SECRETS if OpenBao is unreachable. config/proxy-secrets.js becomes
an operator-edit seed artifact (OpenBao authoritative). README gains a
Secrets section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:29:22 -04:00
wmantly a44d7ef7ab Merge pull request #199 from theta42/release-v1.12.1
Release v1.12.1
2026-08-01 11:12:56 -04:00
wmantly 144efdb5dd chore(release): v1.12.1 2026-08-01 11:11:37 -04:00
dependabot[bot] b54a738524 Bump brace-expansion and ejs in /nodejs (#179)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) to 5.0.9 and updates ancestor dependency [ejs](https://github.com/mde/ejs). These dependencies need to be updated together.


Updates `brace-expansion` from 5.0.7 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.7...v5.0.9)

Updates `ejs` from 3.1.10 to 6.0.1
- [Release notes](https://github.com/mde/ejs/releases)
- [Changelog](https://github.com/mde/ejs/blob/main/RELEASE_NOTES_v5.md)
- [Commits](https://github.com/mde/ejs/compare/v3.1.10...v6.0.1)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.8
  dependency-type: indirect
- dependency-name: ejs
  dependency-version: 6.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William Mantly <wmantly@gmail.com>
2026-08-01 11:11:21 -04:00
dependabot[bot] 8bf963f48b Bump body-parser from 2.2.2 to 2.3.0 in /nodejs (#175)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.2.2 to 2.3.0.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William Mantly <wmantly@gmail.com>
2026-08-01 11:02:25 -04:00
wmantly 30835baaeb Merge pull request #197 from theta42/release-v1.12.0
chore(release): v1.12.0
2026-08-01 01:21:50 -04:00
wmantly 2319ac3a0e chore(release): v1.12.0 2026-08-01 01:20:22 -04:00
wmantly 8c0eaf0d07 Merge pull request #196 from theta42/release/1.9.0
Release 1.9.0: cross-app super admin, modal add-buttons, LE key persistence
2026-07-30 12:03:01 -04:00
wmantly 8c81128235 Release 1.9.0: cross-app super admin, modal add-buttons, LE key persistence 2026-07-30 12:01:50 -04:00
wmantly 4f23d301bf Merge pull request #195 from theta42/feat/super-admin-add-buttons-le-persist
Recognize app_super_admin; add-user/add-permission as modal buttons; persist LE key
2026-07-30 12:00:27 -04:00
wmantly be666f5b2f Recognize app_super_admin; add-user/add-permission as modal buttons; persist LE key
- app_super_admin is a new cross-app LDAP group (also recognized by
  sso-manager-node and jump-host): added to conf.auth.adminGroups so
  members are always global admins here, same as the existing anti-lockout
  adminUsers/adminGroups mechanism.
- Users and Permissions pages: the always-visible sidebar "Add" forms are
  now an "Add User"/"Add Permission" button in the list header that opens
  an app.modal dialog, matching the hosts.ejs convention.
- The Let's Encrypt ACME account key now defaults to the already-persisted
  /data volume (models/host.js) instead of a CWD-relative path
  (./le_key.cert -> /app/le_key.cert in the container), which was lost on
  every image rebuild. Falls back to the old relative path when /data isn't
  present (e.g. local dev outside docker).
2026-07-30 11:57:44 -04:00
wmantly 2bfba93e00 Merge pull request #194 from theta42/release/1.8.0
Release 1.8.0: page width standardization, card layouts, external-user marking
2026-07-29 22:13:59 -04:00
wmantly 8cee583da3 Release 1.8.0: page width standardization, card layouts, external-user marking 2026-07-29 22:12:30 -04:00
wmantly b5fab6c91a Merge pull request #193 from theta42/feat/ui-standardization-external-users
Standardize page width, card layouts; mark SSO users external and read-only
2026-07-29 22:11:39 -04:00
wmantly c7ec65e0d9 Standardize page width, card layouts; mark SSO users external and read-only
- All pages now wrap their content in <div class="container mt-4">,
  matching sso-manager-node's width instead of rendering full-bleed inside
  the fluid shell.
- Users and Permissions pages converted from bare <table>s to the same
  card-grid convention already used on the Groups page.
- Users backed by SSO/OIDC login (backing === 'oidc', set by the redis
  user model's JIT-provisioning path) are now marked "External (SSO)" and
  their password-change control is hidden; PUT /password/:username also
  rejects with 403 server-side for such users. Deletion stays allowed.
  Redis-backend only -- LDAP/PAM deployments have no per-record marker for
  this today.
- app-base.js (byte-identical across the 3 apps): added
  app.util.revealItem(), wired into the Users/Permissions create flows.
- Bumped @simpleworkjs/frontend to ^0.2.7.
2026-07-29 22:09:50 -04:00
wmantly 3f2ef57c78 Release 1.7.0: API-token modal-based create, Edit modal, reveal-modal fix (#192) 2026-07-28 20:43:42 -04:00
wmantly c0e04d1a56 Unify API-token UI: modal-based create, new Edit modal (#191)
Continues the cross-app API-token UI unification (jump-host landed first).
proxy already had the card grid and a description field, so this is a
smaller diff: converts the always-visible inline create-form card into a
"+ New Token" button + app.modal (matching the Add-Resource/Add-Host
convention used stack-wide, per explicit direction to standardize on the
modal-button approach rather than sso-manager-node's inline-card style),
adds a net-new Edit modal (proxy's PUT /api-token/:id already fully
supported it -- no route change needed), and replaces the static
#secretModal with the same bare app.modal showToken()/copyFieldValue()
pattern jump-host uses.

Found and fixed a real timing bug along the way: the create flow's
evalAJAX called app.modal.close() immediately before showToken() (which
calls app.modal.open()) in the same synchronous tick. app.modal is a
singleton, and close() immediately followed by open() collides with
Bootstrap's hide-transition guard -- show() silently no-ops while
_isTransitioning is still true from the just-started hide(), so the
"secret revealed" modal never actually appeared after creating a token.
Confirmed via a live click-through: the reveal modal stayed invisible
(title set, `.show` class never added) with the close() call, and rendered
correctly with it removed.

Also fixed the same latent bug in jump-host's already-shipped v1.10.0
(submitApiToken() had the identical close()-then-open() sequence) and in
sso-manager-node's directory.ejs (saveResource()'s OAuth-secret-reveal
path, softened there by an intervening `await loadResources()` but not
guaranteed race-free).

Verified live: create -> reveal modal now appears correctly; Edit modal
shows real created-by/on data, saves a description change, card refreshes.
2026-07-28 20:40:39 -04:00
wmantly 130d4a2c9b Release 1.6.0: host modal standardization (#190) 2026-07-28 19:06:51 -04:00
wmantly 3b729295b0 Standardize the host modal: tabs onto app.modal, footer, linkable URL (#189)
Migrates proxy's hostModal (the modal this session's app.modal tabs/footer/
url support was originally modeled on) off its old always-in-DOM static
element and onto the shared app.modal component (@simpleworkjs/frontend
0.2.6), continuing the entity-modal standardization started with
sso-manager-node's resource modal.

Unlike the resource modal, this one already had 6 tabs and Host's audit
trail (created_by/created_on/updated_by/updated_on) already existed and was
already populated correctly by routes/host.js -- no model or route changes
needed there. The tab markup itself is kept as one hand-built bodyHtml
string rather than going through app.modal's own `tabs` array option: that
option builds the tab-content div itself, and there'd be no way to wrap a
<form> around just that piece without extending app.modal again, whereas
this modal's tabs already are exactly the pattern app.modal's own tabs
option was modeled on.

Key correctness points, found and handled:
- The one JS binding that was unsafe under DOM rebuild -- a `keyup` handler
  driving the Let's-Encrypt challenge-type/wildcard visibility, previously
  bound once directly against a captured selector -- is now delegated via
  app.modal.on(), the same bug class already found and fixed in the
  resource modal.
- hostLoadAuthSuggestions() (fills the SSO allow-list autocomplete
  datalists) now re-runs on every modal open, not just once at page load,
  since the datalists are rebuilt fresh (empty) each time.
- .actionMessage moved from a sibling of the old modal's <form> into a
  descendant of it: formAJAX's error/success target resolution
  (app.messages.action's closest('div.card') then a find('.actionMessage')
  fallback) only succeeds via the fallback path once app.modal owns the
  DOM, since app.modal's .modal-content carries no 'card' class.
- The footer's audit dates needed the explicit 'x' (unix ms) format token
  for moment() -- Host's created_on/updated_on come back as redis-hash
  strings, and moment's bare fallback parser silently produces "Invalid
  date" for a numeric string without it (this app's own hostParseRow
  already does this correctly elsewhere; the new footer code needed the
  same treatment).

Also adds GET /hosts/:host (mirroring sso-manager-node's /directory/:slug)
plus a client-side deep-link check, threaded through a new onLoaded
callback on hostPopulate().

Verified live against the running dev stack: all 6 tabs render and save
correctly; the footer shows real created/updated-by/on dates; the SSO
autocomplete has options on a second modal open (not just the first);
the challenge-type keyup logic fires correctly on a second-ever modal
open (confirmed via the actual GET /api/host/wildcard-parent/... network
request); the address bar updates to /hosts/{host} and reverts on close;
a direct load of /hosts/{host} auto-opens the right host's modal; and a
real save (PUT) closes the modal and live-updates the row via the existing
pubsub subscription, end to end.
2026-07-28 19:04:56 -04:00
wmantly 44c2ec3fdd Merge pull request #188 from theta42/release/1.5.3
Release 1.5.3
2026-07-28 13:38:28 -04:00
wmantly e9032ec4fb Release 1.5.3: fix HTML-escaped loading indicator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:31:39 -04:00
wmantly a6e745cfc1 Merge pull request #187 from theta42/fix/loading-message-html-escaped
Fix HTML-escaped loading indicator in formAJAX
2026-07-28 13:07:46 -04:00
wmantly 8350ab9b57 Fix HTML-escaped loading indicator in formAJAX
Same fix as sso-manager-node: formAJAX's loading indicator passed a raw
<div class="spinner-border"> string to app.messages.action, which
HTML-escapes its message by design (@simpleworkjs/frontend) -- so every
form submit briefly showed the literal markup as text instead of a
spinner. Replaced with plain text ("Saving…").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:52:39 -04:00
wmantly 001fe84b99 Merge pull request #186 from theta42/release/1.5.2
Release 1.5.2
2026-07-28 00:20:35 -04:00
wmantly e05ec1d2ad Release 1.5.2: add no-native-dialogs regression test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:18:35 -04:00
wmantly 652df4f3cf Merge pull request #185 from theta42/test/no-native-dialogs
Add regression test: no native alert()/confirm()/prompt()
2026-07-27 23:58:01 -04:00
wmantly 9370734084 Add regression test: no native alert()/confirm()/prompt()
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on sso-manager-node's equivalent
secret-rotate flow -- it froze the tab). Every call site in this app was
already removed in favor of app.messages.action/confirm/toast and
app.modal.open; this static check (scans views/ and public/js|lib/js for
bare alert(/confirm(/prompt() calls) keeps a regression from shipping
unnoticed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 23:36:34 -04:00
wmantly 58204da3cb Merge pull request #184 from theta42/release/1.5.1
Release 1.5.1
2026-07-27 17:24:16 -04:00
wmantly 2d7eadec60 Release 1.5.1: remove native confirm() calls
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 17:23:07 -04:00
wmantly 0b9a1aabfd Merge pull request #183 from theta42/fix/no-native-dialogs
Remove native confirm() calls in revokeToken/rotateToken
2026-07-27 16:52:42 -04:00
wmantly 36c3f7a881 Remove native confirm() calls in revokeToken/rotateToken
Native confirm() blocks browser automation entirely (found live, mid
browser-test of the app.messages/app.modal adoption, on sso-manager-node's
equivalent flow). Both functions already receive btn, whose .closest('.card')
is already used for the error path, so app.messages.confirm targets the
same card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:50:33 -04:00
wmantly 3686a5ddb8 Merge pull request #182 from theta42/release/1.5.0
Release 1.5.0
2026-07-27 14:18:27 -04:00
wmantly ce013d7e31 Release 1.5.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:16:47 -04:00
wmantly f6bc38eef2 Merge pull request #181 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:10:13 -04:00
wmantly 0a659428dd Adopt @simpleworkjs/frontend's messages/modal/validate modules
Same swap as sso-manager-node/jump-host: vendored app.util.actionMessage/
actionConfirm replaced by @simpleworkjs/frontend's app.messages.action/
confirm (real HTML-escaping, toast fallback); vendored val.js replaced by
the package's app.validate.js.

proxy's host/target/hostname validation rules (mirrored from the backend's
utils/hostname_validate.js — wildcard DNS patterns, not something other
apps need) move to public/js/app.js, registered via $.validateSettings,
since they're proxy-specific and don't belong in the shared package's
generic rule set (eq/user/password/ip).

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched, same
reasoning as sso-manager-node's PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:07:14 -04:00
wmantly e770bbb41f Merge pull request #180 from theta42/docs/screenshots-refresh
docs: refresh proxy screenshots for the unified UI
2026-07-26 16:31:50 -04:00
wmantly 90619dd4ff docs: refresh proxy screenshots for the unified UI
hosts.png, host-auth-sso.png and host-auth-basic.png still showed the
pre-unification nav; replaced with the current shared shell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:28:44 -04:00
34 changed files with 1682 additions and 1157 deletions
+117
View File
@@ -1,3 +1,24 @@
## v1.35.0
- feat: **permission entries can be edited.** The Permissions page only offered Delete, so changing a role or scope meant removing the grant and re-adding it from memory. New `PUT /api/permission/:id` plus an Edit modal pre-filled from the record.
- fix: a permission's id is derived from (subjectType, subject, scope, domain), so changing any of those is a *different* record, not an update. The endpoint creates the new grant and removes the superseded one in that order, so an edit can never leave the old grant behind still conferring access.
## v1.34.0
- feat: the per-host SSO **Allowed groups** field now autocompletes from the SSO directory's groups. Suggestions previously came only from local groups, permission subjects and `conf.auth` maps — none of which can match an SSO-gated host, because its allow-list is checked against the `groups` claim the SSO issues. New `conf.sso` block (`url` + read-only `apiToken`, minted by theta-suite's bootstrap); results are cached for 5 minutes and the endpoint degrades silently to the old local-only list when unset.
- fix: the SSO group lookup authenticates with `Authorization: Bearer <token>`, not the `auth-token` header — the latter is for browser session UUIDs and would be rejected for a minted API token.
- docs: `docs/concepts-hosts.md` gains a "Putting a host behind single sign-on" section covering the per-host `/__proxy_auth` flow, the wildcard redirect URI the IdP must allow, and where group suggestions come from.
- docs: `secrets.js.example` documents the new `sso` block.
## v1.33.0
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare JSON/text response
- feat: DNS page is now admin-only (hidden from non-admins; API already admin-gated)
- feat: navbar — username no longer underlined; only the active link is bold + underlined
## v1.13.3
- fix: remove missing DEPLOYMENT.md and docs/ from Docker build context
## v1.13.2
- chore: Update CI pipeline integration
# Changelog # Changelog
All notable changes to this project are documented here. Format loosely All notable changes to this project are documented here. Format loosely
@@ -6,6 +27,102 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [1.13.1] - 2026-08-01
### Fixed
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
proxy exit at boot in any deployment without an OpenBao sidecar (standalone
Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing token (warn
+ continue from `CONF_SECRETS`), matching the documented contract. The
theta-env stack is unaffected (it always sets a scoped `VAULT_TOKEN`).
## [1.13.0] - 2026-08-01
### Changed
- **Secrets now load from OpenBao at boot** via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy
authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy `proxy`
read-only on its own path), never the root token. Because the OIDC
`clientSecret` is captured at require time inside `createOidcClient` (during
`require('../models')`, which `require('../app')` triggers transitively),
`bin/www` now defers `require('../app')` until after `bao-conf.init()`
resolves. Fail-soft: if OpenBao is unreachable, boot continues from
`CONF_SECRETS`. The `config/proxy-secrets.js` file is now an operator-edit
seed artifact (gitignored); OpenBao is authoritative. See theta-env's
[Secrets docs](https://theta42.github.io/theta-env/secrets/).
- Bumped package version to track the release tag.
## [1.12.1] - 2026-08-01
### Changed
- Bumped `body-parser` 2.2.2 → 2.3.0 (Dependabot #175).
- Bumped `ejs` and `brace-expansion` (Dependabot #179, security maintenance).
## [1.12.0] - 2026-08-01
### Fixed
- Changed UNIX socket permission in `unix_socket_json.js` to `666` so OpenResty Nginx workers running as `nobody` can resolve targets properly.
## [1.9.0] - 2026-07-30
### Added
- **`app_super_admin` cross-app group** recognized as a global admin (`conf.auth.adminGroups`), same group also recognized by sso-manager-node and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
### Changed
- **Users and Permissions pages**: the always-visible sidebar "Add" forms are now an "Add User"/"Add Permission" button in the list header that opens an `app.modal` dialog, matching the hosts.ejs convention.
- **Let's Encrypt ACME account key** now defaults to the already-persisted `/data` volume instead of a CWD-relative path (`./le_key.cert` -> `/app/le_key.cert` in the container), which was lost on every image rebuild.
## [1.8.0] - 2026-07-28
### Added
- **Users backed by SSO/OIDC login are now marked "External (SSO)"** and read-only: their password-change control is hidden, and `PUT /password/:username` rejects with 403 server-side. Deletion stays allowed. Redis user-backend only.
- **`app.util.revealItem()`** (shared `app-base.js`): scrolls a just-added/-edited element into view and flashes its background. Wired into the Users/Permissions create flows.
### Changed
- **All pages now wrap their content in a standard-width container**, matching sso-manager-node instead of rendering full-bleed.
- **Users and Permissions pages converted from bare `<table>`s to the card-grid convention** already used on the Groups page.
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
## [1.7.0] - 2026-07-28
### Added
- **API tokens: "+ New Token" modal button (replacing the always-visible inline create-form card) and a new Edit modal** — continues the cross-app API-token UI unification started in jump-host. The Edit modal's footer shows real created-by/on data; the `PUT /api-token/:id` route already fully supported editing, so no backend change was needed.
### Fixed
- **Creating an API token didn't show the "save this secret now" reveal modal** — the create flow called `app.modal.close()` immediately before `app.modal.open()` (to show the secret) in the same tick; since `app.modal` is a singleton, that collided with Bootstrap's hide-transition guard and the reveal modal silently never appeared.
## [1.6.0] - 2026-07-28
### Added
- **Host modal: footer with created/updated-by/on metadata, linkable `/hosts/{host}` URL** — migrated onto the shared `app.modal` component (`@simpleworkjs/frontend` 0.2.6), continuing the entity-modal standardization started in sso-manager-node. Kept the existing 6-tab layout (General/TLS & Wildcard/Traffic/Headers/Access/Authentication) unchanged.
### Fixed
- **The Let's-Encrypt challenge-type/wildcard-matching visibility logic could stop reacting to the hostname field after the first Add/Edit** — its `keyup` handler was bound once directly against a selector captured at page load; now delegated so it survives the modal being rebuilt on every open.
- **The SSO allow-list autocomplete (users/groups) could go empty starting on the second Add/Edit** — its backing `<datalist>`s are rebuilt fresh on every modal open now, so the suggestions are refetched each time too.
## [1.5.3] - 2026-07-28
### Fixed
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node's companion release. Replaced with plain text.
## [1.5.2] - 2026-07-28
### Added
- **Regression test**: a static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page and were fully removed in 1.5.1.
## [1.5.1] - 2026-07-27
### Fixed
- **Removed native `confirm()` calls in `revokeToken`/`rotateToken`** (`profile.ejs`), replacing them with `app.messages.confirm`. Native `confirm()` blocks all further browser events on the page (discovered live, mid browser-verification of sso-manager-node's equivalent flow, where it froze the tab).
## [1.5.0] - 2026-07-27
### Changed
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped, and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. proxy's `host`/`target`/`hostname` wildcard-DNS validation rules (mirroring `utils/hostname_validate.js`) moved to `public/js/app.js`, registered via `$.validateSettings`, since they're proxy-specific and don't belong in the shared package's generic rule set. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
## [1.4.0] - 2026-07-26 ## [1.4.0] - 2026-07-26
### Changed ### Changed
-2
View File
@@ -115,8 +115,6 @@ COPY nodejs/api.md ./api.md
# docs/ mirrors the repo's own top-level docs/ folder. # docs/ mirrors the repo's own top-level docs/ folder.
COPY README.md /README.md COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js). # Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit COPY --from=gitinfo /commit.txt ./.build_commit
+17
View File
@@ -162,6 +162,23 @@ docker compose exec proxy tail -f /var/log/nginx/error.log
docker compose logs --tail=200 --since=10m proxy docker compose logs --tail=200 --since=10m proxy
``` ```
## Secrets
Secrets are loaded from **OpenBao** at boot via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy's OIDC
`clientSecret` is captured at require time (inside `createOidcClient` during
`require('../models')`), so `bin/www` runs `bao-conf.init()` **before**
`require('../app')` (which transitively loads models). Fail-soft: if OpenBao is
unreachable, boot continues from `CONF_SECRETS`. The proxy authenticates to
OpenBao with the scoped `VAULT_TOKEN` (env, policy `proxy` — read only
`secret/proxy/conf`), never the root token.
The `config/proxy-secrets.js` file is an operator-edit seed artifact
(gitignored); the bootstrap writes the generated OAuth client creds into
OpenBao, which is authoritative. For the full architecture see theta-env's
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
## Manual Installation ## Manual Installation
For manual installation or other distributions, see the detailed steps below. For manual installation or other distributions, see the detailed steps below.
+32
View File
@@ -68,6 +68,38 @@ host form whenever the name you're entering already has a matching
wildcard available to reuse — including the wildcard's own bare base wildcard available to reuse — including the wildcard's own bare base
domain (e.g. `example.com` itself, not just `something.example.com`). domain (e.g. `example.com` itself, not just `something.example.com`).
## Putting a host behind single sign-on
Each host can be gated on its own, independently of the proxy's management UI.
On the host's **Auth** tab pick **Single sign-on (SSO)** and, optionally, fill in
the **Allowed users** / **Allowed groups** lists. Empty lists mean any
authenticated user is allowed; otherwise the identity must match one of them.
The proxy runs the OIDC flow itself at `/__proxy_auth` on the protected host and
keeps a Redis-backed session in a `__proxy_sso` cookie, so the app behind it
needs no changes.
**The IdP must allow the per-host callback.** Each protected host calls back to
`https://<that-host>/__proxy_auth/callback`, which is a different URL for every
host, all against the proxy's one OAuth client. Register a wildcard redirect URI
on that client — the SSO Manager supports `*` (one label) and `**` (any number):
```
https://**.example.com/__proxy_auth/callback
https://example.com/__proxy_auth/callback
```
theta-suite's bootstrap registers both automatically, and backfills them onto an
existing client. Without them, switching a host to SSO fails at the IdP with
`400 redirect_uri is not registered for this client`.
**Group suggestions come from the SSO.** The Allowed groups field autocompletes
from the SSO directory's groups when `sso.url` and `sso.apiToken` are set in the
proxy's config (theta-suite's bootstrap mints that read-only token). Without it
the field can only suggest the proxy's local groups, which for an SSO-gated host
are rarely the ones you want — the allow-list is matched against the `groups`
claim in the SSO's token, so only SSO groups can ever match.
## Load Balancing ## Load Balancing
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration. If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 310 KiB

+15
View File
@@ -21,6 +21,8 @@ module.exports = app;
// Hold onto the auth middleware // Hold onto the auth middleware
const middleware = require('./middleware/auth'); const middleware = require('./middleware/auth');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('./utils/build_info');
// Grab the projects PubSub // Grab the projects PubSub
app.contoller = require('./controller'); app.contoller = require('./controller');
@@ -115,6 +117,19 @@ app.use(async function(err, req, res, next) {
res.status(status); res.status(status);
// Only expose safe, non-internal fields to the client. // Only expose safe, non-internal fields to the client.
const body = { name: err.name, message: err.message }; const body = { name: err.name, message: err.message };
// Browser navigation gets the HTML error page (shared with SSO); API
// clients get JSON.
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
res.render('error', {
title: conf.environment !== 'production' ? 'dev' : '',
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
logo: conf.logo,
...buildInfo,
error: err,
});
return;
}
res.json(body); res.json(body);
}catch(error){ }catch(error){
console.error('error in the catch-all error handler', error); console.error('error in the catch-all error handler', error);
+36 -23
View File
@@ -4,10 +4,19 @@
* Module dependencies. * Module dependencies.
*/ */
var app = require('../app');
var debug = require('debug')('proxy-api:server');
var http = require('http');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const debug = require('debug')('proxy-api:server');
const http = require('http');
// @simpleworkjs/conf loads ./config/proxy-secrets.js synchronously, then
// @simpleworkjs/bao-conf deep-merges secret/proxy/conf from OpenBao over it.
// The OIDC clientSecret is captured at require time inside models (via
// createOidcClient), and require('../app') transitively loads models, so the
// OpenBao fetch MUST resolve before require('../app'). Fail-soft: if OpenBao
// is unreachable, init() leaves conf as the file-loaded fallback and boot
// continues from ./config/proxy-secrets.js.
require('@simpleworkjs/bao-conf').init({ path: 'proxy', conf }).then(() => {
var app = require('../app'); // models + createOidcClient now see merged conf
/** /**
* Get port from environment and store in Express. * Get port from environment and store in Express.
@@ -33,26 +42,6 @@ server.listen(port);
server.on('error', onError); server.on('error', onError);
server.on('listening', onListening); server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/** /**
* Event listener for HTTP server "error" event. * Event listener for HTTP server "error" event.
*/ */
@@ -96,3 +85,27 @@ function onListening() {
listener() listener()
} }
} }
}).catch(err => {
console.error('boot failed:', err);
process.exit(1);
});
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
+14 -1
View File
@@ -45,11 +45,24 @@ module.exports = {
usernameClaim: 'preferred_username', usernameClaim: 'preferred_username',
}, },
// Read-only SSO management API access, used to populate the per-host SSO
// allow-list autocomplete with the groups that actually exist in the
// directory. Without it the "Allowed groups" field can only suggest groups
// the proxy already knows locally, which for an SSO-gated host is usually
// none of the ones the operator wants. `apiToken` is a machine token minted
// by the theta-suite bootstrap and lives in secrets.js; leaving it unset
// simply falls back to the local-only suggestions.
sso: {
url: '', // e.g. https://sso.example.com
apiToken: '',
},
// Authorization: how groups map to roles, and which groups are global admin. // Authorization: how groups map to roles, and which groups are global admin.
// Per-user overrides are Grant records managed in the app. // Per-user overrides are Grant records managed in the app.
auth: { auth: {
// Members of these SSO/LDAP groups are always global admins. // Members of these SSO/LDAP groups are always global admins.
adminGroups: [], // app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
adminGroups: ['app_super_admin'],
// Optional default role mapping for groups, e.g. // Optional default role mapping for groups, e.g.
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } } // { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } } // { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
+73 -1
View File
@@ -3,6 +3,7 @@
const crypto = require("crypto"); const crypto = require("crypto");
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const baoConf = require('@simpleworkjs/bao-conf');
const Table = require('.'); const Table = require('.');
const ModelPs = require('../utils/model_pubsub'); const ModelPs = require('../utils/model_pubsub');
@@ -139,11 +140,26 @@ class DnsProvider extends Table{
let __intraModel = this.__intraModel(data.dnsProvider); let __intraModel = this.__intraModel(data.dnsProvider);
Provider = __intraModel.Provider; Provider = __intraModel.Provider;
if (!data.id) data.id = crypto.randomBytes(8).toString("hex");
let secrets = {};
for (let key in Provider._keyMap) {
if (Provider._keyMap[key].isPrivate && data[key] !== undefined) {
secrets[key] = data[key];
}
}
// This is here test if the given API key is valid // This is here test if the given API key is valid
let provider = new __intraModel.Provider(data, ...args); let provider = new __intraModel.Provider(data, ...args);
let domains = await provider.listDomains(); let domains = await provider.listDomains();
for (let key in secrets) data[key] = '********';
let instance = await super.create.call(__intraModel, data, ...args); let instance = await super.create.call(__intraModel, data, ...args);
if (Object.keys(secrets).length > 0) {
await baoConf.set(`proxy/dns-providers/${instance.id}`, secrets);
}
try{ try{
await instance.updateDomains(domains); await instance.updateDomains(domains);
}catch(updateError){ }catch(updateError){
@@ -189,7 +205,63 @@ class DnsProvider extends Table{
let instance = await super.get(data, ...args); let instance = await super.get(data, ...args);
let __intraModel = this.__intraModel(instance.dnsProvider); let __intraModel = this.__intraModel(instance.dnsProvider);
return await super.get.call(__intraModel, data, ...args); let resolved = await super.get.call(__intraModel, data, ...args);
try {
let secrets = await baoConf.get(`proxy/dns-providers/${resolved.id}`);
if (secrets) Object.assign(resolved, secrets);
} catch(e) {}
return resolved;
}
static async findall(...args){
let instances = await super.findall(...args);
for (let inst of instances) {
try {
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
if (secrets) Object.assign(inst, secrets);
} catch(e) {}
}
return instances;
}
static async find(...args){
let instances = await super.find(...args);
for (let inst of instances) {
try {
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
if (secrets) Object.assign(inst, secrets);
} catch(e) {}
}
return instances;
}
async update(data){
let Provider = this.constructor.Provider || providers[this.dnsProvider];
let secrets = {};
if (Provider) {
for (let key in Provider._keyMap) {
if (Provider._keyMap[key].isPrivate && data[key] !== undefined && data[key] !== '********') {
secrets[key] = data[key];
data[key] = '********';
} else if (Provider._keyMap[key].isPrivate && data[key] === '********') {
delete data[key]; // Do not update the masked value if it's sent back
}
}
}
let res = await super.update(data);
if (Object.keys(secrets).length > 0) {
let existing = await baoConf.get(`proxy/dns-providers/${this.id}`) || {};
await baoConf.set(`proxy/dns-providers/${this.id}`, { ...existing, ...secrets });
Object.assign(this, secrets);
}
return res;
}
async remove(...args){
await baoConf.request('DELETE', `proxy/dns-providers/${this.id}`).catch(()=>{});
return await super.remove(...args);
} }
static listProviders(){ static listProviders(){
+13
View File
@@ -9,10 +9,23 @@ const tldExtract = require('tld-extract').parse_host;
const LetsEncrypt = require('../utils/letsencrypt'); const LetsEncrypt = require('../utils/letsencrypt');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const fs = require('fs');
const path = require('path');
// Defaults to the same persisted volume Redis uses (/data, see
// docker-entrypoint.sh's REDIS_DATA_DIR) instead of the old CWD-relative
// default (./le_key.cert -> /app/le_key.cert), which lives in the
// container's writable layer and was lost on every rebuild. Falls back to
// the old relative path when /data isn't present (e.g. local dev outside
// docker), so it stays writable there too.
const dataDir = process.env.REDIS_DATA_DIR || '/data';
const accountKeyPath = fs.existsSync(dataDir) ? path.join(dataDir, 'le_key.cert') : './le_key.cert';
const letsEncrypt = new LetsEncrypt({ const letsEncrypt = new LetsEncrypt({
directoryUrl: conf.environment === "production" ? directoryUrl: conf.environment === "production" ?
LetsEncrypt.AcmeClient.directory.letsencrypt.production : LetsEncrypt.AcmeClient.directory.letsencrypt.production :
LetsEncrypt.AcmeClient.directory.letsencrypt.staging, LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
accountKeyPath,
}); });
class Host extends Table{ class Host extends Table{
+78 -93
View File
@@ -1,18 +1,20 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.4.0", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.4.0", "version": "1.35.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
@@ -20,7 +22,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
"ejs": "^3.1.10", "ejs": "^6.0.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
@@ -296,6 +298,18 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/conf": { "node_modules/@simpleworkjs/conf": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
@@ -308,6 +322,15 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/ldap": { "node_modules/@simpleworkjs/ldap": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
@@ -436,12 +459,6 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -507,20 +524,20 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^2.0.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.1", "qs": "^6.15.2",
"raw-body": "^3.0.1", "raw-body": "^3.0.2",
"type-is": "^2.0.1" "type-is": "^2.1.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -530,6 +547,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.8", "version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -550,16 +580,16 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.7", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
@@ -838,18 +868,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/ejs": { "node_modules/ejs": {
"version": "3.1.10", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": { "bin": {
"ejs": "bin/cli.js" "ejs": "bin/cli.js"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.12.18"
} }
}, },
"node_modules/encodeurl": { "node_modules/encodeurl": {
@@ -1061,42 +1088,6 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1473,23 +1464,6 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jq-repeat": { "node_modules/jq-repeat": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz", "resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
@@ -1796,12 +1770,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
@@ -2303,17 +2271,34 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^2.0.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"mime-types": "^3.0.0" "mime-types": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/undefsafe": { "node_modules/undefsafe": {
+8 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.4.0", "version": "1.35.0",
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
@@ -10,10 +10,10 @@
"scripts": { "scripts": {
"start": "node ./bin/www", "start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js", "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js",
"test:integration": "node --test test/integration/dns_provider.test.js", "test:integration": "node --test test/integration/dns_provider.test.js",
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js test/integration/dns_provider.test.js"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@@ -21,8 +21,10 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
@@ -30,7 +32,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
"ejs": "^3.1.10", "ejs": "^6.0.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
+12
View File
@@ -3,10 +3,22 @@ nav.navbar{
padding-right: 1em; padding-right: 1em;
} }
/* Only the active top-nav link is bold + underlined; the username is plain. */
.top-nav a.active{
font-weight: bold;
text-decoration: underline;
}
body { body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
} }
#spa-shell { #spa-shell {
+69
View File
@@ -91,3 +91,72 @@ app.apiToken = (function(app){
return {list, get, add, update, remove, rotate}; return {list, get, add, update, remove, rotate};
})(app); })(app);
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
// Proxy-specific, so it's registered here (via @simpleworkjs/frontend's
// $.validateSettings) rather than in the shared package's generic rule set.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
}
});
})();
+49 -79
View File
@@ -363,7 +363,7 @@ app.auth = (function(app){
} }
if(requiredGroups && !await memberOf(requiredGroups, user)){ if(requiredGroups && !await memberOf(requiredGroups, user)){
app.util.actionMessage( app.messages.action(
`<h1> `<h1>
<i class="fa-solid fa-triangle-exclamation"></i> <i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b> <b>You do not have permission to be here.</b>
@@ -466,13 +466,19 @@ app.permission = (function(app){
}); });
} }
function update(id, args, callback){
app.api.put('permission/' + encodeURIComponent(id), args, function(error, data){
callback(error, data);
});
}
function remove(id, callback){ function remove(id, callback){
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){ app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
callback(error, data); callback(error, data);
}); });
} }
return {list, subjects, add, remove}; return {list, subjects, add, update, remove};
})(app); })(app);
@@ -520,68 +526,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
}; };
function actionMessage(message, $targetPassed, type, callback){ // escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
message = message || ''; // app.util.escapeHtml and app.messages.action/confirm.
function escapeHtml(s){
let $target = $targetPassed.closest('div.card').find('.actionMessage'); return String(s == null ? '' : s)
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]); .replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
type = type || 'info'; .replace(/>/g, '&gt;')
callback = callback || function(){}; .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
// Messages that bring their own buttons (actionConfirm) are left
// alone; everything else gets the standard dismiss button.
if(!message.includes('<button')) message = `
<span class="align-middle">${message}</span>
<button class="action-close btn btn-sm btn-outline-dark float-end">
<i class="fa-solid fa-xmark"></i>
</button>
`
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
}
function actionConfirm(message, $target, type, callback){
return new Promise((resolve, reject) =>{
let id = crypto.randomUUID();
message = `
<h4 class"align-middle" >
<i class="fa-solid fa-triangle-exclamation"></i>
<b>${message}</b>
<span class="float-end">
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
<i class="fa-solid fa-circle-check"></i>
Confirm
</button>
<button type="button" class="btn btn-danger confirm-${id}">
<i class="fa-solid fa-circle-stop"></i>
Cancel
</button>
</span>
</h4>
`
actionMessage(message, $target, type);
$("body").on('click', `.confirm-${id}`, function(){
actionMessage('', $target, type);
resolve(!!$(this).data('confirm'));
});
});
} }
$.fn.serializeObject = function() { $.fn.serializeObject = function() {
@@ -637,11 +590,31 @@ app.util = (function(app){
document.body.removeChild(element); document.body.removeChild(element);
} }
// Scroll a just-added/-edited element into view and flash its
// background, so the user's eye lands on the row that changed instead of
// it silently appearing/updating somewhere off-screen. Takes a jQuery
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
function revealItem(el){
var node = el && el.jquery ? el[0] : el;
if (!node) return;
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({behavior: 'smooth', block: 'center'});
}
var prevTransition = node.style.transition;
var prevBg = node.style.backgroundColor;
node.style.transition = 'background-color 1.5s ease';
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
setTimeout(function(){
node.style.backgroundColor = prevBg;
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
}, 300);
}
return { return {
downloadFile: downloadFile, downloadFile: downloadFile,
getUrlParameter: getUrlParameter, getUrlParameter: getUrlParameter,
actionMessage: actionMessage, escapeHtml: escapeHtml,
actionConfirm, revealItem: revealItem,
} }
})(app); })(app);
@@ -696,9 +669,9 @@ $( document ).ready(async function(){
$(this).closest('.card').slideUp('fast'); $(this).closest('.card').slideUp('fast');
}); });
$('.actionMessage').on('click', 'button.action-close', function(event){ // action-close click handling is wired by @simpleworkjs/frontend's
app.util.actionMessage(null, $(this)); // app.messages.js (delegated on document, so it also covers messages
}); // rendered after this ready handler runs).
setInterval(()=>{ setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{ $('.momentFromNow').each((idx, el)=>{
@@ -729,20 +702,17 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase(); var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){ if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger') app.messages.action('Please fix the form errors.', $form, 'danger')
return false; return false;
} }
app.util.actionMessage( // Plain text: app.messages.action HTML-escapes its message (by design,
`<div class="spinner-border" role="status"> // see @simpleworkjs/frontend), so raw markup like a spinner <div> would
<span class="visually-hidden">Loading...</span> // render literally instead of as an element.
</div>`, app.messages.action('Saving…', $form, 'info');
$form,
'info'
);
app.api[method]($form.attr('action'), formData, function(error, data){ app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear(); $form.validateClear();
if(!error){ if(!error){
$form.trigger("reset"); $form.trigger("reset");
@@ -750,7 +720,7 @@ function formAJAX(btn){
}else{ }else{
console.log('formAJAX res error', error, data) console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){ if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
} }
if(data && data.keys){ if(data && data.keys){
console.log('form key errors', data.keys) console.log('form key errors', data.keys)
-201
View File
@@ -1,201 +0,0 @@
( function( $ ) {
var settings = {
rule: {
eq: function(value, options){
var compare = $('[name=' + options + ']').val();
if ( value != compare ) {
return "Miss-match";
}
}
},
};
$.fn.validate = function(event) {
// let thisSettings = $.extend(true, settings, settingsObj);
let hasErrors = false;
if(this.is('[validate]')) return this.validateField(event);
if(!this.attr('isValid')){
console.log('adding reset event')
this.on('reset', function(){
$(this).attr('isValid', false);
$(this).validateClear();
})
}
this.find('[validate]').each(function(){
if(!$(this).validateField()) hasErrors = true;
});
this.attr('isValid', !hasErrors);
if(hasErrors && event) event.preventDefault();
return !hasErrors;
};
$.fn.validateClear = function(){
$(this).find('input').each(function(){
$(this).removeClass('is-invalid');
$(this).removeClass('is-valid');
})
}
$.fn.validateField = function(){
var attr = this.attr('validate').split(':'); //array of params
var rule = attr[0];
var options = attr[1];
var value = this.val(); //link to input value
var message;
if(this.prop('disabled')) return true;
//checks if field is required, and length
if(!isNaN(options) && value.length < options){
message = `Must be ${options} characters`;
}
//checks if empty to stop processing
if(!isNaN(options) && value.length === 0) {
}else if(rule in settings.rule){
message = settings.rule[rule].apply(this, [value, options]);
}
this.validateMessage(message)
return !message;
}
$.fn.validateMessage = function(message){
if(message && message !== true){
this.closest('.form-group').find('b.invalid-feedback').html(message);
this.addClass('is-invalid');
}else{
this.removeClass('is-invalid');
this.addClass('is-valid');
}
return this;
};
jQuery.extend({
validateSettings: function( settingsObj ) {
$.extend( true, settings, settingsObj );
},
validateInit: function( ettingsObj ) {
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
$( this ).validate( settingsObj, event );
});
}
});
}( jQuery ));
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
ip: function( value ) {
value = value.split( '.' );
if ( value.length != 4 ) {
return "Malformed IP";
}
$.each( value, function( key, value ) {
if( value > 255 || value < 0 ) {
return "Malformed IP";
}
});
},
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
user: function( value ) {
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
// or at least 3 of {lowercase, uppercase, number, symbol}.
password: function( value ) {
if ( typeof value !== 'string' || value.length < 8 ) {
return "Password must be at least 8 characters";
}
if ( value.length >= 12 ) return;
var classes = 0;
if ( /[a-z]/.test( value ) ) classes++;
if ( /[A-Z]/.test( value ) ) classes++;
if ( /[0-9]/.test( value ) ) classes++;
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
if ( classes < 3 ) {
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
}
}
}
});
})();
+41 -2
View File
@@ -58,16 +58,55 @@ function hashHostSecrets(body){
} }
} }
// The SSO's directory groups, for the per-host SSO allow-list autocomplete.
// The host's allow-list is checked against the `groups` claim the SSO puts in
// the token (see utils/host_sso.js), so the only suggestions that can ever
// match are the SSO's own groups -- the local groups below are a fallback, not
// the real answer. Requires conf.sso.apiToken; degrades to [] without it, and
// never fails the request (the form still works, just without suggestions).
//
// Cached for a few minutes: this is typeahead fodder, and every host editor
// opening the form would otherwise hit the SSO.
let ssoGroupCache = {at: 0, groups: []};
const SSO_GROUP_TTL = 5 * 60 * 1000;
async function ssoGroups(){
let sso = conf.sso || {};
if(!sso.url || !sso.apiToken) return [];
if(Date.now() - ssoGroupCache.at < SSO_GROUP_TTL) return ssoGroupCache.groups;
try{
let res = await fetch(`${sso.url.replace(/\/$/, '')}/api/group`, {
// A minted API token (`sso_<id>_<secret>`) authenticates as a bearer
// token; the SSO's `auth-token` header is for browser session UUIDs
// only and would be rejected here.
headers: {Authorization: `Bearer ${sso.apiToken}`, Accept: 'application/json'},
signal: AbortSignal.timeout(5000),
});
if(!res.ok) throw new Error(`SSO group list failed (${res.status})`);
let body = await res.json();
// The SSO returns { results: [...] } -- either CN strings or objects.
let groups = (body && body.results || []).map(g => (typeof g === 'string' ? g : g && g.name)).filter(Boolean);
ssoGroupCache = {at: Date.now(), groups};
return groups;
}catch(error){
console.error(`[auth-suggestions] could not list SSO groups: ${error.message}`);
// Cache the failure briefly so a down SSO doesn't stall every form open.
ssoGroupCache = {at: Date.now(), groups: ssoGroupCache.groups};
return ssoGroupCache.groups;
}
}
// Autocomplete source for the per-host auth allow-lists (SSO users/groups). // Autocomplete source for the per-host auth allow-lists (SSO users/groups).
// Available to any authenticated host editor (not just global admins). Groups // Available to any authenticated host editor (not just global admins). Groups
// are derived from local groups, existing permission group-subjects, and the // are the SSO directory's groups plus local groups, existing permission
// conf.auth admin/role-map groups. // group-subjects, and the conf.auth admin/role-map groups.
router.get('/auth-suggestions', async function(req, res, next){ router.get('/auth-suggestions', async function(req, res, next){
try{ try{
let users = []; let users = [];
try{ users = (await User.list()) || []; }catch(error){ /* none */ } try{ users = (await User.list()) || []; }catch(error){ /* none */ }
let groups = new Set(); let groups = new Set();
for(let g of await ssoGroups()) groups.add(g);
try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ } try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ }
try{ try{
for(let p of await Permission.listDetail()){ for(let p of await Permission.listDetail()){
+41
View File
@@ -57,6 +57,47 @@ router.post('/', async function(req, res, next){
} }
}); });
// Edit an existing grant.
//
// The record id is derived from (subjectType, subject, scope, domain)
// -- Permission.mkId -- so changing any of those is a DIFFERENT record, not an
// in-place update. Changing only the role is a true update. Handle both here so
// the UI can offer a single "edit" instead of making the operator delete and
// re-add, and so a subject/scope change can never leave the old grant behind
// still conferring access.
router.put('/:id', async function(req, res, next){
try{
let existing = await Permission.get(req.params.id);
if(!existing) return res.status(404).json({message: `Permission ${req.params.id} not found.`});
let next_ = {
subjectType: req.body.subjectType !== undefined ? req.body.subjectType : existing.subjectType,
subject: req.body.subject !== undefined ? req.body.subject : existing.subject,
scope: req.body.scope !== undefined ? req.body.scope : existing.scope,
domain: req.body.domain !== undefined ? req.body.domain : existing.domain,
role: req.body.role !== undefined ? req.body.role : existing.role,
created_by: reqUsername(req),
};
if(next_.scope === 'global') next_.domain = '*';
// create() upserts on the new id, so this is safe in either direction;
// remove the old record afterwards only when the identity actually moved.
let permission = await Permission.create(next_);
let newId = Permission.mkId(next_);
if(newId !== req.params.id){
try{ await existing.remove(); }catch(error){ /* already replaced */ }
}
return res.json({
message: `Updated ${next_.subjectType} "${next_.subject}" to ${next_.role}` +
(next_.scope === 'global' ? ' globally.' : ` on ${next_.domain}.`),
...permission,
});
}catch(error){
next(error);
}
});
router.delete('/:id', async function(req, res, next){ router.delete('/:id', async function(req, res, next){
try{ try{
let permission = await Permission.get(req.params.id); let permission = await Permission.get(req.params.id);
+11 -2
View File
@@ -23,7 +23,7 @@ const values ={
// every deploy and isn't cache-busted/fingerprinted. // every deploy and isn't cache-busted/fingerprinted.
mountStaticModules(router, { mountStaticModules(router, {
root: path.join(__dirname, '..'), root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'], deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
}); });
router.get('/', (req, res) => { router.get('/', (req, res) => {
@@ -40,13 +40,22 @@ router.get('/hosts', async function(req, res, next) {
res.render('hosts', {...values}); res.render('hosts', {...values});
}); });
// Linkable deep-link to a single host's modal, e.g. from the host modal's
// app.modal `url` option. No server-side use of :host -- the client reads
// location.pathname itself and opens the matching host's modal once the
// page's own data has loaded (same idiom sso-manager-node uses for
// /directory/:slug).
router.get('/hosts/:host', async function(req, res, next) {
res.render('hosts', {...values});
});
router.get('/dns', async function(req, res, next) { router.get('/dns', async function(req, res, next) {
res.render('dns', {...values}); res.render('dns', {...values});
}); });
router.get('/users', async function(req, res, next) { router.get('/users', async function(req, res, next) {
res.render('users', {...values}); res.redirect(301, '/hosts');
}); });
router.get('/permissions', async function(req, res, next) { router.get('/permissions', async function(req, res, next) {
+10 -1
View File
@@ -81,11 +81,20 @@ router.put('/password', async function(req, res, next){
} }
}); });
// Admin: reset another user's password. // Admin: reset another user's password. Blocked for SSO/OIDC-provisioned
// accounts (backing === 'oidc') -- they authenticate through the IdP, not a
// local password, so resetting one here would be a no-op at best and a
// false sense of control at worst. Only applies to the redis user backend;
// LDAP/PAM-backed deployments have no per-record marker for this.
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){ router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
try{ try{
validatePassword(req.body.password); validatePassword(req.body.password);
let user = await User.get(req.params.username); let user = await User.get(req.params.username);
if(user.backing === 'oidc'){
let e = new Error('Cannot set a password for an SSO-authenticated user.');
e.status = 403;
throw e;
}
return res.json({results: await user.setPassword(req.body)}); return res.json({results: await user.setPassword(req.body)});
}catch(error){ }catch(error){
next(error); next(error);
@@ -0,0 +1,67 @@
const { describe, test, beforeEach, afterEach, after, mock } = require('node:test');
const assert = require('node:assert');
const crypto = require('crypto');
const baoConf = require('@simpleworkjs/bao-conf');
const Table = require('../../models/index');
const DnsProvider = Table.models.DnsProvider;
const DuckDns = require('../../models/dns_provider/duckdns');
describe('DnsProvider Vault Integration', () => {
let originalSet, originalGet, originalRequest;
after(async () => {
if (Table._redis && Table._redis.quit) {
await Table._redis.quit();
}
});
beforeEach(() => {
// Mock baoConf
originalSet = baoConf.set;
originalGet = baoConf.get;
originalRequest = baoConf.request;
const vaultStore = {};
baoConf.set = mock.fn(async (path, data) => { vaultStore[path] = data; return true; });
baoConf.get = mock.fn(async (path) => vaultStore[path] || {});
baoConf.request = mock.fn(async () => ({}));
mock.method(DuckDns.prototype, 'listDomains', async () => []);
mock.method(DnsProvider.prototype, 'updateDomains', async () => {});
});
afterEach(() => {
baoConf.set = originalSet;
baoConf.get = originalGet;
baoConf.request = originalRequest;
mock.restoreAll();
});
test('create() writes isPrivate keys to OpenBao and get() retrieves them', async () => {
const payload = {
name: 'My Duck',
dnsProvider: 'DuckDns',
token: 'super-secret-vault-token',
subdomains: 'myduck',
created_by: 'admin'
};
const instance = await DnsProvider.create(payload);
// 1. Should have called OpenBao set
assert.strictEqual(baoConf.set.mock.callCount(), 1);
const [path, secrets] = baoConf.set.mock.calls[0].arguments;
assert.strictEqual(path, `proxy/dns-providers/${instance.id}`);
assert.deepStrictEqual(secrets, { token: 'super-secret-vault-token' });
// 2. The returned instance should have the secret injected back
assert.strictEqual(instance.token, 'super-secret-vault-token');
// 3. get() should fetch public data from Redis and merge secrets from OpenBao
// (baoConf.get is already mocked to return from vaultStore)
const fetched = await DnsProvider.get(instance.id);
assert.strictEqual(fetched.token, 'super-secret-vault-token');
});
});
@@ -0,0 +1,44 @@
'use strict';
// Regression guard: native alert()/confirm()/prompt() calls block all further
// browser events on the page (found live, mid browser-automation testing, on
// sso-manager-node's equivalent secret-rotate flow) and are visually
// inconsistent with the rest of the UI. Every call site was removed in favor
// of app.messages.action/confirm/toast and app.modal.open; this test keeps
// it that way.
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', '..', d));
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
function walk(dir) {
let files = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files = files.concat(walk(full));
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
}
return files;
}
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
const offenders = [];
for (const root of ROOTS) {
for (const file of walk(root)) {
const src = fs.readFileSync(file, 'utf8');
let m;
NATIVE_DIALOG_RE.lastIndex = 0;
while ((m = NATIVE_DIALOG_RE.exec(src))) {
const line = src.slice(0, m.index).split('\n').length;
offenders.push(`${path.relative(path.join(__dirname, '..', '..'), file)}:${line}${m[2]}(`);
}
}
}
assert.deepStrictEqual(offenders, []);
});
+1 -2
View File
@@ -37,8 +37,7 @@ module.exports = {
// in (plus the synthetic `admin` group when user/me reports isAdmin). // in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [ nav: [
{href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []}, {href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []},
{href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: []}, {href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: ['admin']},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['admin']},
{href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']}, {href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']},
{href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']}, {href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']},
], ],
+4 -2
View File
@@ -1,7 +1,7 @@
<%- include('top') %> <%- include('top') %>
<script type="text/javascript"> <script type="text/javascript">
// Require login to see this page. // Require an admin to see this page.
app.auth.forceLogin(); app.auth.forceLogin(['admin']);
</script> </script>
<style type="text/css"> <style type="text/css">
@@ -113,6 +113,7 @@
}); });
</script> </script>
<div class="container mt-4">
<div class="row mb-3" style="display:none"> <div class="row mb-3" style="display:none">
<div class="col-md-3"> <div class="col-md-3">
<div class="card shadow-lg mb-3"> <div class="card shadow-lg mb-3">
@@ -299,4 +300,5 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('bottom') %> <%- include('bottom') %>
+25
View File
@@ -0,0 +1,25 @@
<%- include('top') %>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<div class="mb-4">
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
</div>
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
<p class="text-muted mb-4">
<% if (error.status === 404) { %>
The page you are looking for doesn't exist or has been moved.
<% } else { %>
An unexpected error occurred. Please try again later.
<% } %>
</p>
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
<i class="fa-solid fa-house me-2"></i>Return to Home
</a>
</div>
</div>
</div>
<%- include('bottom') %>
+45 -40
View File
@@ -16,6 +16,14 @@
<script type="text/javascript"> <script type="text/javascript">
function loadGroups() {
app.group.list(function(error, data){
if(error) return app.messages.action(error, $('#groups-list'), 'danger');
$.scope.LocalGroup.empty();
for(let g of (data.results || [])) $.scope.LocalGroup.push(g);
});
}
// Usernames for the "add member" autocomplete (reuses the permission // Usernames for the "add member" autocomplete (reuses the permission
// subjects endpoint, which is admin-only like this page). // subjects endpoint, which is admin-only like this page).
function loadUserSuggestions(){ function loadUserSuggestions(){
@@ -28,15 +36,16 @@
function removeGroup(name){ function removeGroup(name){
app.group.remove(name, function(error, data){ app.group.remove(name, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $('#groups-list'), 'danger');
$.scope.LocalGroup.remove(name); $.scope.LocalGroup.remove(name);
loadGroups();
}); });
} }
function removeMember(group, username){ function removeMember(group, username){
app.group.removeMember(group, username, function(error, data){ app.group.removeMember(group, username, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $('#groups-list'), 'danger');
// websocket update echoes the new member list. loadGroups();
}); });
} }
@@ -47,17 +56,14 @@
let username = ($input.val() || '').trim(); let username = ($input.val() || '').trim();
if(!username) return; if(!username) return;
app.group.addMember(group, username, function(error, data){ app.group.addMember(group, username, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $('#groups-list'), 'danger');
$input.val(''); $input.val('');
loadGroups();
}); });
} }
$(document).ready(function(){ $(document).ready(function(){
app.group.list(function(error, data){ loadGroups();
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
for(let g of data.results) $.scope.LocalGroup.push(g);
});
loadUserSuggestions(); loadUserSuggestions();
$.scope.LocalGroup.__take = function($el){ $.scope.LocalGroup.__take = function($el){
@@ -66,75 +72,73 @@
}; };
app.subscribe(/^model:LocalGroup:create/, function(data){ app.subscribe(/^model:LocalGroup:create/, function(data){
$.scope.LocalGroup.remove(data.name); loadGroups();
$.scope.LocalGroup.unshift(data);
}); });
app.subscribe(/^model:LocalGroup:update/, function(data, topic){ app.subscribe(/^model:LocalGroup:update/, function(data, topic){
$.scope.LocalGroup.update(topic.split(':')[3], data); loadGroups();
}); });
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){ app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
$.scope.LocalGroup.remove(topic.split(':')[3]); loadGroups();
}); });
}); });
</script> </script>
<div class="container mt-4">
<datalist id="groupUsers"></datalist> <datalist id="groupUsers"></datalist>
<div class="row" style="display:none"> <div class="row">
<div class="col-md-4"> <div class="col-md-4 mb-4">
<div class="card shadow-lg"> <div class="card shadow">
<div class="card-header text-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span> <div><i class="fa-solid fa-users-gear me-2"></i><strong>Add Group</strong></div>
<span class="card-title">Add Group</span> <a href="/docs/access" class="text-reset" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
<form action="group/" onsubmit="formAJAX(this)"> <form action="group/" onsubmit="formAJAX(this)" evalAJAX="loadGroups(); this.reset();">
<div class="form-group"> <div class="mb-3">
<label class="control-label">Group name</label> <label class="form-label fw-bold">Group name</label>
<input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" /> <input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" required />
<div class="text-muted" style="font-size:.8rem"> <div class="form-text">
Lowercased to a slug. Use the name as a Subject (type "group") Lowercased to a slug. Use the name as a Subject (type "group") on the Permissions page.
on the Permissions page.
</div> </div>
</div> </div>
<hr /> <hr />
<button type="submit" class="btn btn-info">Add Group</button> <button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-plus me-1"></i> Add Group</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-8"> <div class="col-md-8 mb-4">
<div class="row row-cols-1 g-3"> <div class="row row-cols-1 g-3" id="groups-list">
<div jq-repeat="LocalGroup" jq-repeat-index="name" style="display:none" class="col"> <div jq-repeat="LocalGroup" jq-repeat-index="name" style="display:none" class="col">
<div class="card shadow-lg"> <div class="card shadow-sm border">
<div class="card-header d-flex align-items-center"> <div class="card-header d-flex align-items-center">
<span class="card-icon me-2"><i class="fa-solid fa-users"></i></span> <span class="card-icon me-2"><i class="fa-solid fa-users text-primary"></i></span>
<span class="card-title">{{ name }}</span> <strong class="me-2">{{ name }}</strong>
<span class="badge text-bg-secondary ms-2">{{ memberCount }} member(s)</span> <span class="badge text-bg-secondary">{{ memberCount }} member(s)</span>
<button type="button" class="btn btn-sm btn-outline-danger ms-auto" onclick="removeGroup('{{name}}')"> <button type="button" class="btn btn-sm btn-outline-danger ms-auto" onclick="removeGroup('{{name}}')">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
</button> </button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-2"> <div class="mb-3">
{{#memberList}} {{#memberList}}
<span class="badge text-bg-info member-pill me-1 mb-1 fs-6"> <span class="badge text-bg-info member-pill me-1 mb-1 fs-6">
{{ username }} {{ username }}
<i class="fa-solid fa-xmark ms-1" onclick="removeMember('{{group}}','{{username}}')"></i> <i class="fa-solid fa-xmark ms-1 text-danger" onclick="removeMember('{{group}}','{{username}}')"></i>
</span> </span>
{{/memberList}} {{/memberList}}
{{^memberList}} {{^memberList}}
<span class="text-muted">No members yet.</span> <span class="text-muted small">No members yet.</span>
{{/memberList}} {{/memberList}}
</div> </div>
<div class="input-group member-add" data-group="{{name}}"> <div class="input-group input-group-sm member-add" data-group="{{name}}">
<input type="text" class="form-control" list="groupUsers" placeholder="username" autocomplete="off" <input type="text" class="form-control" list="groupUsers" placeholder="Add username..." autocomplete="off"
onkeydown="if(event.key==='Enter'){event.preventDefault();addMember(this.nextElementSibling);}" /> onkeydown="if(event.key==='Enter'){event.preventDefault();addMember(this.nextElementSibling);}" />
<button type="button" class="btn btn-success" onclick="addMember(this)"> <button type="button" class="btn btn-success" onclick="addMember(this)">
<i class="fa-solid fa-user-plus"></i> Add <i class="fa-solid fa-user-plus me-1"></i> Add
</button> </button>
</div> </div>
</div> </div>
@@ -143,4 +147,5 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('bottom') %> <%- include('bottom') %>
+490 -478
View File
File diff suppressed because it is too large Load Diff
+183 -116
View File
@@ -4,21 +4,25 @@
app.auth.forceLogin(); app.auth.forceLogin();
</script> </script>
<style type="text/css">
label.control-label{
font-weight: bold;
margin-bottom: 1px;
}
.card-title{
font-weight: bold;
}
.field-hint{
font-size: .8rem;
}
</style>
<script type="text/javascript"> <script type="text/javascript">
// Kept alongside the rendered scope so the Edit modal can pre-fill from the
// record without a second round-trip ($.scope exposes push/empty/remove,
// not a lookup by key).
var permissionsById = {};
function loadPermissions() {
app.permission.list(function(error, data){
if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
permissionsById = {};
$.scope.Permission.empty();
for(let p of (data.results || [])){
permissionsById[p.id] = p;
$.scope.Permission.push(p);
}
});
}
// Fill the username/group datalists that back the Subject autocomplete. // Fill the username/group datalists that back the Subject autocomplete.
function loadSubjectSuggestions(){ function loadSubjectSuggestions(){
app.permission.subjects(function(error, data){ app.permission.subjects(function(error, data){
@@ -40,21 +44,133 @@
} }
} }
function permissionAddOpen(){
app.modal.open({title: 'Add Permission', bodyHtml:
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close(); loadPermissions();">'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Subject type</label>'
+ '<select class="form-select" name="subjectType" onchange="subjectTypeChanged(this)">'
+ '<option value="user">User</option>'
+ '<option value="group">Group</option>'
+ '</select>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Subject (username or group)</label>'
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Scope</label>'
+ '<select class="form-select" name="scope">'
+ '<option value="domain">Domain</option>'
+ '<option value="global">Global</option>'
+ '</select>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Domain (for domain scope)</label>'
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
+ '<div class="form-text text-muted">'
+ 'Wildcards: <code>*.example.com</code> matches one label, '
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
+ '<code>**</code> matches every domain.'
+ '</div>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Role</label>'
+ '<select class="form-select" name="role">'
+ '<option value="viewer">Viewer (read)</option>'
+ '<option value="manager">Manager (full over domain)</option>'
+ '<option value="admin">Admin (global only)</option>'
+ '</select>'
+ '</div>'
+ '<hr />'
+ '<div class="d-flex justify-content-end gap-2">'
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
+ '<button type="submit" class="btn btn-primary">Add Permission</button>'
+ '</div>'
+ '</form>',
});
}
// Edit an existing grant. A permission's id is derived from
// (subjectType, subject, scope, domain), so changing any of those replaces
// the record rather than updating it -- the API handles that and removes the
// superseded grant, which is why this is a single Edit rather than making
// the operator delete and re-add (and risk leaving the old grant in place).
function permissionEditOpen(id){
var p = permissionsById[id] || {};
function sel(v, want){ return v === want ? ' selected' : ''; }
app.modal.open({title: 'Edit Permission', bodyHtml:
'<div class="mb-3">'
+ '<label class="form-label fw-bold">Subject type</label>'
+ '<select class="form-select" id="perm-edit-subjectType">'
+ '<option value="user"' + sel(p.subjectType, 'user') + '>User</option>'
+ '<option value="group"' + sel(p.subjectType, 'group') + '>Group</option>'
+ '</select>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Subject (username or group)</label>'
+ '<input type="text" class="form-control" id="perm-edit-subject" list="subjectUsers" autocomplete="off" value="' + app.util.escapeHtml(p.subject || '') + '" />'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Scope</label>'
+ '<select class="form-select" id="perm-edit-scope">'
+ '<option value="domain"' + sel(p.scope, 'domain') + '>Domain</option>'
+ '<option value="global"' + sel(p.scope, 'global') + '>Global</option>'
+ '</select>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Domain (for domain scope)</label>'
+ '<input type="text" class="form-control" id="perm-edit-domain" autocomplete="off" value="' + app.util.escapeHtml(p.domain || '') + '" />'
+ '<div class="form-text text-muted">'
+ 'Wildcards: <code>*.example.com</code> matches one label, '
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
+ '<code>**</code> matches every domain.'
+ '</div>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Role</label>'
+ '<select class="form-select" id="perm-edit-role">'
+ '<option value="viewer"' + sel(p.role, 'viewer') + '>Viewer (read)</option>'
+ '<option value="manager"' + sel(p.role, 'manager') + '>Manager (full over domain)</option>'
+ '<option value="admin"' + sel(p.role, 'admin') + '>Admin (global only)</option>'
+ '</select>'
+ '</div>'
+ '<hr />'
+ '<div class="d-flex justify-content-end gap-2">'
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
+ '<button type="button" class="btn btn-primary" onclick="permissionEditSave(\'' + id + '\')">Save changes</button>'
+ '</div>',
});
}
function permissionEditSave(id){
var payload = {
subjectType: $('#perm-edit-subjectType').val(),
subject: ($('#perm-edit-subject').val() || '').trim(),
scope: $('#perm-edit-scope').val(),
domain: ($('#perm-edit-domain').val() || '').trim(),
role: $('#perm-edit-role').val(),
};
if(!payload.subject){
return app.messages.toast('Subject is required', 'warning');
}
app.permission.update(id, payload, function(error, data){
if(error) return app.messages.action((data && data.message) || error, $('#permissions-list'), 'danger');
app.modal.close();
loadPermissions();
});
}
function removePermission(id){ function removePermission(id){
app.permission.remove(id, function(error, data){ app.permission.remove(id, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger'); if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
// The websocket echo removes the row; drop it locally too for snappiness.
$.scope.Permission.remove(id); $.scope.Permission.remove(id);
loadPermissions();
}); });
} }
$(document).ready(function(){ $(document).ready(function(){
// Existing permissions. loadPermissions();
app.permission.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger');
for(let p of data.results) $.scope.Permission.push(p);
});
loadSubjectSuggestions(); loadSubjectSuggestions();
$.scope.Permission.__take = function($el, item, list){ $.scope.Permission.__take = function($el, item, list){
@@ -64,113 +180,64 @@
// Live updates (model:Permission:*), so adds/removes reflect for everyone. // Live updates (model:Permission:*), so adds/removes reflect for everyone.
app.subscribe(/^model:Permission:create/, function(data){ app.subscribe(/^model:Permission:create/, function(data){
$.scope.Permission.remove(data.id); loadPermissions();
$.scope.Permission.unshift(data);
}); });
app.subscribe(/^model:Permission:remove/, function(data, topic){ app.subscribe(/^model:Permission:remove/, function(data, topic){
$.scope.Permission.remove(topic.split(':')[3]); loadPermissions();
}); });
}); });
</script> </script>
<div class="container mt-4">
<datalist id="subjectUsers"></datalist> <datalist id="subjectUsers"></datalist>
<datalist id="subjectGroups"></datalist> <datalist id="subjectGroups"></datalist>
<div class="row" style="display:none"> <div class="row">
<div class="col-md-4"> <div class="col-12">
<div class="card shadow-lg"> <div class="card shadow">
<div class="card-header text-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span class="card-icon float-start"> <div>
<i class="fa-solid fa-user-shield"></i> <i class="fa-solid fa-user-shield me-2"></i><strong>Permissions List</strong>
</span>
<span class="card-title">Add Permission</span>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div>
<div class="card-header actionMessage" style="display:none"></div> <a href="/docs/access" class="text-reset me-3" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<div class="card-body"> <button type="button" class="btn btn-sm btn-primary" onclick="permissionAddOpen()">
<form action="permission/" onsubmit="formAJAX(this)"> <i class="fa-solid fa-user-shield me-1"></i> Add Permission
<div class="form-group">
<label class="control-label">Subject type</label>
<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Subject (username or group)</label>
<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />
</div>
<div class="form-group">
<label class="control-label">Scope</label>
<select class="form-control" name="scope">
<option value="domain">Domain</option>
<option value="global">Global</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Domain (for domain scope)</label>
<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />
<div class="field-hint text-muted">
Wildcards: <code>*.example.com</code> matches one label,
<code>**.example.com</code> matches any depth (incl. the apex),
<code>**</code> matches every domain.
</div>
</div>
<div class="form-group">
<label class="control-label">Role</label>
<select class="form-control" name="role">
<option value="viewer">Viewer (read)</option>
<option value="manager">Manager (full over domain)</option>
<option value="admin">Admin (global only)</option>
</select>
</div>
<hr />
<button type="submit" class="btn btn-info">Add Permission</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-list-check"></i>
</span>
<span class="card-title">Permissions</span>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Type</th>
<th>Subject</th>
<th>Scope</th>
<th>Domain</th>
<th>Role</th>
<th>Delete</th>
</thead>
<tbody>
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none">
<td class="align-middle">{{ subjectType }}</td>
<td class="align-middle">{{ subject }}</td>
<td class="align-middle">{{ scope }}</td>
<td class="align-middle">{{ domain }}</td>
<td class="align-middle">{{ role }}</td>
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash"></i>
Delete
</button> </button>
</td> </div>
</tr> </div>
</tbody>
</table> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body p-0">
<ul class="list-group list-group-flush" id="permissions-list">
<li class="list-group-item d-flex align-items-center justify-content-between py-3" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<div class="d-flex align-items-center">
<div class="me-3 fs-4 text-primary">
<i class="fa-solid fa-user-check"></i>
</div>
<div>
<h6 class="mb-1 fw-bold">
<span class="badge bg-secondary me-2">{{ subjectType }}</span> {{ subject }}
</h6>
<div class="small text-muted">
<span class="me-3"><strong>Scope:</strong> {{ scope }}</span>
<span class="me-3"><strong>Domain:</strong> <code>{{ domain }}</code></span>
<span><strong>Role:</strong> <span class="badge bg-info text-dark">{{ role }}</span></span>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="permissionEditOpen('{{id}}')">
<i class="fa-solid fa-pen me-1"></i> Edit
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash me-1"></i> Delete
</button>
</div>
</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
+133 -74
View File
@@ -63,12 +63,13 @@
$(document).ready(function(){ $(document).ready(function(){
app.api.get('user/me', function(error, data){ app.api.get('user/me', function(error, data){
if(error) return app.util.actionMessage(error, $('#profile-card'), 'danger'); if(error) return app.messages.action(error, $('#profile-card'), 'danger');
renderProfile(data); renderProfile(data);
}); });
}); });
</script> </script>
<div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-lg" id="profile-card"> <div class="card shadow-lg" id="profile-card">
@@ -102,43 +103,33 @@
</div> </div>
</div> </div>
<!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-key"></i> API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>
<div class="input-group">
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" onclick="copyToken()" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer &lt;token&gt;</code></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<script type="text/javascript"> <script type="text/javascript">
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var tokensById = {}; var tokensById = {};
function showSecret(secret){ // Shared "reveal secret once" display -- same pattern as jump-host's
document.getElementById('secretValue').value = secret; // showToken(), which sso-manager-node also uses.
secretModal.show(); function showToken(title, token){
app.modal.open({title: title, bodyHtml:
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.util.escapeHtml(token) + '">'
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></div>'
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
});
} }
function copyToken(){ // Not the checkmark-flash technique some of this codebase's other copy
var el = document.getElementById('secretValue'); // buttons use -- FontAwesome replaces <i> icons with inline <svg>, so
el.select(); el.setSelectionRange(0, 99999); // swapping the <i>'s class silently no-ops. A toast doesn't have that
try { document.execCommand('copy'); } catch(_){} // problem.
function copyFieldValue(sel){
var $el = $(sel);
var text = $el.val();
if(!text) return;
navigator.clipboard.writeText(text).then(function(){
app.messages.toast('Copied to clipboard', 'success');
}, function(){
app.messages.toast('Could not copy — select and copy manually', 'danger');
});
} }
function fmtTime(ms){ function fmtTime(ms){
@@ -170,70 +161,134 @@
function tableAJAX(){ function tableAJAX(){
app.apiToken.list(function(error, data){ app.apiToken.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.apiTokenCard.$this, 'danger'); if(error) return app.messages.action(error, $.scope.apiTokenCard.$this, 'danger');
var tokens = data.results || [];
$.scope.apiTokenCard.empty(); $.scope.apiTokenCard.empty();
(data.results || []).forEach(function(token){ tokens.forEach(function(token){
$.scope.apiTokenCard.push(processToken(token)); $.scope.apiTokenCard.push(processToken(token));
}); });
$('#api-tokens-empty').toggle(tokens.length === 0);
}); });
} }
function revokeToken(id, name, btn){ async function revokeToken(id, name, btn){
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return; const ok = await app.messages.confirm('Revoke API token "' + name + '"? It stops working immediately.', $(btn).closest('.card'), 'danger');
if(!ok) return;
app.apiToken.remove({id: id}, function(error, data){ app.apiToken.remove({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger'); if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
$.scope.apiTokenCard.remove('id', id); $.scope.apiTokenCard.remove('id', id);
}); });
} }
function rotateToken(id, name, btn){ async function rotateToken(id, name, btn){
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return; const ok = await app.messages.confirm('Rotate API token "' + name + '"? The old token stops working immediately.', $(btn).closest('.card'), 'warning');
if(!ok) return;
app.apiToken.rotate({id: id}, function(error, data){ app.apiToken.rotate({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger'); if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
showSecret(data.token); showToken('API Token Rotated', data.token);
tableAJAX();
});
}
// Create is a native <form>+formAJAX submission (matching this app's own
// hostModal convention) rather than a JS-built payload -- the form now
// lives inside app.modal's body, rebuilt fresh on every open(), so
// .actionMessage must be a descendant of the form (not a sibling, as the
// old static create-form card had it) for formAJAX's error/success
// targeting to resolve correctly (it falls back to searching descendants
// of the form once app.modal's card-less .modal-content fails the
// closest('div.card') check).
function createApiToken(){
var $body = app.modal.open({
title: 'New API Token',
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>'
// Deliberately does NOT call app.modal.close() before showToken() --
// app.modal is a singleton, and close() immediately followed by
// open() in the same synchronous tick collides with Bootstrap's
// hide-transition guard (show() silently no-ops while _isTransitioning
// is still true from the just-started hide()). open() alone already
// overwrites the (already-visible) modal's content in place.
+ '<form id="newTokenForm" action="api-token/" method="post" onsubmit="formAJAX(this)" evalAJAX="showToken(\'API Token Created\', data.token); tableAJAX();">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" name="name" placeholder="CI host sync" required>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" name="description" placeholder="Used by the nightly sync job">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>'
+ '<input type="number" class="form-control" name="expires_in_days" value="0" min="0">'
+ '</div>'
+ '</form>',
footer: {
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
+ '<button type="submit" form="newTokenForm" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>',
},
});
$body.find('[name=name]').focus();
}
function editToken(id){
var t = tokensById[id]; if(!t) return;
app.modal.open({
title: 'Edit Token',
bodyHtml:
'<input type="hidden" id="edit-token-id" value="' + app.util.escapeHtml(id) + '">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" id="edit-token-name" value="' + app.util.escapeHtml(t.name || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" id="edit-token-description" value="' + app.util.escapeHtml(t.description || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days, blank = keep as-is, 0 = never)</label>'
+ '<input type="number" class="form-control" id="edit-token-days" min="0">'
+ '</div>',
footer: {
metaHtml: 'Created by ' + app.util.escapeHtml(t.created_by || '—') + ' on ' + fmtTime(t.created_on),
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
},
});
}
function saveEditToken(){
var payload = {
id: $('#edit-token-id').val(),
name: $('#edit-token-name').val(),
description: $('#edit-token-description').val(),
expires_in_days: $('#edit-token-days').val(),
};
app.apiToken.update(payload, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to update token', app.modal.body(), 'danger');
app.modal.close();
tableAJAX(); tableAJAX();
}); });
} }
$(document).ready(function(){ $(document).ready(function(){
tableAJAX(); tableAJAX();
// After a successful create, show the raw token once + refresh the list.
$('form[action="api-token/"]').attr('evalAJAX',
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
);
}); });
</script> </script>
<div class="row mt-3"> <div class="row mt-3 justify-content-center">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
<a href="/docs/api-tokens" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<p class="text-muted small">A personal access token lets scripts and services call the proxy management API as you, with your permissions. Treat it like a password.</p>
<form action="api-token/" method="post" onsubmit="formAJAX(this)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" placeholder="CI host sync" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="description" placeholder="Used by the nightly sync job">
</div>
<div class="mb-3">
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
<input type="number" class="form-control" name="expires_in_days" value="0" min="0">
</div>
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>
</form>
</div>
</div>
</div>
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-lg">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
<span>
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<p class="text-muted small px-3 pt-3 mb-0">A personal access token lets scripts and services call the proxy management API as you, with your permissions. Treat it like a password.</p>
<div class="card-body">
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3"> <div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
<div class="card-header"> <div class="card-header">
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5> <h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
@@ -254,10 +309,14 @@
</dl> </dl>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button> <button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button> <button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
<%- include('bottom') %> <%- include('bottom') %>
+13 -3
View File
@@ -21,9 +21,11 @@
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script> <script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script> <script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script> <script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script> <script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script> <script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script> <script type="text/javascript" src="/static/js/app.js"></script>
</head> </head>
<body> <body>
@@ -47,7 +49,7 @@
</ul> </ul>
<div class="form-inline mt-2 mt-md-0"> <div class="form-inline mt-2 mt-md-0">
<% if(ui.profileUrl){ %> <% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;"> <a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span> <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a> </a>
<% } else { %> <% } else { %>
@@ -80,16 +82,24 @@
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
// --sw-content-offset tracks the same height as #spa-shell's margin-top
// (fixed navbar, plus the update banner while it's shown), so any
// in-page sticky element (e.g. a sticky search/sort bar) can offset
// itself below both fixed elements via `top: var(--sw-content-offset)`
// instead of colliding with them at the viewport's true top:0.
function showUpdateBanner(){ function showUpdateBanner(){
let $nav = $('nav.fixed-top'); let $nav = $('nav.fixed-top');
let $banner = $('#update-banner'); let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show(); $banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px'); let offset = $nav.outerHeight() + $banner.outerHeight();
$('#spa-shell').css('margin-top', offset + 'px');
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
} }
function dismissUpdateBanner(){ function dismissUpdateBanner(){
$('#update-banner').hide(); $('#update-banner').hide();
$('#spa-shell').css('margin-top', ''); $('#spa-shell').css('margin-top', '');
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
sessionStorage.setItem('update-banner-dismissed', '1'); sessionStorage.setItem('update-banner-dismissed', '1');
} }
+66 -68
View File
@@ -20,11 +20,16 @@
<script type="text/javascript"> <script type="text/javascript">
function processUser(user){
user.isExternal = user.backing === 'oidc';
return user;
}
function populateUsers(actionMessage){ function populateUsers(actionMessage){
app.user.list(function(error, data){ app.user.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.users.$this, 'danger'); if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
for(let user of data.results){ for(let user of data.results){
$.scope.users.push(user); $.scope.users.push(processUser(user));
} }
$.scope.users.__put = function($el, item, list){ $.scope.users.__put = function($el, item, list){
$el.addClass('bg-success'); $el.addClass('bg-success');
@@ -37,11 +42,37 @@
function removeUser(username){ function removeUser(username){
app.user.remove({username: username}, function(error, data){ app.user.remove({username: username}, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.users.$this, 'danger'); if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
$.scope.users.remove(username); $.scope.users.remove(username);
}); });
} }
function userAddOpen(){
app.modal.open({title: 'Add New User', bodyHtml:
'<form action="user/" onsubmit="formAJAX(this)" evalAJAX="'
+ '$.scope.users.splice(0, 0, processUser(data));'
+ 'setTimeout(function(){ app.util.revealItem($(\'#user-row-\' + data.username)); }, 100);'
+ 'app.modal.close();'
+ '">'
+ '<input type="hidden" class="form-control" name="delete" value="false" />'
+ '<div class="form-group">'
+ '<label class="control-label">User-name</label>'
+ '<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Password</label>'
+ '<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Again</label>'
+ '<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>'
+ '</div>'
+ '<hr />'
+ '<button type="submit" class="btn btn-info">Add</button>'
+ '</form>',
});
}
$(document).ready(function(){ $(document).ready(function(){
populateUsers(); //populate the table populateUsers(); //populate the table
@@ -54,50 +85,9 @@
}); });
</script> </script>
<div class="container mt-4">
<div class="row" style="display:none"> <div class="row" style="display:none">
<div class="col-md-4"> <div class="col-12">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-user-plus"></i>
</span>
<span class="card-title">
Add New User
</span>
<span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="user/" onsubmit="formAJAX(this)" evalAJAX="
$.scope.users.splice(0, 0, data);
">
<input type="hidden" class="form-control" name="delete" value="false" />
<div class="form-group">
<label class="control-label">User-name</label>
<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />
</div>
<div class="form-group">
<label class="control-label">Password</label>
<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>
</div>
<div class="form-group">
<label class="control-label">Again</label>
<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>
</div>
<hr />
<button type="submit" class="btn btn-info">
Add
</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header text-center"> <div class="card-header text-center">
@@ -109,40 +99,48 @@
</span> </span>
<span class="float-end"> <span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a> <a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-circle-minus"></i> <button type="button" class="btn btn-sm btn-success" onclick="userAddOpen()">
<i class="fa-solid fa-user-plus"></i>
Add User
</button>
</span> </span>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive"> <div class="card-body">
<table class="card-body table table-striped" style="margin-bottom:0"> <div class="row row-cols-1 row-cols-lg-2 g-3" id="user-cards">
<thead> <div class="col" jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
<th>Name</th> <div class="card shadow-sm h-100">
<th>Password</th> <div class="card-body">
<th>Delete</th> <h6 class="d-flex align-items-center mb-2">
</thead> <i class="fa-solid fa-user me-2"></i>
<tbody>
<tr jq-repeat="users" jq-repeat-index="username" style="display:none" >
<td class="align-middle">
{{ username }} {{ username }}
</td> {{#isExternal}}
<td> <span class="badge text-bg-secondary ms-2" title="Provisioned via SSO login; no local password to manage here.">
<i class="fa-solid fa-cloud"></i> External (SSO)
</span>
{{/isExternal}}
</h6>
<form class="input-group" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)"> {{^isExternal}}
<input type="password" name="password" class="form-control" placeholder="Change {{ username }} password" aria-label="Update password"> <form class="input-group input-group-sm mb-2" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
<input type="password" name="password" class="form-control" placeholder="Change password" aria-label="Update password">
<button class="btn btn-warning" type="submit">Change</button> <button class="btn btn-warning" type="submit">Change</button>
</form> </form>
{{/isExternal}}
{{#isExternal}}
<p class="text-muted small mb-2">Authenticates via SSO -- cannot be edited here.</p>
{{/isExternal}}
</td> <button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removeUser('{{username}}')">
<i class="fa-solid fa-user-slash"></i> <i class="fa-solid fa-user-slash"></i>
Delete Delete
</button> </button>
</td> </div>
</tr> </div>
</tbody> </div>
</table> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
+12
View File
@@ -42,6 +42,18 @@ module.exports = {
usernameClaim: 'preferred_username', usernameClaim: 'preferred_username',
}, },
// Read-only access to the SSO's management API, used to populate the
// per-host SSO allow-list autocomplete with the directory's actual groups.
// A host gated on SSO matches its allow-list against the `groups` claim the
// SSO issues, so only SSO groups can ever match -- without this the field
// can only suggest the proxy's own local groups. `apiToken` is a machine
// token minted by theta-suite's bootstrap; leaving it blank simply falls
// back to local-only suggestions.
sso: {
url: 'http://sso-manager:3001',
apiToken: '',
},
// Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a // Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a
// self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path // self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path
// for strict verification. bindPassword MUST match the // for strict verification. bindPassword MUST match the