Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aeccbcbbe9 | |||
| 15b154fc8d | |||
| a44d7ef7ab | |||
| 144efdb5dd | |||
| b54a738524 | |||
| 8bf963f48b | |||
| 30835baaeb | |||
| 2319ac3a0e | |||
| 8c0eaf0d07 | |||
| 8c81128235 | |||
| 4f23d301bf | |||
| be666f5b2f | |||
| 2bfba93e00 | |||
| 8cee583da3 | |||
| b5fab6c91a | |||
| c7ec65e0d9 | |||
| 3f2ef57c78 | |||
| c0e04d1a56 |
@@ -6,6 +6,62 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+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' } }
|
||||
|
||||
@@ -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
+72
-97
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.5.3",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxy-api",
|
||||
"version": "1.5.3",
|
||||
"version": "1.9.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.6",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
@@ -21,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",
|
||||
@@ -297,6 +298,18 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/bao-conf": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.0.tgz",
|
||||
"integrity": "sha512-HxB2ohFuDKbwTfNh5dXCot0dd6qoP+3Ebz1xKH0eOhKNVNMjHU1p7XZv2VTe+VnHn+DV22Eh8lAgWxnX/pkXUw==",
|
||||
"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",
|
||||
@@ -310,9 +323,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/frontend": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
|
||||
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
|
||||
"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"
|
||||
@@ -446,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",
|
||||
@@ -517,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"
|
||||
@@ -540,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",
|
||||
@@ -560,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": {
|
||||
@@ -848,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": {
|
||||
@@ -1071,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",
|
||||
@@ -1483,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",
|
||||
@@ -1806,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",
|
||||
@@ -2313,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": {
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.6.0",
|
||||
"version": "1.13.0",
|
||||
"author": [
|
||||
{
|
||||
"name": "William Mantly",
|
||||
@@ -22,8 +22,9 @@
|
||||
"@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.6",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
@@ -31,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",
|
||||
|
||||
@@ -7,6 +7,12 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||
sticky element offset itself below both fixed elements via
|
||||
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||
viewport's true top:0. */
|
||||
--sw-content-offset: 4.5rem;
|
||||
}
|
||||
|
||||
#spa-shell {
|
||||
|
||||
@@ -584,10 +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,
|
||||
escapeHtml: escapeHtml,
|
||||
revealItem: revealItem,
|
||||
}
|
||||
})(app);
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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') %>
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<datalist id="groupUsers"></datalist>
|
||||
|
||||
<div class="row" style="display:none">
|
||||
@@ -143,4 +144,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
|
||||
@@ -752,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">
|
||||
@@ -886,5 +887,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('bottom') %>
|
||||
|
||||
@@ -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){
|
||||
app.permission.remove(id, function(error, data){
|
||||
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
|
||||
@@ -66,6 +110,7 @@
|
||||
app.subscribe(/^model:Permission:create/, function(data){
|
||||
$.scope.Permission.remove(data.id);
|
||||
$.scope.Permission.unshift(data);
|
||||
setTimeout(function(){ app.util.revealItem($('#permission-row-' + data.id)); }, 100);
|
||||
});
|
||||
app.subscribe(/^model:Permission:remove/, function(data, topic){
|
||||
$.scope.Permission.remove(topic.split(':')[3]);
|
||||
@@ -73,67 +118,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<datalist id="subjectUsers"></datalist>
|
||||
<datalist id="subjectGroups"></datalist>
|
||||
|
||||
<div class="row" style="display:none">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-lg">
|
||||
|
||||
<div class="card-header text-center">
|
||||
<span class="card-icon float-start">
|
||||
<i class="fa-solid fa-user-shield"></i>
|
||||
</span>
|
||||
<span class="card-title">Add Permission</span>
|
||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<form action="permission/" onsubmit="formAJAX(this)">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Subject type</label>
|
||||
<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">
|
||||
<option value="user">User</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Subject (username or group)</label>
|
||||
<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Scope</label>
|
||||
<select class="form-control" name="scope">
|
||||
<option value="domain">Domain</option>
|
||||
<option value="global">Global</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Domain (for domain scope)</label>
|
||||
<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />
|
||||
<div class="field-hint text-muted">
|
||||
Wildcards: <code>*.example.com</code> matches one label,
|
||||
<code>**.example.com</code> matches any depth (incl. the apex),
|
||||
<code>**</code> matches every domain.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Role</label>
|
||||
<select class="form-control" name="role">
|
||||
<option value="viewer">Viewer (read)</option>
|
||||
<option value="manager">Manager (full over domain)</option>
|
||||
<option value="admin">Admin (global only)</option>
|
||||
</select>
|
||||
</div>
|
||||
<hr />
|
||||
<button type="submit" class="btn btn-info">Add Permission</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-lg">
|
||||
|
||||
<div class="card-header text-center">
|
||||
@@ -141,38 +131,41 @@
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
</span>
|
||||
<span class="card-title">Permissions</span>
|
||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<span class="float-end">
|
||||
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="permissionAddOpen()">
|
||||
<i class="fa-solid fa-user-shield"></i>
|
||||
Add Permission
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Type</th>
|
||||
<th>Subject</th>
|
||||
<th>Scope</th>
|
||||
<th>Domain</th>
|
||||
<th>Role</th>
|
||||
<th>Delete</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none">
|
||||
<td class="align-middle">{{ subjectType }}</td>
|
||||
<td class="align-middle">{{ subject }}</td>
|
||||
<td class="align-middle">{{ scope }}</td>
|
||||
<td class="align-middle">{{ domain }}</td>
|
||||
<td class="align-middle">{{ role }}</td>
|
||||
<td class="align-middle">
|
||||
<button type="button" class="btn btn-danger" onclick="removePermission('{{id}}')">
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 row-cols-lg-2 g-3" id="permission-cards">
|
||||
<div class="col" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-2">
|
||||
<span class="badge text-bg-secondary">{{ subjectType }}</span>
|
||||
{{ subject }}
|
||||
</h6>
|
||||
<dl class="row mb-2 small">
|
||||
<dt class="col-4">Scope</dt><dd class="col-8">{{ scope }}</dd>
|
||||
<dt class="col-4">Domain</dt><dd class="col-8">{{ domain }}</dd>
|
||||
<dt class="col-4">Role</dt><dd class="col-8">{{ role }}</dd>
|
||||
</dl>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
|
||||
+143
-86
@@ -69,6 +69,7 @@
|
||||
});
|
||||
</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){
|
||||
@@ -171,10 +162,12 @@
|
||||
function tableAJAX(){
|
||||
app.apiToken.list(function(error, data){
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,74 +185,138 @@
|
||||
if(!ok) return;
|
||||
app.apiToken.rotate({id: id}, function(error, data){
|
||||
if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
|
||||
showSecret(data.token);
|
||||
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') %>
|
||||
|
||||
@@ -82,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');
|
||||
}
|
||||
|
||||
|
||||
+65
-67
@@ -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.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');
|
||||
@@ -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(){
|
||||
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