Compare commits

...

18 Commits

Author SHA1 Message Date
wmantly 4aa994121a Merge pull request #201 from theta42/fix/bao-conf-1.0.1
v1.13.1: bump @simpleworkjs/bao-conf to 1.0.1 (fix standalone boot crash)
2026-08-01 12:50:47 -04:00
wmantly 2e92f58750 v1.13.1: bump @simpleworkjs/bao-conf to 1.0.1
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing boot
(.catch -> process.exit(1)) in any deployment without an OpenBao sidecar
(standalone Docker, bare metal). 1.0.1 makes init() fail-soft on a
missing token (warn + continue from CONF_SECRETS). The theta-env stack
is unaffected (it always sets a scoped VAULT_TOKEN).

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

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


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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William Mantly <wmantly@gmail.com>
2026-08-01 11:02:25 -04:00
wmantly 30835baaeb Merge pull request #197 from theta42/release-v1.12.0
chore(release): v1.12.0
2026-08-01 01:21:50 -04:00
wmantly 2319ac3a0e chore(release): v1.12.0 2026-08-01 01:20:22 -04:00
wmantly 8c0eaf0d07 Merge pull request #196 from theta42/release/1.9.0
Release 1.9.0: cross-app super admin, modal add-buttons, LE key persistence
2026-07-30 12:03:01 -04:00
wmantly 8c81128235 Release 1.9.0: cross-app super admin, modal add-buttons, LE key persistence 2026-07-30 12:01:50 -04:00
wmantly 4f23d301bf Merge pull request #195 from theta42/feat/super-admin-add-buttons-le-persist
Recognize app_super_admin; add-user/add-permission as modal buttons; persist LE key
2026-07-30 12:00:27 -04:00
wmantly be666f5b2f Recognize app_super_admin; add-user/add-permission as modal buttons; persist LE key
- app_super_admin is a new cross-app LDAP group (also recognized by
  sso-manager-node and jump-host): added to conf.auth.adminGroups so
  members are always global admins here, same as the existing anti-lockout
  adminUsers/adminGroups mechanism.
- Users and Permissions pages: the always-visible sidebar "Add" forms are
  now an "Add User"/"Add Permission" button in the list header that opens
  an app.modal dialog, matching the hosts.ejs convention.
- The Let's Encrypt ACME account key now defaults to the already-persisted
  /data volume (models/host.js) instead of a CWD-relative path
  (./le_key.cert -> /app/le_key.cert in the container), which was lost on
  every image rebuild. Falls back to the old relative path when /data isn't
  present (e.g. local dev outside docker).
2026-07-30 11:57:44 -04:00
wmantly 2bfba93e00 Merge pull request #194 from theta42/release/1.8.0
Release 1.8.0: page width standardization, card layouts, external-user marking
2026-07-29 22:13:59 -04:00
wmantly 8cee583da3 Release 1.8.0: page width standardization, card layouts, external-user marking 2026-07-29 22:12:30 -04:00
wmantly b5fab6c91a Merge pull request #193 from theta42/feat/ui-standardization-external-users
Standardize page width, card layouts; mark SSO users external and read-only
2026-07-29 22:11:39 -04:00
wmantly c7ec65e0d9 Standardize page width, card layouts; mark SSO users external and read-only
- All pages now wrap their content in <div class="container mt-4">,
  matching sso-manager-node's width instead of rendering full-bleed inside
  the fluid shell.
- Users and Permissions pages converted from bare <table>s to the same
  card-grid convention already used on the Groups page.
