Compare commits

..

14 Commits

Author SHA1 Message Date
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
8 changed files with 482 additions and 396 deletions
+24
View File
@@ -6,6 +6,30 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [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
+6 -6
View File
@@ -1,19 +1,19 @@
{
"name": "proxy-api",
"version": "1.4.0",
"version": "1.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxy-api",
"version": "1.4.0",
"version": "1.5.3",
"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.5",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0",
@@ -310,9 +310,9 @@
}
},
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "proxy-api",
"version": "1.5.0",
"version": "1.6.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"
@@ -23,7 +23,7 @@
"@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0",
+4 -7
View File
@@ -679,13 +679,10 @@ function formAJAX(btn){
return false;
}
app.messages.action(
`<div class="spinner-border" role="status">
<span class="visually-hidden">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.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
+9
View File
@@ -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});
});
@@ -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, []);
});
+384 -374
View File
@@ -61,7 +61,7 @@
return host;
}
function hostPopulate(){
function hostPopulate(onLoaded){
app.api.get('host?detail=1&provider=1', function(error, res){
if(error) return app.messages.action(error, $.scope.hosts.$this, 'danger');
@@ -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();
@@ -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,8 +615,6 @@
$('#challengeType-HTTP-01').prop('checked', true);
}
}
hostModal().show();
}
function hostDownloadCert(host, type){
@@ -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){
@@ -548,333 +887,4 @@
</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') %>
+6 -4
View File
@@ -178,16 +178,18 @@
});
}
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.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.messages.action(error, $(btn).closest('.card'), 'danger');
showSecret(data.token);