Compare commits

...

38 Commits

Author SHA1 Message Date
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
wmantly 75045b4b33 Merge pull request #178 from theta42/feature/ui-unification
Release 1.4.0: unified front-end UI shell
2026-07-26 00:30:08 -04:00
wmantly 2e959f7c48 Release 1.4.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly fcc09d1e4b logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:08 -04:00
wmantly a83a5fd39a app.api.delete: accept the (url, data, callback) form formAJAX uses; defer the login-card reveal to DOM ready
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (the host/DNS delete buttons) landed its callback in
the data slot and never ran.

The login page's "reveal the card once we know you're logged out" branch
touched an element further down the same page, which threw when
isLoggedIn answered before the parser got there (it always did without a
stored token). It now runs on DOM ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly 3a307c1563 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

proxy specifics:
- .group-required base rule added to styles.css; the admin nav items lost
  their inline display:none in favour of it.
- The brand link points at / instead of #.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly f055d4f567 Merge pull request #177 from theta42/release/v1.3.0
Release 1.3.0
2026-07-25 16:38:47 -04:00
26 changed files with 1469 additions and 1042 deletions
+85
View File
@@ -6,6 +6,91 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [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
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Added
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
- Admin-only nav items lost their inline `display: none` in favour of that class, and the brand link points at `/` instead of `#`.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
## [1.3.0] - 2026-07-25
### Added
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

+5
View File
@@ -69,6 +69,11 @@ app.use(express.json());
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
// Set as an app local so every res.render has it, including routes that don't
// spread the routers' `values` object.
app.locals.ui = require('./utils/ui');
// Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to
// the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the
// per-host session cookie. Mounted before the page router.
+2 -1
View File
@@ -49,7 +49,8 @@ module.exports = {
// Per-user overrides are Grant records managed in the app.
auth: {
// 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.
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
+13
View File
@@ -9,10 +9,23 @@ const tldExtract = require('tld-extract').parse_host;
const LetsEncrypt = require('../utils/letsencrypt');
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({
directoryUrl: conf.environment === "production" ?
LetsEncrypt.AcmeClient.directory.letsencrypt.production :
LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
accountKeyPath,
});
class Host extends Table{
+85 -7
View File
@@ -1,18 +1,19 @@
{
"name": "proxy-api",
"version": "1.2.2",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxy-api",
"version": "1.2.2",
"version": "1.7.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0",
@@ -20,7 +21,7 @@
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^6.0.1",
"ejs": "^3.1.10",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"extend": "^3.0.2",
@@ -308,6 +309,15 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
@@ -436,6 +446,12 @@
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -832,15 +848,18 @@
"license": "MIT"
},
"node_modules/ejs": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.12.18"
"node": ">=0.10.0"
}
},
"node_modules/encodeurl": {
@@ -1052,6 +1071,42 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"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": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1428,6 +1483,23 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"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": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
@@ -1734,6 +1806,12 @@
"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": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "proxy-api",
"version": "1.3.0",
"version": "1.9.0",
"author": [
{
"name": "William Mantly",
@@ -10,10 +10,10 @@
"scripts": {
"start": "node ./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: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": "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/no_native_dialogs.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": {
"node": ">=18.0.0"
@@ -21,8 +21,9 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0",
@@ -30,7 +31,7 @@
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^6.0.1",
"ejs": "^3.1.10",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"extend": "^3.0.2",
+10
View File
@@ -7,6 +7,12 @@ body {
display: flex;
flex-direction: column;
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 {
@@ -18,3 +24,7 @@ body {
.card-title{
font-weight: bold;
}
.group-required{
display: none;
}
+69
View File
@@ -91,3 +91,72 @@ app.apiToken = (function(app){
return {list, get, add, update, remove, rotate};
})(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 );
},
}
});
})();
+282 -107
View File
@@ -1,3 +1,12 @@
// Shared client framework for the theta42 apps.
//
// This file is byte-identical across sso-manager-node, proxy and jump-host —
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
// the /api/user/me response), never from edits to this file. Edit all three
// copies together.
//
// jQuery 4 safe: no $.isFunction, no $.holdReady.
var app = {};
app.pubsub = (function(){
@@ -45,7 +54,7 @@ app.pubsub = (function(){
app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket;
$(document).ready(function(){
socket = io({
@@ -75,10 +84,26 @@ app.socket = (function(app){
app.api = (function(app){
var baseURL = '/api/'
function post(url, data, callback){
if(typeof callback !== 'function') callback = callback2;
// post/put/delete are dual-mode: pass a callback for the node-style
// (error, data, status) form, or omit it to get a Promise that resolves
// with the parsed body and rejects with the error body. get/options return
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
function body(method, url, data, callback){
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: method,
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: 'POST',
type: method,
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
@@ -87,40 +112,44 @@ app.api = (function(app){
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
);
}
});
}
function post(url, data, callback){
return body('POST', url, data, callback);
}
function put(url, data, callback){
if(typeof callback !== 'function') callback = callback2;
return $.ajax({
type: 'PUT',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
}
});
return body('PUT', url, data, callback);
}
function remove(url, callback, callback2){
if(typeof callback !== 'function') callback = callback2;
// Called both as (url, callback) and — from formAJAX, which always passes
// the serialized form as the second argument — as (url, data, callback).
// No request body is sent either way.
function remove(url, data, callback){
if(typeof data === 'function'){
callback = data;
data = undefined;
}
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: 'DELETE',
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: 'delete',
type: 'DELETE',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
@@ -128,11 +157,11 @@ app.api = (function(app){
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
);
}
});
}
@@ -179,7 +208,11 @@ app.api = (function(app){
})(app)
app.auth = (function(app){
var user = {}
// One in-flight/cached GET /api/user/me per page load. Every gating
// decision (nav items, per-view forceLogin, group-required elements) reads
// this same promise instead of re-fetching.
var userPromise = null;
function setToken(token){
localStorage.setItem('APIToken', token);
}
@@ -188,18 +221,95 @@ app.auth = (function(app){
return localStorage.getItem('APIToken');
}
function isLoggedIn(callback){
if(getToken()){
return app.api.get('user/me', function(error, data){
// data now carries effective rights (isAdmin, global, domains).
if(!error) app.auth.user = app.auth.perms = data;
return callback(error, data);
});
}else{
callback(null, false);
async function getUser(){
try{
return await app.api.get('user/me');
}catch(error){
if(error && error.status === 401) return null;
throw error;
}
}
// Cached current user, or false when there's no token at all. Callers that
// need a fresh copy (after a login or a profile change) pass force.
function loadUser(force){
if(force || !userPromise){
userPromise = getToken() ? getUser() : Promise.resolve(null);
userPromise = userPromise.then(function(user){
app.auth.user = app.auth.perms = user || null;
return user;
});
}
return userPromise;
}
// The apps report group membership two ways: sso-manager-node returns LDAP
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
// is exposed as a synthetic `admin` group so one gating model covers both.
function groupCNs(user){
var raw = (user && (user.memberOf || user.groups)) || [];
if(!Array.isArray(raw)) raw = [raw];
var names = raw.map(function(group){
return String(group).split(',')[0].replace(/^cn=/i, '');
});
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
return names;
}
async function memberOf(groupNameToFind, user){
user = user || await loadUser();
if(!user) return false;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
return groupCNs(user).some(function(group){
return groupNameToFind.includes(group);
});
}
// True when the logged-in user is a global admin (per user/me). Sync — only
// meaningful once isLoggedIn/forceLogin has resolved.
function isAdmin(){
return !!(app.auth.perms && app.auth.perms.isAdmin);
}
// Dual-mode: returns a Promise resolving to the user (or false), and calls
// an optional node-style callback with the same result.
function isLoggedIn(callback){
var promise = loadUser().then(function(user){
return user || false;
});
if(typeof callback === 'function'){
promise.then(function(user){
callback(null, user);
}, function(error){
callback(error, false);
});
}
return promise;
}
function logIn(args, callback){
app.api.post('auth/login', args, function(error, data){
if(data.login){
setToken(data.token);
}
loadUser(true);
callback(error, !!data.token);
});
}
// Clears the session only — the caller decides where to go next (the nav's
// Log Out button uses ui.logoutRedirect).
function logOut(callback){
localStorage.removeItem('APIToken');
userPromise = null;
app.auth.user = app.auth.perms = null;
if(typeof callback === 'function') callback();
}
// Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
@@ -230,46 +340,68 @@ app.auth = (function(app){
return true;
}
// True when the logged-in user is a global admin (per user/me).
function isAdmin(){
return !!(app.auth.perms && app.auth.perms.isAdmin);
}
function logIn(args, callback){
app.api.post('auth/login', args, function(error, data){
if(data.login){
setToken(data.token);
}
callback(error, !!data.token);
});
}
function logOut(callback){
localStorage.removeItem('APIToken');
callback();
}
function forceLogin(){
// jQuery 4 removed $.holdReady; rely on the redirect below to keep an
// unauthenticated user off the page instead of pausing document ready.
app.auth.isLoggedIn(function(error, isLoggedIn){
if(error || !isLoggedIn){
app.auth.logOut(function(){})
location.replace(`/login${location.href.replace(location.origin, '')}`);
}
});
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
// unauthorised user is kept off the page by a redirect / an error panel
// rather than by pausing document ready.
//
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
// `admin` group covers the OIDC clients' isAdmin flag.
async function forceLogin(requiredGroups){
var user = await loadUser();
if(!user){
logOut(function(){});
location.replace('/login?redirect=' + encodeURIComponent(
location.pathname + location.search
));
return false;
}
if(user.onboardingRequired && location.pathname !== '/onboarding'){
location.replace('/onboarding');
return false;
}
if(requiredGroups && !await memberOf(requiredGroups, user)){
app.messages.action(
`<h1>
<i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b>
<i class="fa-solid fa-triangle-exclamation"></i>
</h1>`,
$('#spa-shell'),
'danger',
);
throw new Error("User does not have permission");
}
return user;
}
// Where to go after a successful login: the ?redirect= query param, or the
// legacy /login/<path> suffix form, constrained to a same-origin path. The
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
// is how the OIDC provider sends an unauthenticated user through login.
function logInRedirect(){
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
var params = new URLSearchParams(location.search);
var target = params.get('redirect')
|| location.href.replace(location.origin + '/login', '')
|| '/';
window.location.href = safeInternalPath(target);
}
return {
getToken: getToken,
setToken: setToken,
isLoggedIn: isLoggedIn,
consumeTokenFragment: consumeTokenFragment,
getUser: getUser,
loadUser: loadUser,
groupCNs: groupCNs,
memberOf: memberOf,
isAdmin: isAdmin,
isLoggedIn: isLoggedIn,
safeInternalPath: safeInternalPath,
consumeTokenFragment: consumeTokenFragment,
user: null,
perms: null,
logIn: logIn,
logOut: logOut,
@@ -279,6 +411,11 @@ app.auth = (function(app){
})(app);
// Back-compat alias for views that awaited the cached user directly.
Object.defineProperty(app.auth, 'asyncUser', {
get: function(){ return app.auth.loadUser(); },
});
app.user = (function(app){
function list(callback){
app.api.get('user/?detail=true', function(error, data){
@@ -308,6 +445,8 @@ app.user = (function(app){
})(app);
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
// these endpoints; the calls are inert elsewhere.
app.permission = (function(app){
function list(callback){
app.api.get('permission/', function(error, data){
@@ -381,29 +520,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
};
function actionMessage(message, $target, type, callback){
message = message || '';
$target = $target.closest('div.card').find('.actionMessage');
type = type || 'info';
callback = callback || function(){};
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);
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)
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
// app.util.escapeHtml and app.messages.action/confirm.
function escapeHtml(s){
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
$.fn.serializeObject = function() {
@@ -459,14 +584,65 @@ app.util = (function(app){
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 {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
actionMessage: actionMessage
escapeHtml: escapeHtml,
revealItem: revealItem,
}
})(app);
$( document ).ready(function(){
// Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
if(!groups.length) return;
var style = document.getElementById('group-required-rules');
if(!style){
style = document.createElement('style');
style.id = 'group-required-rules';
document.head.appendChild(style);
}
for(var group of groups){
try{
style.sheet.insertRule(
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// A group whose CN isn't a usable CSS identifier just gates nothing.
}
}
};
$( document ).ready(async function(){
// Show content the user's groups entitle them to.
app.auth.applyGroupVisibility(await app.auth.loadUser());
$('div.row').fadeIn('slow'); //show the page
//panel button's
@@ -487,9 +663,9 @@ $( document ).ready(function(){
$(this).closest('.card').slideUp('fast');
});
$('.actionMessage').on('click', 'button.action-close', function(event){
app.util.actionMessage(null, $(this));
});
// action-close click handling is wired by @simpleworkjs/frontend's
// app.messages.js (delegated on document, so it also covers messages
// rendered after this ready handler runs).
setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{
@@ -520,18 +696,17 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase();
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;
}
app.util.actionMessage(
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>',
$form,
'info'
);
// Plain text: app.messages.action HTML-escapes its message (by design,
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
// render literally instead of as an element.
app.messages.action('Saving…', $form, 'info');
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();
if(!error){
$form.trigger("reset");
@@ -539,7 +714,7 @@ function formAJAX(btn){
}else{
console.log('formAJAX res error', error, data)
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){
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)";
}
}
}
});
})();
+10 -1
View File
@@ -23,7 +23,7 @@ const values ={
// every deploy and isn't cache-busted/fingerprinted.
mountStaticModules(router, {
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) => {
@@ -40,6 +40,15 @@ router.get('/hosts', async function(req, res, next) {
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) {
res.render('dns', {...values});
});
+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){
try{
validatePassword(req.body.password);
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)});
}catch(error){
next(error);
@@ -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, []);
});
+45
View File
@@ -0,0 +1,45 @@
'use strict';
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
//
// Those two partials are byte-identical across sso-manager-node, proxy and
// jump-host — everything that differs between the apps lives here and is
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
// in sync across the three apps; a missing key is a render-time ReferenceError,
// not a silent fallback.
module.exports = {
// --- footer -------------------------------------------------------------
repoUrl: 'https://github.com/theta42/proxy',
licenseUrl: 'https://github.com/theta42/proxy/blob/master/LICENSE',
// In-app docs route (routes/docs.js). Apps without one point at the
// published docs site and set docsExternal.
docsUrl: '/docs',
docsExternal: false,
// Only sso-manager-node serves a Terms of Service page; null hides the link.
tosUrl: null,
// --- header / nav -------------------------------------------------------
faviconUrl: '/static/favicon.svg',
// Where the current-user chip links. null renders it as a plain span (for
// apps with no profile page).
profileUrl: '/profile',
// Where "Log Out" lands.
logoutRedirect: '/',
// Admin-only "a newer release is available" banner, backed by
// GET /api/update-check. Apps without that endpoint set false.
updateCheck: true,
updateLabel: 'the proxy',
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
// item; an empty list means "always visible". Gating is done client-side by
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
{href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []},
{href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: []},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', 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']},
],
};
+29 -23
View File
@@ -1,24 +1,30 @@
</div>
</div><!-- end spa-shell -->
<footer class="py-2 bg-dark text-light mt-4">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank">
<img width="64" src="/static/img/theta42.svg"/>
</a>
&copy; <%- buildYear %> theta42 &middot;
<a href="https://github.com/theta42/proxy/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="/docs" class="text-light text-decoration-none">
<i class="fa-solid fa-book"></i> Docs
</a>
<a href="https://github.com/theta42/proxy" target="_blank" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
</span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body>
</html>
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
app.locals in app.js). Edit all three copies together. -->
<footer class="py-2 bg-dark text-light mt-4">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank">
<img width="64" src="/static/img/theta42.svg"/>
</a>
&copy; <%- buildYear %> theta42 &middot;
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
<i class="fa-solid fa-book"></i> Docs
</a>
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<% if(ui.tosUrl){ %>
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
<% } %>
</span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body>
</html>
+2
View File
@@ -113,6 +113,7 @@
});
</script>
<div class="container mt-4">
<div class="row mb-3" style="display:none">
<div class="col-md-3">
<div class="card shadow-lg mb-3">
@@ -299,4 +300,5 @@
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+6 -4
View File
@@ -28,14 +28,14 @@
function removeGroup(name){
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, $.scope.LocalGroup.$this, 'danger');
$.scope.LocalGroup.remove(name);
});
}
function removeMember(group, username){
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, $.scope.LocalGroup.$this, 'danger');
// websocket update echoes the new member list.
});
}
@@ -47,14 +47,14 @@
let username = ($input.val() || '').trim();
if(!username) return;
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, $.scope.LocalGroup.$this, 'danger');
$input.val('');
});
}
$(document).ready(function(){
app.group.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
for(let g of data.results) $.scope.LocalGroup.push(g);
});
@@ -78,6 +78,7 @@
});
</script>
<div class="container mt-4">
<datalist id="groupUsers"></datalist>
<div class="row" style="display:none">
@@ -143,4 +144,5 @@
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+393 -381
View File
@@ -61,9 +61,9 @@
return host;
}
function hostPopulate(){
function hostPopulate(onLoaded){
app.api.get('host?detail=1&provider=1', function(error, res){
if(error) return app.util.actionMessage(error, $.scope.hosts.$this, 'danger');
if(error) return app.messages.action(error, $.scope.hosts.$this, 'danger');
for(let host of res.results){
$.scope.hosts.push(hostParseRow(host));
@@ -75,6 +75,8 @@
$el.removeClass('table-success');
});
};
if (onLoaded) onLoaded();
});
}
@@ -90,10 +92,341 @@
// ----- Add / Edit modal --------------------------------------------------
function hostModal(){
return bootstrap.Modal.getOrCreateInstance(document.getElementById('hostModal'));
function hostModalClose(){ app.modal.close(); }
// Built once; injected fresh into app.modal's body on every open() call
// (unlike the old always-in-DOM static modal). .actionMessage lives
// INSIDE the form (not a sibling, as it was before) because formAJAX's
// error/success target resolves via $form.closest('div.card') (which
// app.modal's plain, card-less .modal-content never matches) falling
// back to $form.find('.actionMessage') -- a descendant search.
var hostModalBodyHtml = `
<form id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
<div class="actionMessage m-0" style="display:none"></div>
<ul class="nav nav-tabs flex-nowrap overflow-x-auto" role="tablist">
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
</ul>
<div class="tab-content pt-3">
<!-- General -->
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
<div class="form-group">
<label for="host" class="form-label">Incoming host name</label>
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">
The public hostname clients request. Use <code>*.example.com</code>
for one subdomain level, <code>**.example.com</code> for any depth,
or <code>**</code> as a catch-all.
</small>
<small id="host-rename-help" class="field-help text-muted d-block" style="display:none">
Wildcard hosts, their children, and auto-created subdomain cache
entries can't be renamed here — the name is referenced elsewhere
(the wildcard's own children, or the cache entry's parent). Delete
and recreate instead.
</small>
</div>
<div class="form-group">
<label class="form-label">Incoming SSL</label>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
Force HTTPS <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-false" value="false">
Allow both HTTP and HTTPS
</label></div>
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
</div>
<hr>
<div class="form-group">
<label for="ip" class="form-label">Target IP or host name</label>
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10, app.internal.net, or sso-manager" validate="target:3" />
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only &mdash; no protocol, port, or path.</small>
</div>
<div class="row">
<div class="col form-group">
<label for="targetPort" class="form-label">Target TCP port</label>
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
<b class="invalid-feedback"></b>
</div>
<div class="col form-group">
<label class="form-label">Target SSL</label>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
Proxy to HTTP <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label></div>
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
</div>
</div>
<hr>
<div class="form-group">
<label for="targets" class="form-label">Additional Targets (Load Balancing)</label>
<textarea name="targets" class="form-control" rows="2" placeholder="10.0.0.2:8080&#10;10.0.0.3:8080"></textarea>
<small class="field-help text-muted d-block">Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included.</small>
</div>
</div>
<!-- TLS & Wildcard -->
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
<div class="form-group autoSll">
<label class="form-label">
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
</label>
<div class="radio" id="challengeType-HTTP-01-container"><label>
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
HTTP-01
</label></div>
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
DNS-01 Wildcard
</label></div>
<div class="radio challengeType-container" id="challengeType-child-container"><label>
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
</label></div>
<small class="field-help text-muted d-block">
Options light up based on the host name: wildcard certs need a DNS
provider for the domain; child hosts reuse a parent wildcard.
</small>
</div>
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
<label class="form-label">Wildcard matching</label>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
Match only subdomains defined here <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label></div>
<small class="field-help text-muted d-block">
"Recommended" only routes subdomains you've explicitly registered
as their own host (optionally as a "Parent Wildcard" child of this
one, to reuse this cert). "Match any" auto-creates a temporary
route to this host's target for <i>any</i> undefined subdomain the
first time it's requested — convenient, but it means every subdomain
typo or scan attempt also gets routed here.
</small>
</div>
</div>
<!-- Traffic -->
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label></div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
<hr>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label></div>
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label></div>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label></div>
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
</div>
</div>
<!-- Headers -->
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
</div>
</div>
<!-- Access -->
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
<h6 class="text-muted">IP access</h6>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
</div>
</div>
<!-- Authentication -->
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
<p class="field-help text-muted">
Pick one authentication method for this host &mdash; basic auth and
SSO can't both be enabled, to avoid ambiguity about which one
actually protected a request. Choose "Off" for a public host.
</p>
<div class="form-group">
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
Off (public)
</label></div>
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
Basic authentication
</label></div>
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
Single sign-on (SSO)
</label></div>
</div>
<!-- Actually-submitted fields; kept in sync with the radios above by
hostAuthModeChanged() so only one can be true at a time. -->
<input type="hidden" name="basicauth_enabled" id="basicauth_enabled-hidden" value="false">
<input type="hidden" name="sso_enabled" id="sso_enabled-hidden" value="false">
<div id="hostTab-auth-basicFields" style="display:none">
<hr>
<h6 class="text-muted">Basic authentication</h6>
<div class="form-group">
<label for="basicauth_realm" class="form-label">Realm</label>
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
</div>
<div class="form-group">
<label for="basicauth_users" class="form-label">Users</label>
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
<small class="field-help text-muted d-block">
Current: <span class="basicauth-current">none</span>.
Passwords are stored hashed and never shown here. Leave blank to keep
the current users; entering any lines replaces the whole list. To
manage individual users (delete / change password), use the table
below once the host has been saved.
</small>
</div>
</div>
<div id="hostTab-auth-ssoFields" style="display:none">
<hr>
<h6 class="text-muted">Single sign-on (SSO)</h6>
<p class="field-help text-muted">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</p>
<div class="form-group">
<label for="sso_allow_users" class="form-label">Allowed users</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
</div>
<div class="form-group">
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
</div>
</div>
<div id="hostTab-auth-basicUsersMgmt" style="display:none">
<hr>
<h6 class="text-muted">Manage basic-auth users</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead><tr><th>Username</th><th>New password</th><th></th></tr></thead>
<tbody id="basicAuthUserRows"></tbody>
</table>
</div>
</div>
</div>
</div>
<datalist id="hostSsoUsers"></datalist>
<datalist id="hostSsoGroups"></datalist>
</form>
`;
function openHostModal(title, submitLabel, host){
app.modal.open({
title: title,
size: 'lg',
bodyHtml: hostModalBodyHtml,
footer: {
// created_on/updated_on come back as redis-hash strings, not
// numbers -- moment's bare fallback parser mishandles that
// (silently "Invalid date"), same reason hostParseRow above
// uses the explicit "x" (unix ms) format token.
metaHtml: host ? app.modal.formatAudit(host, {formatDate: function(ms){ return moment(ms, 'x').format('YYYY-MM-DD HH:mm'); }}) : '',
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><i class="fa-solid fa-ban"></i> Cancel</button>'
+ '<button type="submit" form="hostForm" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> ' + $('<div>').text(submitLabel).html() + '</button>',
},
url: host ? {path: '/hosts/' + encodeURIComponent(host.host)} : null,
});
// The datalists were just rebuilt empty -- refill them (cheap re-fetch,
// no cache bug here since this function has no cache guard to skip it).
hostLoadAuthSuggestions();
}
function hostModalClose(){ hostModal().hide(); }
function hostShowTab(id){
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
@@ -152,9 +485,9 @@
let password = $pass.val();
if(!password) return;
app.api.put('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), {password}, function(error, data){
if(error) return app.util.actionMessage((data && data.message) || 'Failed to update password', $rows, 'danger');
if(error) return app.messages.action((data && data.message) || 'Failed to update password', $rows, 'danger');
$pass.val('');
app.util.actionMessage('Password updated for "' + username + '".', $rows, 'success');
app.messages.action('Password updated for "' + username + '".', $rows, 'success');
});
});
// No confirm step, matching this form's existing "Delete" button
@@ -163,7 +496,7 @@
let $del = $('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button>');
$del.on('click', function(){
app.api.delete('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), function(error, data){
if(error) return app.util.actionMessage((data && data.message) || 'Failed to delete user', $rows, 'danger');
if(error) return app.messages.action((data && data.message) || 'Failed to delete user', $rows, 'danger');
$tr.remove();
$('.basicauth-current').text(Object.keys((data && data.basicauth_users) || {}).join(', ') || 'none');
});
@@ -206,20 +539,17 @@
}
function hostAddOpen(){
openHostModal('Add host', 'Add host', null);
hostFormReset();
$('#hostModalTitle').text('Add host');
$('#hostModalSubmitText').text('Add host');
hostModal().show();
}
async function hostEditOpen(host){
hostFormReset();
let h = $.scope.hosts.getByKey(host);
openHostModal('Edit ' + host, 'Save changes', h);
hostFormReset();
let $f = $('#hostForm');
$f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host));
$('#hostModalTitle').text('Edit ' + host);
$('#hostModalSubmitText').text('Save changes');
// Scalar fields: booleans drive the matching radio, everything else the
// input with that name. Object/array fields are handled as text below.
@@ -285,13 +615,11 @@
$('#challengeType-HTTP-01').prop('checked', true);
}
}
hostModal().show();
}
function hostDownloadCert(host, type){
app.host.getCert({host}, function(error, data){
if(error) app.util.actionMessage(error.message, $.scope.hosts.$this, 'danger');
if(error) app.messages.action(error.message, $.scope.hosts.$this, 'danger');
app.util.downloadFile(`${host}-${type}.crt`, data[type])
});
}
@@ -322,9 +650,9 @@
app.host.clearCache(function(error, data){
$btn.prop('disabled', false);
if(error){
return app.util.actionMessage(error.message || error, $.scope.hosts.$this, 'danger');
return app.messages.action(error.message || error, $.scope.hosts.$this, 'danger');
}
app.util.actionMessage(data.message, $.scope.hosts.$this, 'success');
app.messages.action(data.message, $.scope.hosts.$this, 'success');
});
}
@@ -341,41 +669,52 @@
}
}
// Determine what Let's Encrypt challenge type the given host name can use.
// Delegated (not a direct bind against a captured selector) since
// app.modal.open() rebuilds #hostForm from scratch on every call -- a
// direct .on('keyup', ...) bind here would silently stop firing after
// the first Add/Edit.
app.modal.on('keyup', '#hostForm [name=host]', async function(){
let $hostField = $(this);
// Reset the allowed types on start
$('#challengeType-child-container').addClass('challengeType-container');
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
$('#wildcard_matchAny-container').addClass('challengeType-container');
let host = $hostField.val();
// If it's a wildcard, we must check the domain has a registered provider.
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
$('#wildcard_matchAny-container').removeClass('challengeType-container');
return;
}
// Check if a wildcard cert is available for the given host. When it is,
// make "Parent Wildcard" the default choice (it reuses an existing cert).
let wildcardParent = await hostMatchWildcard(host);
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
$('#challengeType-wildcardChild').prop('checked', true);
return;
}
// Revert the form to a valid state.
$('#challengeType-child-relatedHost').text('');
$('#challengeType-HTTP-01').prop('checked', true);
});
$(document).ready(function(){
// Populate the host UI table
hostPopulate();
hostLoadAuthSuggestions();
// Determine what Let's Encrypt challenge type the given host name can use.
let $hostField = $('#hostForm [name=host]');
$hostField.on('keyup', async function(){
// Reset the allowed types on start
$('#challengeType-child-container').addClass('challengeType-container');
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
$('#wildcard_matchAny-container').addClass('challengeType-container');
let host = $hostField.val();
// If it's a wildcard, we must check the domain has a registered provider.
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
$('#wildcard_matchAny-container').removeClass('challengeType-container');
return;
// Populate the host UI table, then check for a deep-linked host
// (e.g. a direct load of /hosts/some-host, or app.modal's url option
// having pushed that path) once the table's data is actually loaded.
hostPopulate(function(){
const slug = app.modal.deepLinkSlug('/hosts');
if (slug) {
const h = $.scope.hosts.getByKey(decodeURIComponent(slug));
if (h) hostEditOpen(h.host);
}
// Check if a wildcard cert is available for the given host. When it is,
// make "Parent Wildcard" the default choice (it reuses an existing cert).
let wildcardParent = await hostMatchWildcard(host);
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
$('#challengeType-wildcardChild').prop('checked', true);
return;
}
// Revert the form to a valid state.
$('#challengeType-child-relatedHost').text('');
$('#challengeType-HTTP-01').prop('checked', true);
});
$.scope.hosts.take = function($el, item, list){
@@ -413,6 +752,7 @@
});
</script>
<div class="container mt-4">
<div class="row" style="display:none">
<div class="col-12">
<div class="card shadow-lg hostListPanel">
@@ -547,334 +887,6 @@
</div>
</div>
</div>
<!-- Add / Edit host modal ------------------------------------------------- -->
<div class="modal fade" id="hostModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content card border-0">
<div class="modal-header">
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
<a href="/docs/hosts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="card-header actionMessage m-0" style="display:none"></div>
<div class="modal-body">
<ul class="nav nav-tabs flex-nowrap overflow-x-auto" role="tablist">
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
</ul>
<form class="addHost" id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
<div class="tab-content pt-3">
<!-- General -->
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
<div class="form-group">
<label for="host" class="form-label">Incoming host name</label>
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">
The public hostname clients request. Use <code>*.example.com</code>
for one subdomain level, <code>**.example.com</code> for any depth,
or <code>**</code> as a catch-all.
</small>
<small id="host-rename-help" class="field-help text-muted d-block" style="display:none">
Wildcard hosts, their children, and auto-created subdomain cache
entries can't be renamed here — the name is referenced elsewhere
(the wildcard's own children, or the cache entry's parent). Delete
and recreate instead.
</small>
</div>
<div class="form-group">
<label class="form-label">Incoming SSL</label>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
Force HTTPS <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-false" value="false">
Allow both HTTP and HTTPS
</label></div>
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
</div>
<hr>
<div class="form-group">
<label for="ip" class="form-label">Target IP or host name</label>
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10, app.internal.net, or sso-manager" validate="target:3" />
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only &mdash; no protocol, port, or path.</small>
</div>
<div class="row">
<div class="col form-group">
<label for="targetPort" class="form-label">Target TCP port</label>
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
<b class="invalid-feedback"></b>
</div>
<div class="col form-group">
<label class="form-label">Target SSL</label>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
Proxy to HTTP <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label></div>
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
</div>
</div>
<hr>
<div class="form-group">
<label for="targets" class="form-label">Additional Targets (Load Balancing)</label>
<textarea name="targets" class="form-control" rows="2" placeholder="10.0.0.2:8080&#10;10.0.0.3:8080"></textarea>
<small class="field-help text-muted d-block">Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included.</small>
</div>
</div>
<!-- TLS & Wildcard -->
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
<div class="form-group autoSll">
<label class="form-label">
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
</label>
<div class="radio" id="challengeType-HTTP-01-container"><label>
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
HTTP-01
</label></div>
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
DNS-01 Wildcard
</label></div>
<div class="radio challengeType-container" id="challengeType-child-container"><label>
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
</label></div>
<small class="field-help text-muted d-block">
Options light up based on the host name: wildcard certs need a DNS
provider for the domain; child hosts reuse a parent wildcard.
</small>
</div>
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
<label class="form-label">Wildcard matching</label>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
Match only subdomains defined here <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label></div>
<small class="field-help text-muted d-block">
"Recommended" only routes subdomains you've explicitly registered
as their own host (optionally as a "Parent Wildcard" child of this
one, to reuse this cert). "Match any" auto-creates a temporary
route to this host's target for <i>any</i> undefined subdomain the
first time it's requested — convenient, but it means every subdomain
typo or scan attempt also gets routed here.
</small>
</div>
</div>
<!-- Traffic -->
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label></div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
<hr>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label></div>
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label></div>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label></div>
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
</div>
</div>
<!-- Headers -->
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
</div>
</div>
<!-- Access -->
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
<h6 class="text-muted">IP access</h6>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
</div>
</div>
<!-- Authentication -->
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
<p class="field-help text-muted">
Pick one authentication method for this host &mdash; basic auth and
SSO can't both be enabled, to avoid ambiguity about which one
actually protected a request. Choose "Off" for a public host.
</p>
<div class="form-group">
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
Off (public)
</label></div>
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
Basic authentication
</label></div>
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
Single sign-on (SSO)
</label></div>
</div>
<!-- Actually-submitted fields; kept in sync with the radios above by
hostAuthModeChanged() so only one can be true at a time. -->
<input type="hidden" name="basicauth_enabled" id="basicauth_enabled-hidden" value="false">
<input type="hidden" name="sso_enabled" id="sso_enabled-hidden" value="false">
<div id="hostTab-auth-basicFields" style="display:none">
<hr>
<h6 class="text-muted">Basic authentication</h6>
<div class="form-group">
<label for="basicauth_realm" class="form-label">Realm</label>
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
</div>
<div class="form-group">
<label for="basicauth_users" class="form-label">Users</label>
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
<small class="field-help text-muted d-block">
Current: <span class="basicauth-current">none</span>.
Passwords are stored hashed and never shown here. Leave blank to keep
the current users; entering any lines replaces the whole list. To
manage individual users (delete / change password), use the table
below once the host has been saved.
</small>
</div>
</div>
<div id="hostTab-auth-ssoFields" style="display:none">
<hr>
<h6 class="text-muted">Single sign-on (SSO)</h6>
<p class="field-help text-muted">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</p>
<div class="form-group">
<label for="sso_allow_users" class="form-label">Allowed users</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
</div>
<div class="form-group">
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
</div>
</div>
<div id="hostTab-auth-basicUsersMgmt" style="display:none">
<hr>
<h6 class="text-muted">Manage basic-auth users</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead><tr><th>Username</th><th>New password</th><th></th></tr></thead>
<tbody id="basicAuthUserRows"></tbody>
</table>
</div>
</div>
</div>
</div>
<datalist id="hostSsoUsers"></datalist>
<datalist id="hostSsoGroups"></datalist>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fa-solid fa-ban"></i> Cancel
</button>
<button type="submit" form="hostForm" class="btn btn-success">
<i class="fa-solid fa-floppy-disk"></i>
<span id="hostModalSubmitText">Add host</span>
</button>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+12 -8
View File
@@ -4,14 +4,18 @@
// If we arrived from the OIDC callback with a token in the URL fragment,
// store it and forward on before doing anything else.
if(!app.auth.consumeTokenFragment()){
app.auth.isLoggedIn(function(error, isLoggedIn){
if(isLoggedIn){
app.auth.logInRedirect();
}else{
// Reveal the login card once we know the user is not logged in.
document.getElementById('login-card-row').style.display = '';
}
})
// The reveal below touches an element further down this page, so wait
// for the DOM — isLoggedIn can answer before the parser gets there.
$(document).ready(function(){
app.auth.isLoggedIn(function(error, isLoggedIn){
if(isLoggedIn){
app.auth.logInRedirect();
}else{
// Reveal the login card once we know the user is not logged in.
document.getElementById('login-card-row').style.display = '';
}
});
});
}
</script>
+76 -83
View File
@@ -40,9 +40,53 @@
}
}
function permissionAddOpen(){
app.modal.open({title: 'Add Permission', bodyHtml:
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close();">'
+ '<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>',
});
}
function removePermission(id){
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, $.scope.Permission.$this, 'danger');
// The websocket echo removes the row; drop it locally too for snappiness.
$.scope.Permission.remove(id);
});
@@ -51,7 +95,7 @@
$(document).ready(function(){
// Existing permissions.
app.permission.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger');
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
for(let p of data.results) $.scope.Permission.push(p);
});
@@ -66,6 +110,7 @@
app.subscribe(/^model:Permission:create/, function(data){
$.scope.Permission.remove(data.id);
$.scope.Permission.unshift(data);
setTimeout(function(){ app.util.revealItem($('#permission-row-' + data.id)); }, 100);
});
app.subscribe(/^model:Permission:remove/, function(data, topic){
$.scope.Permission.remove(topic.split(':')[3]);
@@ -73,67 +118,12 @@
});
</script>
<div class="container mt-4">
<datalist id="subjectUsers"></datalist>
<datalist id="subjectGroups"></datalist>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-user-shield"></i>
</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 class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="permission/" onsubmit="formAJAX(this)">
<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="col-12">
<div class="card shadow-lg">
<div class="card-header text-center">
@@ -141,38 +131,41 @@
<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>
<span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn btn-sm btn-success" onclick="permissionAddOpen()">
<i class="fa-solid fa-user-shield"></i>
Add Permission
</button>
</span>
</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}}')">
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="permission-cards">
<div class="col" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<div class="card shadow-sm h-100">
<div class="card-body">
<h6 class="mb-2">
<span class="badge text-bg-secondary">{{ subjectType }}</span>
{{ subject }}
</h6>
<dl class="row mb-2 small">
<dt class="col-4">Scope</dt><dd class="col-8">{{ scope }}</dd>
<dt class="col-4">Domain</dt><dd class="col-8">{{ domain }}</dd>
<dt class="col-4">Role</dt><dd class="col-8">{{ role }}</dd>
</dl>
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+153 -94
View File
@@ -63,12 +63,13 @@
$(document).ready(function(){
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);
});
});
</script>
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg" id="profile-card">
@@ -102,43 +103,33 @@
</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">
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var tokensById = {};
function showSecret(secret){
document.getElementById('secretValue').value = secret;
secretModal.show();
// Shared "reveal secret once" display -- same pattern as jump-host's
// showToken(), which sso-manager-node also uses.
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(){
var el = document.getElementById('secretValue');
el.select(); el.setSelectionRange(0, 99999);
try { document.execCommand('copy'); } catch(_){}
// Not the checkmark-flash technique some of this codebase's other copy
// buttons use -- FontAwesome replaces <i> icons with inline <svg>, so
// swapping the <i>'s class silently no-ops. A toast doesn't have that
// 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){
@@ -170,94 +161,162 @@
function tableAJAX(){
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();
(data.results || []).forEach(function(token){
tokens.forEach(function(token){
$.scope.apiTokenCard.push(processToken(token));
});
$('#api-tokens-empty').toggle(tokens.length === 0);
});
}
function revokeToken(id, name, btn){
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return;
async function revokeToken(id, name, btn){
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){
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);
});
}
function rotateToken(id, name, btn){
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return;
async function rotateToken(id, name, btn){
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){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
showSecret(data.token);
if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
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();
});
}
$(document).ready(function(){
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>
<div class="row mt-3">
<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="row mt-3 justify-content-center">
<div class="col-md-8">
<div class="card-header actionMessage" style="display:none"></div>
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
<div class="card-header">
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
<small class="text-muted font-monospace">{{ id_short }}</small>
<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>
<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">
{{ #description }}<p>{{ description }}</p>{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{ created_display }}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{ expires_display }}}</dd>
</dl>
</div>
<div class="card-footer">
<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>
<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 class="card-header">
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
<small class="text-muted font-monospace">{{ id_short }}</small>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
{{ #description }}<p>{{ description }}</p>{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{ created_display }}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{ expires_display }}}</dd>
</dl>
</div>
<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="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>
<%- include('bottom') %>
+64 -56
View File
@@ -4,8 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%- name %> <%- title %></title>
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
via app.locals in app.js). Edit all three copies together. -->
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
<!-- CSS are placed here -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
@@ -14,63 +17,52 @@
<!-- Scripts are placed here -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.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/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/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>
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav">
<li class="nav-item">
<a class="nav-link active" href="/hosts">
<i class="fa-solid fa-network-wired"></i>
Hosts
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/dns"><i class="fa-solid fa-record-vinyl"></i>
DNS
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
Users
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/permissions"><i class="fa-solid fa-user-shield"></i>
Permissions
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-gear"></i>
Groups
<%# Items gated on a group start hidden (.group-required) and are
revealed by app-base.js for the groups the user is in. %>
<% for(const item of ui.nav){ %>
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
<%- item.label %>
</a>
</li>
<% } %>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
<% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<% } else { %>
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</span>
<% } %>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-out"></i>
<i class="fas fa-sign-in"></i>
Login
</a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button>
@@ -78,6 +70,7 @@
</div>
</nav>
<% if(ui.updateCheck){ %>
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
@@ -89,19 +82,43 @@
</div>
<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(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$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(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
sessionStorage.setItem('update-banner-dismissed', '1');
}
function checkForUpdate(){
if(sessionStorage.getItem('update-banner-dismissed')) return;
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
</script>
<% } %>
<script type="text/javascript">
$(document).ready(function(){
// Set the correct link to active in the top nav bar
@@ -113,34 +130,24 @@
}
})
// Set the correct login/logout button, and reveal admin-only nav
// items for global admins.
app.auth.isLoggedIn(function(error, data){
if(data){
// Set the correct login/logout button, and reveal the current user's
// name once we know who they are. Group-gated nav items are revealed
// by app-base.js off the same cached user/me.
app.auth.isLoggedIn(function(error, me){
if(me){
$('#cl-logout-button').show();
if(data.username){
$('#cl-username-text').text(data.username);
let username = me.uid || me.username;
if(username){
$('#cl-username-text').text(username);
$('#cl-username').css('display', '');
}
<% if(ui.updateCheck){ %>
if(me.isAdmin) checkForUpdate();
<% } %>
}else{
$('#cl-login-button').show();
}
if(data && data.isAdmin){
$('.nav-admin').css('display', '');
if(!sessionStorage.getItem('update-banner-dismissed')){
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of the proxy is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
}
});
});
@@ -148,4 +155,5 @@
<!-- Container -->
<div id="spa-shell" class="container-fluid">
<div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div>
+67 -69
View File
@@ -20,11 +20,16 @@
<script type="text/javascript">
function processUser(user){
user.isExternal = user.backing === 'oidc';
return user;
}
function populateUsers(actionMessage){
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){
$.scope.users.push(user);
$.scope.users.push(processUser(user));
}
$.scope.users.__put = function($el, item, list){
$el.addClass('bg-success');
@@ -37,11 +42,37 @@
function removeUser(username){
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);
});
}
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(){
populateUsers(); //populate the table
@@ -54,50 +85,9 @@
});
</script>
<div class="container mt-4">
<div class="row" style="display:none">
<div class="col-md-4">
<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="col-12">
<div class="card shadow-lg">
<div class="card-header text-center">
@@ -109,42 +99,50 @@
</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>
<button type="button" class="btn btn-sm btn-success" onclick="userAddOpen()">
<i class="fa-solid fa-user-plus"></i>
Add User
</button>
</span>
</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>Name</th>
<th>Password</th>
<th>Delete</th>
</thead>
<tbody>
<tr jq-repeat="users" jq-repeat-index="username" style="display:none" >
<td class="align-middle">
{{ username }}
</td>
<td>
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="user-cards">
<div class="col" jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
<div class="card shadow-sm h-100">
<div class="card-body">
<h6 class="d-flex align-items-center mb-2">
<i class="fa-solid fa-user me-2"></i>
{{ username }}
{{#isExternal}}
<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)">
<input type="password" name="password" class="form-control" placeholder="Change {{ username }} password" aria-label="Update password">
{{^isExternal}}
<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>
</form>
{{/isExternal}}
{{#isExternal}}
<p class="text-muted small mb-2">Authenticates via SSO -- cannot be edited here.</p>
{{/isExternal}}
</td>
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removeUser('{{username}}')">
<button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
<i class="fa-solid fa-user-slash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>