- Users backed by SSO/OIDC login (backing === 'oidc', set by the redis
  user model's JIT-provisioning path) are now marked "External (SSO)" and
  their password-change control is hidden; PUT /password/:username also
  rejects with 403 server-side for such users. Deletion stays allowed.
  Redis-backend only -- LDAP/PAM deployments have no per-record marker for
  this today.
- app-base.js (byte-identical across the 3 apps): added
  app.util.revealItem(), wired into the Users/Permissions create flows.
- Bumped @simpleworkjs/frontend to ^0.2.7.
2026-07-29 22:09:50 -04:00
17 changed files with 438 additions and 316 deletions
+59
View File
@@ -6,6 +6,65 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [1.13.1] - 2026-08-01
### Fixed
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
proxy exit at boot in any deployment without an OpenBao sidecar (standalone
Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing token (warn
+ continue from `CONF_SECRETS`), matching the documented contract. The
theta-env stack is unaffected (it always sets a scoped `VAULT_TOKEN`).
## [1.13.0] - 2026-08-01
### Changed
- **Secrets now load from OpenBao at boot** via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy
authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy `proxy`
read-only on its own path), never the root token. Because the OIDC
`clientSecret` is captured at require time inside `createOidcClient` (during
`require('../models')`, which `require('../app')` triggers transitively),
`bin/www` now defers `require('../app')` until after `bao-conf.init()`
resolves. Fail-soft: if OpenBao is unreachable, boot continues from
`CONF_SECRETS`. The `config/proxy-secrets.js` file is now an operator-edit
seed artifact (gitignored); OpenBao is authoritative. See theta-env's
[Secrets docs](https://theta42.github.io/theta-env/secrets/).
- Bumped package version to track the release tag.
## [1.12.1] - 2026-08-01
### Changed
- Bumped `body-parser` 2.2.2 → 2.3.0 (Dependabot #175).
- Bumped `ejs` and `brace-expansion` (Dependabot #179, security maintenance).
## [1.12.0] - 2026-08-01
### Fixed
- Changed UNIX socket permission in `unix_socket_json.js` to `666` so OpenResty Nginx workers running as `nobody` can resolve targets properly.
## [1.9.0] - 2026-07-30
### Added
- **`app_super_admin` cross-app group** recognized as a global admin (`conf.auth.adminGroups`), same group also recognized by sso-manager-node and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
### Changed
- **Users and Permissions pages**: the always-visible sidebar "Add" forms are now an "Add User"/"Add Permission" button in the list header that opens an `app.modal` dialog, matching the hosts.ejs convention.
- **Let's Encrypt ACME account key** now defaults to the already-persisted `/data` volume instead of a CWD-relative path (`./le_key.cert` -> `/app/le_key.cert` in the container), which was lost on every image rebuild.
## [1.8.0] - 2026-07-28
### Added
- **Users backed by SSO/OIDC login are now marked "External (SSO)"** and read-only: their password-change control is hidden, and `PUT /password/:username` rejects with 403 server-side. Deletion stays allowed. Redis user-backend only.
- **`app.util.revealItem()`** (shared `app-base.js`): scrolls a just-added/-edited element into view and flashes its background. Wired into the Users/Permissions create flows.
### Changed
- **All pages now wrap their content in a standard-width container**, matching sso-manager-node instead of rendering full-bleed.
- **Users and Permissions pages converted from bare `<table>`s to the card-grid convention** already used on the Groups page.
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
## [1.7.0] - 2026-07-28 ## [1.7.0] - 2026-07-28
### Added ### Added
+17
View File
@@ -162,6 +162,23 @@ docker compose exec proxy tail -f /var/log/nginx/error.log
docker compose logs --tail=200 --since=10m proxy docker compose logs --tail=200 --since=10m proxy
``` ```
## Secrets
Secrets are loaded from **OpenBao** at boot via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy's OIDC
`clientSecret` is captured at require time (inside `createOidcClient` during
`require('../models')`), so `bin/www` runs `bao-conf.init()` **before**
`require('../app')` (which transitively loads models). Fail-soft: if OpenBao is
unreachable, boot continues from `CONF_SECRETS`. The proxy authenticates to
OpenBao with the scoped `VAULT_TOKEN` (env, policy `proxy` — read only
`secret/proxy/conf`), never the root token.
The `config/proxy-secrets.js` file is an operator-edit seed artifact
(gitignored); the bootstrap writes the generated OAuth client creds into
OpenBao, which is authoritative. For the full architecture see theta-env's
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
## Manual Installation ## Manual Installation
For manual installation or other distributions, see the detailed steps below. For manual installation or other distributions, see the detailed steps below.
+78 -65
View File
@@ -4,34 +4,91 @@
* Module dependencies. * Module dependencies.
*/ */
var app = require('../app');
var debug = require('debug')('proxy-api:server');
var http = require('http');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const debug = require('debug')('proxy-api:server');
const http = require('http');
/** // @simpleworkjs/conf loads ./config/proxy-secrets.js synchronously, then
* Get port from environment and store in Express. // @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.
*/
/** var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
* Create HTTP server. app.set('port', port);
*/
var server = http.createServer(app); /**
* Create HTTP server.
*/
var io = require('socket.io')(server); var server = http.createServer(app);
app.io = io;
/** var io = require('socket.io')(server);
* Listen on provided port, on all network interfaces. app.io = io;
*/
server.listen(port); /**
server.on('error', onError); * Listen on provided port, on all network interfaces.
server.on('listening', onListening); */
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. * Normalize a port into a number, string, or false.
@@ -51,48 +108,4 @@ function normalizePort(val) {
} }
return false; 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
View File
@@ -49,7 +49,8 @@ module.exports = {
// Per-user overrides are Grant records managed in the app. // Per-user overrides are Grant records managed in the app.
auth: { auth: {
// Members of these SSO/LDAP groups are always global admins. // Members of these SSO/LDAP groups are always global admins.
adminGroups: [], // app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
adminGroups: ['app_super_admin'],
// Optional default role mapping for groups, e.g. // Optional default role mapping for groups, e.g.
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } } // { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } } // { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
+13
View File
@@ -9,10 +9,23 @@ const tldExtract = require('tld-extract').parse_host;
const LetsEncrypt = require('../utils/letsencrypt'); const LetsEncrypt = require('../utils/letsencrypt');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const fs = require('fs');
const path = require('path');
// Defaults to the same persisted volume Redis uses (/data, see
// docker-entrypoint.sh's REDIS_DATA_DIR) instead of the old CWD-relative
// default (./le_key.cert -> /app/le_key.cert), which lives in the
// container's writable layer and was lost on every rebuild. Falls back to
// the old relative path when /data isn't present (e.g. local dev outside
// docker), so it stays writable there too.
const dataDir = process.env.REDIS_DATA_DIR || '/data';
const accountKeyPath = fs.existsSync(dataDir) ? path.join(dataDir, 'le_key.cert') : './le_key.cert';
const letsEncrypt = new LetsEncrypt({ const letsEncrypt = new LetsEncrypt({
directoryUrl: conf.environment === "production" ? directoryUrl: conf.environment === "production" ?
LetsEncrypt.AcmeClient.directory.letsencrypt.production : LetsEncrypt.AcmeClient.directory.letsencrypt.production :
LetsEncrypt.AcmeClient.directory.letsencrypt.staging, LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
accountKeyPath,
}); });
class Host extends Table{ class Host extends Table{
+72 -97
View File
@@ -1,19 +1,20 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.5.3", "version": "1.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.5.3", "version": "1.13.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.6", "@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
@@ -21,7 +22,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
"ejs": "^3.1.10", "ejs": "^6.0.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
@@ -297,6 +298,18 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/conf": { "node_modules/@simpleworkjs/conf": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
@@ -310,9 +323,9 @@
} }
}, },
"node_modules/@simpleworkjs/frontend": { "node_modules/@simpleworkjs/frontend": {
"version": "0.2.6", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==", "integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@@ -446,12 +459,6 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -517,20 +524,20 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^2.0.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.1", "qs": "^6.15.2",
"raw-body": "^3.0.1", "raw-body": "^3.0.2",
"type-is": "^2.0.1" "type-is": "^2.1.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -540,6 +547,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.8", "version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -560,16 +580,16 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.7", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
@@ -848,18 +868,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/ejs": { "node_modules/ejs": {
"version": "3.1.10", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": { "bin": {
"ejs": "bin/cli.js" "ejs": "bin/cli.js"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.12.18"
} }
}, },
"node_modules/encodeurl": { "node_modules/encodeurl": {
@@ -1071,42 +1088,6 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1483,23 +1464,6 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jq-repeat": { "node_modules/jq-repeat": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz", "resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
@@ -1806,12 +1770,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
@@ -2313,17 +2271,34 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^2.0.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"mime-types": "^3.0.0" "mime-types": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/undefsafe": { "node_modules/undefsafe": {
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.7.0", "version": "1.13.1",
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
@@ -22,8 +22,9 @@
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.6", "@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
@@ -31,7 +32,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
"ejs": "^3.1.10", "ejs": "^6.0.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
+6
View File
@@ -7,6 +7,12 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
} }
#spa-shell { #spa-shell {
+21
View File
@@ -584,10 +584,31 @@ app.util = (function(app){
document.body.removeChild(element); document.body.removeChild(element);
} }
// Scroll a just-added/-edited element into view and flash its
// background, so the user's eye lands on the row that changed instead of
// it silently appearing/updating somewhere off-screen. Takes a jQuery
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
function revealItem(el){
var node = el && el.jquery ? el[0] : el;
if (!node) return;
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({behavior: 'smooth', block: 'center'});
}
var prevTransition = node.style.transition;
var prevBg = node.style.backgroundColor;
node.style.transition = 'background-color 1.5s ease';
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
setTimeout(function(){
node.style.backgroundColor = prevBg;
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
}, 300);
}
return { return {
downloadFile: downloadFile, downloadFile: downloadFile,
getUrlParameter: getUrlParameter, getUrlParameter: getUrlParameter,
escapeHtml: escapeHtml, escapeHtml: escapeHtml,
revealItem: revealItem,
} }
})(app); })(app);
+10 -1
View File
@@ -81,11 +81,20 @@ router.put('/password', async function(req, res, next){
} }
}); });
// Admin: reset another user's password. // Admin: reset another user's password. Blocked for SSO/OIDC-provisioned
// accounts (backing === 'oidc') -- they authenticate through the IdP, not a
// local password, so resetting one here would be a no-op at best and a
// false sense of control at worst. Only applies to the redis user backend;
// LDAP/PAM-backed deployments have no per-record marker for this.
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){ router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
try{ try{
validatePassword(req.body.password); validatePassword(req.body.password);
let user = await User.get(req.params.username); let user = await User.get(req.params.username);
if(user.backing === 'oidc'){
let e = new Error('Cannot set a password for an SSO-authenticated user.');
e.status = 403;
throw e;
}
return res.json({results: await user.setPassword(req.body)}); return res.json({results: await user.setPassword(req.body)});
}catch(error){ }catch(error){
next(error); next(error);
+2
View File
@@ -113,6 +113,7 @@
}); });
</script> </script>
<div class="container mt-4">
<div class="row mb-3" style="display:none"> <div class="row mb-3" style="display:none">
<div class="col-md-3"> <div class="col-md-3">
<div class="card shadow-lg mb-3"> <div class="card shadow-lg mb-3">
@@ -299,4 +300,5 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('bottom') %> <%- include('bottom') %>
+2
View File
@@ -78,6 +78,7 @@
}); });
</script> </script>
<div class="container mt-4">
<datalist id="groupUsers"></datalist> <datalist id="groupUsers"></datalist>
<div class="row" style="display:none"> <div class="row" style="display:none">
@@ -143,4 +144,5 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('bottom') %> <%- include('bottom') %>
+2
View File
@@ -752,6 +752,7 @@
}); });
</script> </script>
<div class="container mt-4">
<div class="row" style="display:none"> <div class="row" style="display:none">
<div class="col-12"> <div class="col-12">
<div class="card shadow-lg hostListPanel"> <div class="card shadow-lg hostListPanel">
@@ -886,5 +887,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('bottom') %> <%- include('bottom') %>
+74 -81
View File
@@ -40,6 +40,50 @@
} }
} }
function permissionAddOpen(){
app.modal.open({title: 'Add Permission', bodyHtml:
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close();">'
+ '<div class="form-group">'
+ '<label class="control-label">Subject type</label>'
+ '<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">'
+ '<option value="user">User</option>'
+ '<option value="group">Group</option>'
+ '</select>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Subject (username or group)</label>'
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Scope</label>'
+ '<select class="form-control" name="scope">'
+ '<option value="domain">Domain</option>'
+ '<option value="global">Global</option>'
+ '</select>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Domain (for domain scope)</label>'
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
+ '<div class="field-hint text-muted">'
+ 'Wildcards: <code>*.example.com</code> matches one label, '
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
+ '<code>**</code> matches every domain.'
+ '</div>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Role</label>'
+ '<select class="form-control" name="role">'
+ '<option value="viewer">Viewer (read)</option>'
+ '<option value="manager">Manager (full over domain)</option>'
+ '<option value="admin">Admin (global only)</option>'
+ '</select>'
+ '</div>'
+ '<hr />'
+ '<button type="submit" class="btn btn-info">Add Permission</button>'
+ '</form>',
});
}
function removePermission(id){ function removePermission(id){
app.permission.remove(id, function(error, data){ app.permission.remove(id, function(error, data){
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger'); if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
@@ -66,6 +110,7 @@
app.subscribe(/^model:Permission:create/, function(data){ app.subscribe(/^model:Permission:create/, function(data){
$.scope.Permission.remove(data.id); $.scope.Permission.remove(data.id);
$.scope.Permission.unshift(data); $.scope.Permission.unshift(data);
setTimeout(function(){ app.util.revealItem($('#permission-row-' + data.id)); }, 100);
}); });
app.subscribe(/^model:Permission:remove/, function(data, topic){ app.subscribe(/^model:Permission:remove/, function(data, topic){
$.scope.Permission.remove(topic.split(':')[3]); $.scope.Permission.remove(topic.split(':')[3]);
@@ -73,67 +118,12 @@
}); });
</script> </script>
<div class="container mt-4">
<datalist id="subjectUsers"></datalist> <datalist id="subjectUsers"></datalist>
<datalist id="subjectGroups"></datalist> <datalist id="subjectGroups"></datalist>
<div class="row" style="display:none"> <div class="row" style="display:none">
<div class="col-md-4"> <div class="col-12">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-user-shield"></i>
</span>
<span class="card-title">Add Permission</span>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="permission/" onsubmit="formAJAX(this)">
<div class="form-group">
<label class="control-label">Subject type</label>
<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Subject (username or group)</label>
<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />
</div>
<div class="form-group">
<label class="control-label">Scope</label>
<select class="form-control" name="scope">
<option value="domain">Domain</option>
<option value="global">Global</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Domain (for domain scope)</label>
<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />
<div class="field-hint text-muted">
Wildcards: <code>*.example.com</code> matches one label,
<code>**.example.com</code> matches any depth (incl. the apex),
<code>**</code> matches every domain.
</div>
</div>
<div class="form-group">
<label class="control-label">Role</label>
<select class="form-control" name="role">
<option value="viewer">Viewer (read)</option>
<option value="manager">Manager (full over domain)</option>
<option value="admin">Admin (global only)</option>
</select>
</div>
<hr />
<button type="submit" class="btn btn-info">Add Permission</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header text-center"> <div class="card-header text-center">
@@ -141,38 +131,41 @@
<i class="fa-solid fa-list-check"></i> <i class="fa-solid fa-list-check"></i>
</span> </span>
<span class="card-title">Permissions</span> <span class="card-title">Permissions</span>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a> <span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn btn-sm btn-success" onclick="permissionAddOpen()">
<i class="fa-solid fa-user-shield"></i>
Add Permission
</button>
</span>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive"> <div class="card-body">
<table class="card-body table table-striped" style="margin-bottom:0"> <div class="row row-cols-1 row-cols-lg-2 g-3" id="permission-cards">
<thead> <div class="col" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<th>Type</th> <div class="card shadow-sm h-100">
<th>Subject</th> <div class="card-body">
<th>Scope</th> <h6 class="mb-2">
<th>Domain</th> <span class="badge text-bg-secondary">{{ subjectType }}</span>
<th>Role</th> {{ subject }}
<th>Delete</th> </h6>
</thead> <dl class="row mb-2 small">
<tbody> <dt class="col-4">Scope</dt><dd class="col-8">{{ scope }}</dd>
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none"> <dt class="col-4">Domain</dt><dd class="col-8">{{ domain }}</dd>
<td class="align-middle">{{ subjectType }}</td> <dt class="col-4">Role</dt><dd class="col-8">{{ role }}</dd>
<td class="align-middle">{{ subject }}</td> </dl>
<td class="align-middle">{{ scope }}</td> <button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
<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> <i class="fa-solid fa-trash"></i>
Delete Delete
</button> </button>
</td> </div>
</tr> </div>
</tbody> </div>
</table> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('bottom') %> <%- include('bottom') %>
+2
View File
@@ -69,6 +69,7 @@
}); });
</script> </script>
<div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-lg" id="profile-card"> <div class="card shadow-lg" id="profile-card">
@@ -317,4 +318,5 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('bottom') %> <%- include('bottom') %>
+9 -1
View File
@@ -82,16 +82,24 @@
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
// --sw-content-offset tracks the same height as #spa-shell's margin-top
// (fixed navbar, plus the update banner while it's shown), so any
// in-page sticky element (e.g. a sticky search/sort bar) can offset
// itself below both fixed elements via `top: var(--sw-content-offset)`
// instead of colliding with them at the viewport's true top:0.
function showUpdateBanner(){ function showUpdateBanner(){
let $nav = $('nav.fixed-top'); let $nav = $('nav.fixed-top');
let $banner = $('#update-banner'); let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show(); $banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px'); let offset = $nav.outerHeight() + $banner.outerHeight();
$('#spa-shell').css('margin-top', offset + 'px');
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
} }
function dismissUpdateBanner(){ function dismissUpdateBanner(){
$('#update-banner').hide(); $('#update-banner').hide();
$('#spa-shell').css('margin-top', ''); $('#spa-shell').css('margin-top', '');
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
sessionStorage.setItem('update-banner-dismissed', '1'); sessionStorage.setItem('update-banner-dismissed', '1');
} }
+65 -67
View File
@@ -20,11 +20,16 @@
<script type="text/javascript"> <script type="text/javascript">
function processUser(user){
user.isExternal = user.backing === 'oidc';
return user;
}
function populateUsers(actionMessage){ function populateUsers(actionMessage){
app.user.list(function(error, data){ app.user.list(function(error, data){
if(error) return app.messages.action(error, $.scope.users.$this, 'danger'); if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
for(let user of data.results){ for(let user of data.results){
$.scope.users.push(user); $.scope.users.push(processUser(user));
} }
$.scope.users.__put = function($el, item, list){ $.scope.users.__put = function($el, item, list){
$el.addClass('bg-success'); $el.addClass('bg-success');
@@ -42,6 +47,32 @@
}); });
} }
function userAddOpen(){
app.modal.open({title: 'Add New User', bodyHtml:
'<form action="user/" onsubmit="formAJAX(this)" evalAJAX="'
+ '$.scope.users.splice(0, 0, processUser(data));'
+ 'setTimeout(function(){ app.util.revealItem($(\'#user-row-\' + data.username)); }, 100);'
+ 'app.modal.close();'
+ '">'
+ '<input type="hidden" class="form-control" name="delete" value="false" />'
+ '<div class="form-group">'
+ '<label class="control-label">User-name</label>'
+ '<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Password</label>'
+ '<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Again</label>'
+ '<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>'
+ '</div>'
+ '<hr />'
+ '<button type="submit" class="btn btn-info">Add</button>'
+ '</form>',
});
}
$(document).ready(function(){ $(document).ready(function(){
populateUsers(); //populate the table populateUsers(); //populate the table
@@ -54,50 +85,9 @@
}); });
</script> </script>
<div class="container mt-4">
<div class="row" style="display:none"> <div class="row" style="display:none">
<div class="col-md-4"> <div class="col-12">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-user-plus"></i>
</span>
<span class="card-title">
Add New User
</span>
<span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="user/" onsubmit="formAJAX(this)" evalAJAX="
$.scope.users.splice(0, 0, data);
">
<input type="hidden" class="form-control" name="delete" value="false" />
<div class="form-group">
<label class="control-label">User-name</label>
<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />
</div>
<div class="form-group">
<label class="control-label">Password</label>
<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>
</div>
<div class="form-group">
<label class="control-label">Again</label>
<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>
</div>
<hr />
<button type="submit" class="btn btn-info">
Add
</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header text-center"> <div class="card-header text-center">
@@ -109,42 +99,50 @@
</span> </span>
<span class="float-end"> <span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a> <a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-circle-minus"></i> <button type="button" class="btn btn-sm btn-success" onclick="userAddOpen()">
<i class="fa-solid fa-user-plus"></i>
Add User
</button>
</span> </span>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive"> <div class="card-body">
<table class="card-body table table-striped" style="margin-bottom:0"> <div class="row row-cols-1 row-cols-lg-2 g-3" id="user-cards">
<thead> <div class="col" jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
<th>Name</th> <div class="card shadow-sm h-100">
<th>Password</th> <div class="card-body">
<th>Delete</th> <h6 class="d-flex align-items-center mb-2">
</thead> <i class="fa-solid fa-user me-2"></i>
<tbody> {{ username }}
<tr jq-repeat="users" jq-repeat-index="username" style="display:none" > {{#isExternal}}
<td class="align-middle"> <span class="badge text-bg-secondary ms-2" title="Provisioned via SSO login; no local password to manage here.">
{{ username }} <i class="fa-solid fa-cloud"></i> External (SSO)
</td> </span>
<td> {{/isExternal}}
</h6>
<form class="input-group" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)"> {{^isExternal}}
<input type="password" name="password" class="form-control" placeholder="Change {{ username }} password" aria-label="Update password"> <form class="input-group input-group-sm mb-2" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
<input type="password" name="password" class="form-control" placeholder="Change password" aria-label="Update password">
<button class="btn btn-warning" type="submit">Change</button> <button class="btn btn-warning" type="submit">Change</button>
</form> </form>
{{/isExternal}}
{{#isExternal}}
<p class="text-muted small mb-2">Authenticates via SSO -- cannot be edited here.</p>
{{/isExternal}}
</td> <button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removeUser('{{username}}')">
<i class="fa-solid fa-user-slash"></i> <i class="fa-solid fa-user-slash"></i>
Delete Delete
</button> </button>
</td> </div>
</tr> </div>
</tbody> </div>
</table> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('bottom') %> <%- include('bottom') %>