Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb9388a4b1 | |||
| 9dce4b6c24 | |||
| 1bbf593232 | |||
| 8107755307 | |||
| 1b8ef1f848 | |||
| eb08b6b5b9 | |||
| 63be1f1020 | |||
| 62cdaa2cdd | |||
| 13a02e6850 | |||
| baba3a414f | |||
| d049b2de49 | |||
| b0e8104790 | |||
| 3cc769cc3c | |||
| 94ff5c76eb | |||
| d74e3168ed | |||
| 9029de825c | |||
| b50a1de76f | |||
| c419249e98 | |||
| 4aa994121a | |||
| 2e92f58750 | |||
| aeccbcbbe9 | |||
| 15b154fc8d | |||
| a44d7ef7ab | |||
| 144efdb5dd | |||
| b54a738524 | |||
| 8bf963f48b | |||
| 30835baaeb | |||
| 2319ac3a0e | |||
| 8c0eaf0d07 | |||
| 8c81128235 | |||
| 4f23d301bf | |||
| be666f5b2f | |||
| 2bfba93e00 | |||
| 8cee583da3 | |||
| b5fab6c91a | |||
| c7ec65e0d9 | |||
| 3f2ef57c78 | |||
| c0e04d1a56 | |||
| 130d4a2c9b | |||
| 3b729295b0 | |||
| 44c2ec3fdd | |||
| e9032ec4fb | |||
| a6e745cfc1 | |||
| 8350ab9b57 | |||
| 001fe84b99 | |||
| e05ec1d2ad | |||
| 652df4f3cf | |||
| 9370734084 | |||
| 58204da3cb | |||
| 2d7eadec60 | |||
| 0b9a1aabfd | |||
| 36c3f7a881 | |||
| 3686a5ddb8 | |||
| ce013d7e31 | |||
| f6bc38eef2 | |||
| 0a659428dd | |||
| e770bbb41f | |||
| 90619dd4ff |
+107
@@ -1,3 +1,14 @@
|
||||
## v1.33.0
|
||||
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare JSON/text response
|
||||
- feat: DNS page is now admin-only (hidden from non-admins; API already admin-gated)
|
||||
- feat: navbar — username no longer underlined; only the active link is bold + underlined
|
||||
|
||||
## v1.13.3
|
||||
- fix: remove missing DEPLOYMENT.md and docs/ from Docker build context
|
||||
|
||||
## v1.13.2
|
||||
- chore: Update CI pipeline integration
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. Format loosely
|
||||
@@ -6,6 +17,102 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.13.1] - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
|
||||
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
|
||||
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
|
||||
proxy exit at boot in any deployment without an OpenBao sidecar (standalone
|
||||
Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing token (warn
|
||||
+ continue from `CONF_SECRETS`), matching the documented contract. The
|
||||
theta-env stack is unaffected (it always sets a scoped `VAULT_TOKEN`).
|
||||
|
||||
## [1.13.0] - 2026-08-01
|
||||
|
||||
### Changed
|
||||
- **Secrets now load from OpenBao at boot** via
|
||||
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy
|
||||
authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy `proxy` —
|
||||
read-only on its own path), never the root token. Because the OIDC
|
||||
`clientSecret` is captured at require time inside `createOidcClient` (during
|
||||
`require('../models')`, which `require('../app')` triggers transitively),
|
||||
`bin/www` now defers `require('../app')` until after `bao-conf.init()`
|
||||
resolves. Fail-soft: if OpenBao is unreachable, boot continues from
|
||||
`CONF_SECRETS`. The `config/proxy-secrets.js` file is now an operator-edit
|
||||
seed artifact (gitignored); OpenBao is authoritative. See theta-env's
|
||||
[Secrets docs](https://theta42.github.io/theta-env/secrets/).
|
||||
- Bumped package version to track the release tag.
|
||||
|
||||
## [1.12.1] - 2026-08-01
|
||||
|
||||
### Changed
|
||||
- Bumped `body-parser` 2.2.2 → 2.3.0 (Dependabot #175).
|
||||
- Bumped `ejs` and `brace-expansion` (Dependabot #179, security maintenance).
|
||||
|
||||
## [1.12.0] - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
- Changed UNIX socket permission in `unix_socket_json.js` to `666` so OpenResty Nginx workers running as `nobody` can resolve targets properly.
|
||||
|
||||
## [1.9.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- **`app_super_admin` cross-app group** recognized as a global admin (`conf.auth.adminGroups`), same group also recognized by sso-manager-node and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
|
||||
|
||||
### Changed
|
||||
- **Users and Permissions pages**: the always-visible sidebar "Add" forms are now an "Add User"/"Add Permission" button in the list header that opens an `app.modal` dialog, matching the hosts.ejs convention.
|
||||
- **Let's Encrypt ACME account key** now defaults to the already-persisted `/data` volume instead of a CWD-relative path (`./le_key.cert` -> `/app/le_key.cert` in the container), which was lost on every image rebuild.
|
||||
|
||||
## [1.8.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Users backed by SSO/OIDC login are now marked "External (SSO)"** and read-only: their password-change control is hidden, and `PUT /password/:username` rejects with 403 server-side. Deletion stays allowed. Redis user-backend only.
|
||||
- **`app.util.revealItem()`** (shared `app-base.js`): scrolls a just-added/-edited element into view and flashes its background. Wired into the Users/Permissions create flows.
|
||||
|
||||
### Changed
|
||||
- **All pages now wrap their content in a standard-width container**, matching sso-manager-node instead of rendering full-bleed.
|
||||
- **Users and Permissions pages converted from bare `<table>`s to the card-grid convention** already used on the Groups page.
|
||||
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
|
||||
|
||||
## [1.7.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **API tokens: "+ New Token" modal button (replacing the always-visible inline create-form card) and a new Edit modal** — continues the cross-app API-token UI unification started in jump-host. The Edit modal's footer shows real created-by/on data; the `PUT /api-token/:id` route already fully supported editing, so no backend change was needed.
|
||||
|
||||
### Fixed
|
||||
- **Creating an API token didn't show the "save this secret now" reveal modal** — the create flow called `app.modal.close()` immediately before `app.modal.open()` (to show the secret) in the same tick; since `app.modal` is a singleton, that collided with Bootstrap's hide-transition guard and the reveal modal silently never appeared.
|
||||
|
||||
## [1.6.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Host modal: footer with created/updated-by/on metadata, linkable `/hosts/{host}` URL** — migrated onto the shared `app.modal` component (`@simpleworkjs/frontend` 0.2.6), continuing the entity-modal standardization started in sso-manager-node. Kept the existing 6-tab layout (General/TLS & Wildcard/Traffic/Headers/Access/Authentication) unchanged.
|
||||
|
||||
### Fixed
|
||||
- **The Let's-Encrypt challenge-type/wildcard-matching visibility logic could stop reacting to the hostname field after the first Add/Edit** — its `keyup` handler was bound once directly against a selector captured at page load; now delegated so it survives the modal being rebuilt on every open.
|
||||
- **The SSO allow-list autocomplete (users/groups) could go empty starting on the second Add/Edit** — its backing `<datalist>`s are rebuilt fresh on every modal open now, so the suggestions are refetched each time too.
|
||||
|
||||
## [1.5.3] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node's companion release. Replaced with plain text.
|
||||
|
||||
## [1.5.2] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Regression test**: a static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page and were fully removed in 1.5.1.
|
||||
|
||||
## [1.5.1] - 2026-07-27
|
||||
|
||||
### Fixed
|
||||
- **Removed native `confirm()` calls in `revokeToken`/`rotateToken`** (`profile.ejs`), replacing them with `app.messages.confirm`. Native `confirm()` blocks all further browser events on the page (discovered live, mid browser-verification of sso-manager-node's equivalent flow, where it froze the tab).
|
||||
|
||||
## [1.5.0] - 2026-07-27
|
||||
|
||||
### Changed
|
||||
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped, and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. proxy's `host`/`target`/`hostname` wildcard-DNS validation rules (mirroring `utils/hostname_validate.js`) moved to `public/js/app.js`, registered via `$.validateSettings`, since they're proxy-specific and don't belong in the shared package's generic rule set. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
|
||||
|
||||
## [1.4.0] - 2026-07-26
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -115,8 +115,6 @@ COPY nodejs/api.md ./api.md
|
||||
# docs/ mirrors the repo's own top-level docs/ folder.
|
||||
COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||
COPY docs /docs
|
||||
|
||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||
|
||||
@@ -162,6 +162,23 @@ docker compose exec proxy tail -f /var/log/nginx/error.log
|
||||
docker compose logs --tail=200 --since=10m proxy
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
Secrets are loaded from **OpenBao** at boot via
|
||||
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy's OIDC
|
||||
`clientSecret` is captured at require time (inside `createOidcClient` during
|
||||
`require('../models')`), so `bin/www` runs `bao-conf.init()` **before**
|
||||
`require('../app')` (which transitively loads models). Fail-soft: if OpenBao is
|
||||
unreachable, boot continues from `CONF_SECRETS`. The proxy authenticates to
|
||||
OpenBao with the scoped `VAULT_TOKEN` (env, policy `proxy` — read only
|
||||
`secret/proxy/conf`), never the root token.
|
||||
|
||||
The `config/proxy-secrets.js` file is an operator-edit seed artifact
|
||||
(gitignored); the bootstrap writes the generated OAuth client creds into
|
||||
OpenBao, which is authoritative. For the full architecture see theta-env's
|
||||
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
For manual installation or other distributions, see the detailed steps below.
|
||||
|
||||
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 |
@@ -21,6 +21,8 @@ module.exports = app;
|
||||
|
||||
// Hold onto the auth middleware
|
||||
const middleware = require('./middleware/auth');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('./utils/build_info');
|
||||
|
||||
// Grab the projects PubSub
|
||||
app.contoller = require('./controller');
|
||||
@@ -115,6 +117,19 @@ app.use(async function(err, req, res, next) {
|
||||
res.status(status);
|
||||
// Only expose safe, non-internal fields to the client.
|
||||
const body = { name: err.name, message: err.message };
|
||||
// Browser navigation gets the HTML error page (shared with SSO); API
|
||||
// clients get JSON.
|
||||
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
|
||||
res.render('error', {
|
||||
title: conf.environment !== 'production' ? 'dev' : '',
|
||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||
name: conf.name,
|
||||
logo: conf.logo,
|
||||
...buildInfo,
|
||||
error: err,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(body);
|
||||
}catch(error){
|
||||
console.error('error in the catch-all error handler', error);
|
||||
|
||||
+78
-65
@@ -4,34 +4,91 @@
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
var app = require('../app');
|
||||
var debug = require('debug')('proxy-api:server');
|
||||
var http = require('http');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const debug = require('debug')('proxy-api:server');
|
||||
const http = require('http');
|
||||
|
||||
/**
|
||||
* Get port from environment and store in Express.
|
||||
*/
|
||||
// @simpleworkjs/conf loads ./config/proxy-secrets.js synchronously, then
|
||||
// @simpleworkjs/bao-conf deep-merges secret/proxy/conf from OpenBao over it.
|
||||
// The OIDC clientSecret is captured at require time inside models (via
|
||||
// createOidcClient), and require('../app') transitively loads models, so the
|
||||
// OpenBao fetch MUST resolve before require('../app'). Fail-soft: if OpenBao
|
||||
// is unreachable, init() leaves conf as the file-loaded fallback and boot
|
||||
// continues from ./config/proxy-secrets.js.
|
||||
require('@simpleworkjs/bao-conf').init({ path: 'proxy', conf }).then(() => {
|
||||
var app = require('../app'); // models + createOidcClient now see merged conf
|
||||
|
||||
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
|
||||
app.set('port', port);
|
||||
/**
|
||||
* Get port from environment and store in Express.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create HTTP server.
|
||||
*/
|
||||
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
|
||||
app.set('port', port);
|
||||
|
||||
var server = http.createServer(app);
|
||||
/**
|
||||
* Create HTTP server.
|
||||
*/
|
||||
|
||||
var io = require('socket.io')(server);
|
||||
app.io = io;
|
||||
var server = http.createServer(app);
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
var io = require('socket.io')(server);
|
||||
app.io = io;
|
||||
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "error" event.
|
||||
*/
|
||||
|
||||
function onError(error) {
|
||||
if (error.syscall !== 'listen') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
var bind = typeof port === 'string'
|
||||
? 'Pipe ' + port
|
||||
: 'Port ' + port;
|
||||
|
||||
// handle specific listen errors with friendly messages
|
||||
switch (error.code) {
|
||||
case 'EACCES':
|
||||
console.error(bind + ' requires elevated privileges');
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'EADDRINUSE':
|
||||
console.error(bind + ' is already in use');
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "listening" event.
|
||||
*/
|
||||
|
||||
function onListening() {
|
||||
var addr = server.address();
|
||||
var bind = typeof addr === 'string'
|
||||
? 'pipe ' + addr
|
||||
: 'port ' + addr.port;
|
||||
console.log('Listening on ' + bind);
|
||||
|
||||
for(let listener of app.onListen){
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('boot failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalize a port into a number, string, or false.
|
||||
@@ -51,48 +108,4 @@ function normalizePort(val) {
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "error" event.
|
||||
*/
|
||||
|
||||
function onError(error) {
|
||||
if (error.syscall !== 'listen') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
var bind = typeof port === 'string'
|
||||
? 'Pipe ' + port
|
||||
: 'Port ' + port;
|
||||
|
||||
// handle specific listen errors with friendly messages
|
||||
switch (error.code) {
|
||||
case 'EACCES':
|
||||
console.error(bind + ' requires elevated privileges');
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'EADDRINUSE':
|
||||
console.error(bind + ' is already in use');
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "listening" event.
|
||||
*/
|
||||
|
||||
function onListening() {
|
||||
var addr = server.address();
|
||||
var bind = typeof addr === 'string'
|
||||
? 'pipe ' + addr
|
||||
: 'port ' + addr.port;
|
||||
console.log('Listening on ' + bind);
|
||||
|
||||
for(let listener of app.onListen){
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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' } }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const crypto = require("crypto");
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const Table = require('.');
|
||||
const ModelPs = require('../utils/model_pubsub');
|
||||
|
||||
@@ -139,11 +140,26 @@ class DnsProvider extends Table{
|
||||
let __intraModel = this.__intraModel(data.dnsProvider);
|
||||
Provider = __intraModel.Provider;
|
||||
|
||||
if (!data.id) data.id = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
let secrets = {};
|
||||
for (let key in Provider._keyMap) {
|
||||
if (Provider._keyMap[key].isPrivate && data[key] !== undefined) {
|
||||
secrets[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
// This is here test if the given API key is valid
|
||||
let provider = new __intraModel.Provider(data, ...args);
|
||||
let domains = await provider.listDomains();
|
||||
|
||||
for (let key in secrets) data[key] = '********';
|
||||
|
||||
let instance = await super.create.call(__intraModel, data, ...args);
|
||||
|
||||
if (Object.keys(secrets).length > 0) {
|
||||
await baoConf.set(`proxy/dns-providers/${instance.id}`, secrets);
|
||||
}
|
||||
try{
|
||||
await instance.updateDomains(domains);
|
||||
}catch(updateError){
|
||||
@@ -189,7 +205,63 @@ class DnsProvider extends Table{
|
||||
let instance = await super.get(data, ...args);
|
||||
let __intraModel = this.__intraModel(instance.dnsProvider);
|
||||
|
||||
return await super.get.call(__intraModel, data, ...args);
|
||||
let resolved = await super.get.call(__intraModel, data, ...args);
|
||||
try {
|
||||
let secrets = await baoConf.get(`proxy/dns-providers/${resolved.id}`);
|
||||
if (secrets) Object.assign(resolved, secrets);
|
||||
} catch(e) {}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
static async findall(...args){
|
||||
let instances = await super.findall(...args);
|
||||
for (let inst of instances) {
|
||||
try {
|
||||
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
|
||||
if (secrets) Object.assign(inst, secrets);
|
||||
} catch(e) {}
|
||||
}
|
||||
return instances;
|
||||
}
|
||||
|
||||
static async find(...args){
|
||||
let instances = await super.find(...args);
|
||||
for (let inst of instances) {
|
||||
try {
|
||||
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
|
||||
if (secrets) Object.assign(inst, secrets);
|
||||
} catch(e) {}
|
||||
}
|
||||
return instances;
|
||||
}
|
||||
|
||||
async update(data){
|
||||
let Provider = this.constructor.Provider || providers[this.dnsProvider];
|
||||
let secrets = {};
|
||||
if (Provider) {
|
||||
for (let key in Provider._keyMap) {
|
||||
if (Provider._keyMap[key].isPrivate && data[key] !== undefined && data[key] !== '********') {
|
||||
secrets[key] = data[key];
|
||||
data[key] = '********';
|
||||
} else if (Provider._keyMap[key].isPrivate && data[key] === '********') {
|
||||
delete data[key]; // Do not update the masked value if it's sent back
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let res = await super.update(data);
|
||||
|
||||
if (Object.keys(secrets).length > 0) {
|
||||
let existing = await baoConf.get(`proxy/dns-providers/${this.id}`) || {};
|
||||
await baoConf.set(`proxy/dns-providers/${this.id}`, { ...existing, ...secrets });
|
||||
Object.assign(this, secrets);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async remove(...args){
|
||||
await baoConf.request('DELETE', `proxy/dns-providers/${this.id}`).catch(()=>{});
|
||||
return await super.remove(...args);
|
||||
}
|
||||
|
||||
static listProviders(){
|
||||
|
||||
@@ -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{
|
||||
|
||||
Generated
+78
-93
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.4.0",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxy-api",
|
||||
"version": "1.4.0",
|
||||
"version": "1.33.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
@@ -20,7 +22,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"compression": "^1.8.1",
|
||||
"ejs": "^3.1.10",
|
||||
"ejs": "^6.0.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
@@ -296,6 +298,18 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/bao-conf": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
|
||||
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/conf": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||
@@ -308,6 +322,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,12 +459,6 @@
|
||||
"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",
|
||||
@@ -507,20 +524,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -530,6 +547,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||
@@ -550,16 +580,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -838,18 +868,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jake": "^10.8.5"
|
||||
},
|
||||
"bin": {
|
||||
"ejs": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
@@ -1061,42 +1088,6 @@
|
||||
"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",
|
||||
@@ -1473,23 +1464,6 @@
|
||||
"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",
|
||||
@@ -1796,12 +1770,6 @@
|
||||
"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",
|
||||
@@ -2303,17 +2271,34 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.4.0",
|
||||
"version": "1.33.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,10 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
@@ -30,7 +32,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"compression": "^1.8.1",
|
||||
"ejs": "^3.1.10",
|
||||
"ejs": "^6.0.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
|
||||
@@ -3,10 +3,22 @@ nav.navbar{
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
||||
.top-nav a.active{
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body {
|
||||
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 {
|
||||
|
||||
@@ -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 );
|
||||
},
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -363,7 +363,7 @@ app.auth = (function(app){
|
||||
}
|
||||
|
||||
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||
app.util.actionMessage(
|
||||
app.messages.action(
|
||||
`<h1>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>You do not have permission to be here.</b>
|
||||
@@ -520,68 +520,15 @@ app.util = (function(app){
|
||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||
};
|
||||
|
||||
function actionMessage(message, $targetPassed, type, callback){
|
||||
message = message || '';
|
||||
|
||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
||||
|
||||
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);
|
||||
|
||||
// Messages that bring their own buttons (actionConfirm) are left
|
||||
// alone; everything else gets the standard dismiss button.
|
||||
if(!message.includes('<button')) message = `
|
||||
<span class="align-middle">${message}</span>
|
||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
`
|
||||
$target.html(message).slideDown('fast');
|
||||
}
|
||||
setTimeout(callback,10)
|
||||
}
|
||||
|
||||
function actionConfirm(message, $target, type, callback){
|
||||
return new Promise((resolve, reject) =>{
|
||||
let id = crypto.randomUUID();
|
||||
message = `
|
||||
<h4 class"align-middle" >
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>${message}</b>
|
||||
<span class="float-end">
|
||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
||||
<i class="fa-solid fa-circle-check"></i>
|
||||
Confirm
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger confirm-${id}">
|
||||
<i class="fa-solid fa-circle-stop"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
</h4>
|
||||
`
|
||||
actionMessage(message, $target, type);
|
||||
$("body").on('click', `.confirm-${id}`, function(){
|
||||
actionMessage('', $target, type);
|
||||
resolve(!!$(this).data('confirm'));
|
||||
});
|
||||
});
|
||||
|
||||
// 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
$.fn.serializeObject = function() {
|
||||
@@ -637,11 +584,31 @@ 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,
|
||||
actionConfirm,
|
||||
escapeHtml: escapeHtml,
|
||||
revealItem: revealItem,
|
||||
}
|
||||
})(app);
|
||||
|
||||
@@ -696,9 +663,9 @@ $( document ).ready(async 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)=>{
|
||||
@@ -729,20 +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="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.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");
|
||||
@@ -750,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)
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
+11
-2
@@ -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,13 +40,22 @@ 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});
|
||||
});
|
||||
|
||||
|
||||
router.get('/users', async function(req, res, next) {
|
||||
res.render('users', {...values});
|
||||
res.redirect(301, '/hosts');
|
||||
});
|
||||
|
||||
router.get('/permissions', async function(req, res, next) {
|
||||
|
||||
+10
-1
@@ -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,67 @@
|
||||
const { describe, test, beforeEach, afterEach, after, mock } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const Table = require('../../models/index');
|
||||
const DnsProvider = Table.models.DnsProvider;
|
||||
const DuckDns = require('../../models/dns_provider/duckdns');
|
||||
|
||||
describe('DnsProvider Vault Integration', () => {
|
||||
let originalSet, originalGet, originalRequest;
|
||||
|
||||
after(async () => {
|
||||
if (Table._redis && Table._redis.quit) {
|
||||
await Table._redis.quit();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock baoConf
|
||||
originalSet = baoConf.set;
|
||||
originalGet = baoConf.get;
|
||||
originalRequest = baoConf.request;
|
||||
|
||||
const vaultStore = {};
|
||||
baoConf.set = mock.fn(async (path, data) => { vaultStore[path] = data; return true; });
|
||||
baoConf.get = mock.fn(async (path) => vaultStore[path] || {});
|
||||
baoConf.request = mock.fn(async () => ({}));
|
||||
|
||||
mock.method(DuckDns.prototype, 'listDomains', async () => []);
|
||||
mock.method(DnsProvider.prototype, 'updateDomains', async () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
baoConf.set = originalSet;
|
||||
baoConf.get = originalGet;
|
||||
baoConf.request = originalRequest;
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
test('create() writes isPrivate keys to OpenBao and get() retrieves them', async () => {
|
||||
const payload = {
|
||||
name: 'My Duck',
|
||||
dnsProvider: 'DuckDns',
|
||||
token: 'super-secret-vault-token',
|
||||
subdomains: 'myduck',
|
||||
created_by: 'admin'
|
||||
};
|
||||
|
||||
const instance = await DnsProvider.create(payload);
|
||||
|
||||
// 1. Should have called OpenBao set
|
||||
assert.strictEqual(baoConf.set.mock.callCount(), 1);
|
||||
const [path, secrets] = baoConf.set.mock.calls[0].arguments;
|
||||
|
||||
assert.strictEqual(path, `proxy/dns-providers/${instance.id}`);
|
||||
assert.deepStrictEqual(secrets, { token: 'super-secret-vault-token' });
|
||||
|
||||
// 2. The returned instance should have the secret injected back
|
||||
assert.strictEqual(instance.token, 'super-secret-vault-token');
|
||||
|
||||
// 3. get() should fetch public data from Redis and merge secrets from OpenBao
|
||||
// (baoConf.get is already mocked to return from vaultStore)
|
||||
const fetched = await DnsProvider.get(instance.id);
|
||||
assert.strictEqual(fetched.token, 'super-secret-vault-token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||
// browser events on the page (found live, mid browser-automation testing, on
|
||||
// sso-manager-node's equivalent secret-rotate flow) and are visually
|
||||
// inconsistent with the rest of the UI. Every call site was removed in favor
|
||||
// of app.messages.action/confirm/toast and app.modal.open; this test keeps
|
||||
// it that way.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', '..', d));
|
||||
|
||||
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||
|
||||
function walk(dir) {
|
||||
let files = [];
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||
const offenders = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of walk(root)) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
let m;
|
||||
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||
const line = src.slice(0, m.index).split('\n').length;
|
||||
offenders.push(`${path.relative(path.join(__dirname, '..', '..'), file)}:${line} — ${m[2]}(`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(offenders, []);
|
||||
});
|
||||
+1
-2
@@ -37,8 +37,7 @@ module.exports = {
|
||||
// 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: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', 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']},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">
|
||||
// Require login to see this page.
|
||||
app.auth.forceLogin();
|
||||
// Require an admin to see this page.
|
||||
app.auth.forceLogin(['admin']);
|
||||
</script>
|
||||
|
||||
<style type="text/css">
|
||||
@@ -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') %>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
|
||||
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
|
||||
<p class="text-muted mb-4">
|
||||
<% if (error.status === 404) { %>
|
||||
The page you are looking for doesn't exist or has been moved.
|
||||
<% } else { %>
|
||||
An unexpected error occurred. Please try again later.
|
||||
<% } %>
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
|
||||
<i class="fa-solid fa-house me-2"></i>Return to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('bottom') %>
|
||||
+45
-40
@@ -16,6 +16,14 @@
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
function loadGroups() {
|
||||
app.group.list(function(error, data){
|
||||
if(error) return app.messages.action(error, $('#groups-list'), 'danger');
|
||||
$.scope.LocalGroup.empty();
|
||||
for(let g of (data.results || [])) $.scope.LocalGroup.push(g);
|
||||
});
|
||||
}
|
||||
|
||||
// Usernames for the "add member" autocomplete (reuses the permission
|
||||
// subjects endpoint, which is admin-only like this page).
|
||||
function loadUserSuggestions(){
|
||||
@@ -28,15 +36,16 @@
|
||||
|
||||
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, $('#groups-list'), 'danger');
|
||||
$.scope.LocalGroup.remove(name);
|
||||
loadGroups();
|
||||
});
|
||||
}
|
||||
|
||||
function removeMember(group, username){
|
||||
app.group.removeMember(group, username, function(error, data){
|
||||
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
|
||||
// websocket update echoes the new member list.
|
||||
if(error) return app.messages.action(error, $('#groups-list'), 'danger');
|
||||
loadGroups();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,17 +56,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, $('#groups-list'), 'danger');
|
||||
$input.val('');
|
||||
loadGroups();
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
app.group.list(function(error, data){
|
||||
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
|
||||
for(let g of data.results) $.scope.LocalGroup.push(g);
|
||||
});
|
||||
|
||||
loadGroups();
|
||||
loadUserSuggestions();
|
||||
|
||||
$.scope.LocalGroup.__take = function($el){
|
||||
@@ -66,75 +72,73 @@
|
||||
};
|
||||
|
||||
app.subscribe(/^model:LocalGroup:create/, function(data){
|
||||
$.scope.LocalGroup.remove(data.name);
|
||||
$.scope.LocalGroup.unshift(data);
|
||||
loadGroups();
|
||||
});
|
||||
app.subscribe(/^model:LocalGroup:update/, function(data, topic){
|
||||
$.scope.LocalGroup.update(topic.split(':')[3], data);
|
||||
loadGroups();
|
||||
});
|
||||
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
|
||||
$.scope.LocalGroup.remove(topic.split(':')[3]);
|
||||
loadGroups();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<datalist id="groupUsers"></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-users-gear"></i></span>
|
||||
<span class="card-title">Add Group</span>
|
||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div><i class="fa-solid fa-users-gear me-2"></i><strong>Add Group</strong></div>
|
||||
<a href="/docs/access" class="text-reset" 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="group/" onsubmit="formAJAX(this)">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Group name</label>
|
||||
<input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" />
|
||||
<div class="text-muted" style="font-size:.8rem">
|
||||
Lowercased to a slug. Use the name as a Subject (type "group")
|
||||
on the Permissions page.
|
||||
<form action="group/" onsubmit="formAJAX(this)" evalAJAX="loadGroups(); this.reset();">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Group name</label>
|
||||
<input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" required />
|
||||
<div class="form-text">
|
||||
Lowercased to a slug. Use the name as a Subject (type "group") on the Permissions page.
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<button type="submit" class="btn btn-info">Add Group</button>
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-plus me-1"></i> Add Group</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="row row-cols-1 g-3">
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="row row-cols-1 g-3" id="groups-list">
|
||||
<div jq-repeat="LocalGroup" jq-repeat-index="name" style="display:none" class="col">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card shadow-sm border">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<span class="card-icon me-2"><i class="fa-solid fa-users"></i></span>
|
||||
<span class="card-title">{{ name }}</span>
|
||||
<span class="badge text-bg-secondary ms-2">{{ memberCount }} member(s)</span>
|
||||
<span class="card-icon me-2"><i class="fa-solid fa-users text-primary"></i></span>
|
||||
<strong class="me-2">{{ name }}</strong>
|
||||
<span class="badge text-bg-secondary">{{ memberCount }} member(s)</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-auto" onclick="removeGroup('{{name}}')">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<div class="mb-3">
|
||||
{{#memberList}}
|
||||
<span class="badge text-bg-info member-pill me-1 mb-1 fs-6">
|
||||
{{ username }}
|
||||
<i class="fa-solid fa-xmark ms-1" onclick="removeMember('{{group}}','{{username}}')"></i>
|
||||
<i class="fa-solid fa-xmark ms-1 text-danger" onclick="removeMember('{{group}}','{{username}}')"></i>
|
||||
</span>
|
||||
{{/memberList}}
|
||||
{{^memberList}}
|
||||
<span class="text-muted">No members yet.</span>
|
||||
<span class="text-muted small">No members yet.</span>
|
||||
{{/memberList}}
|
||||
</div>
|
||||
<div class="input-group member-add" data-group="{{name}}">
|
||||
<input type="text" class="form-control" list="groupUsers" placeholder="username" autocomplete="off"
|
||||
<div class="input-group input-group-sm member-add" data-group="{{name}}">
|
||||
<input type="text" class="form-control" list="groupUsers" placeholder="Add username..." autocomplete="off"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();addMember(this.nextElementSibling);}" />
|
||||
<button type="button" class="btn btn-success" onclick="addMember(this)">
|
||||
<i class="fa-solid fa-user-plus"></i> Add
|
||||
<i class="fa-solid fa-user-plus me-1"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,4 +147,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
|
||||
+393
-381
@@ -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 & 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 — 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.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 — 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 & 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 — 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.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 — 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') %>
|
||||
|
||||
+98
-113
@@ -4,21 +4,16 @@
|
||||
app.auth.forceLogin();
|
||||
</script>
|
||||
|
||||
<style type="text/css">
|
||||
label.control-label{
|
||||
font-weight: bold;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.card-title{
|
||||
font-weight: bold;
|
||||
}
|
||||
.field-hint{
|
||||
font-size: .8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
function loadPermissions() {
|
||||
app.permission.list(function(error, data){
|
||||
if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
|
||||
$.scope.Permission.empty();
|
||||
for(let p of (data.results || [])) $.scope.Permission.push(p);
|
||||
});
|
||||
}
|
||||
|
||||
// Fill the username/group datalists that back the Subject autocomplete.
|
||||
function loadSubjectSuggestions(){
|
||||
app.permission.subjects(function(error, data){
|
||||
@@ -40,21 +35,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
function permissionAddOpen(){
|
||||
app.modal.open({title: 'Add Permission', bodyHtml:
|
||||
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close(); loadPermissions();">'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Subject type</label>'
|
||||
+ '<select class="form-select" name="subjectType" onchange="subjectTypeChanged(this)">'
|
||||
+ '<option value="user">User</option>'
|
||||
+ '<option value="group">Group</option>'
|
||||
+ '</select>'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Subject (username or group)</label>'
|
||||
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Scope</label>'
|
||||
+ '<select class="form-select" name="scope">'
|
||||
+ '<option value="domain">Domain</option>'
|
||||
+ '<option value="global">Global</option>'
|
||||
+ '</select>'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Domain (for domain scope)</label>'
|
||||
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
|
||||
+ '<div class="form-text text-muted">'
|
||||
+ 'Wildcards: <code>*.example.com</code> matches one label, '
|
||||
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
|
||||
+ '<code>**</code> matches every domain.'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Role</label>'
|
||||
+ '<select class="form-select" name="role">'
|
||||
+ '<option value="viewer">Viewer (read)</option>'
|
||||
+ '<option value="manager">Manager (full over domain)</option>'
|
||||
+ '<option value="admin">Admin (global only)</option>'
|
||||
+ '</select>'
|
||||
+ '</div>'
|
||||
+ '<hr />'
|
||||
+ '<div class="d-flex justify-content-end gap-2">'
|
||||
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
|
||||
+ '<button type="submit" class="btn btn-primary">Add Permission</button>'
|
||||
+ '</div>'
|
||||
+ '</form>',
|
||||
});
|
||||
}
|
||||
|
||||
function removePermission(id){
|
||||
app.permission.remove(id, function(error, data){
|
||||
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger');
|
||||
// The websocket echo removes the row; drop it locally too for snappiness.
|
||||
if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
|
||||
$.scope.Permission.remove(id);
|
||||
loadPermissions();
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
// Existing permissions.
|
||||
app.permission.list(function(error, data){
|
||||
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger');
|
||||
for(let p of data.results) $.scope.Permission.push(p);
|
||||
});
|
||||
|
||||
loadPermissions();
|
||||
loadSubjectSuggestions();
|
||||
|
||||
$.scope.Permission.__take = function($el, item, list){
|
||||
@@ -64,115 +101,63 @@
|
||||
|
||||
// Live updates (model:Permission:*), so adds/removes reflect for everyone.
|
||||
app.subscribe(/^model:Permission:create/, function(data){
|
||||
$.scope.Permission.remove(data.id);
|
||||
$.scope.Permission.unshift(data);
|
||||
loadPermissions();
|
||||
});
|
||||
app.subscribe(/^model:Permission:remove/, function(data, topic){
|
||||
$.scope.Permission.remove(topic.split(':')[3]);
|
||||
loadPermissions();
|
||||
});
|
||||
});
|
||||
</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="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
|
||||
<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 class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa-solid fa-user-shield me-2"></i><strong>Permissions List</strong>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/docs/access" class="text-reset me-3" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="permissionAddOpen()">
|
||||
<i class="fa-solid fa-user-shield me-1"></i> Add Permission
|
||||
</button>
|
||||
</div>
|
||||
</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 class="card-body p-0">
|
||||
<ul class="list-group list-group-flush" id="permissions-list">
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between py-3" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3 fs-4 text-primary">
|
||||
<i class="fa-solid fa-user-check"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-1 fw-bold">
|
||||
<span class="badge bg-secondary me-2">{{ subjectType }}</span> {{ subject }}
|
||||
</h6>
|
||||
<div class="small text-muted">
|
||||
<span class="me-3"><strong>Scope:</strong> {{ scope }}</span>
|
||||
<span class="me-3"><strong>Domain:</strong> <code>{{ domain }}</code></span>
|
||||
<span><strong>Role:</strong> <span class="badge bg-info text-dark">{{ role }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Role</label>
|
||||
<select class="form-control" name="role">
|
||||
<option value="viewer">Viewer (read)</option>
|
||||
<option value="manager">Manager (full over domain)</option>
|
||||
<option value="admin">Admin (global only)</option>
|
||||
</select>
|
||||
</div>
|
||||
<hr />
|
||||
<button type="submit" class="btn btn-info">Add Permission</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-lg">
|
||||
|
||||
<div class="card-header text-center">
|
||||
<span class="card-icon float-start">
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
</span>
|
||||
<span class="card-title">Permissions</span>
|
||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Type</th>
|
||||
<th>Subject</th>
|
||||
<th>Scope</th>
|
||||
<th>Domain</th>
|
||||
<th>Role</th>
|
||||
<th>Delete</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none">
|
||||
<td class="align-middle">{{ subjectType }}</td>
|
||||
<td class="align-middle">{{ subject }}</td>
|
||||
<td class="align-middle">{{ scope }}</td>
|
||||
<td class="align-middle">{{ domain }}</td>
|
||||
<td class="align-middle">{{ role }}</td>
|
||||
<td class="align-middle">
|
||||
<button type="button" class="btn btn-danger" onclick="removePermission('{{id}}')">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
Delete
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removePermission('{{id}}')">
|
||||
<i class="fa-solid fa-trash me-1"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
|
||||
+153
-94
@@ -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 <token></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') %>
|
||||
|
||||
+13
-3
@@ -21,9 +21,11 @@
|
||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||
<script type="text/javascript" src='/static-modules/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>
|
||||
@@ -47,7 +49,7 @@
|
||||
</ul>
|
||||
<div class="form-inline mt-2 mt-md-0">
|
||||
<% if(ui.profileUrl){ %>
|
||||
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</a>
|
||||
<% } else { %>
|
||||
@@ -80,16 +82,24 @@
|
||||
</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');
|
||||
}
|
||||
|
||||
|
||||
+67
-69
@@ -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') %>
|
||||
|
||||
Reference in New Issue
Block a user