Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d32fb3044 |
@@ -1,24 +1,3 @@
|
|||||||
## v1.35.0
|
|
||||||
- feat: **permission entries can be edited.** The Permissions page only offered Delete, so changing a role or scope meant removing the grant and re-adding it from memory. New `PUT /api/permission/:id` plus an Edit modal pre-filled from the record.
|
|
||||||
- fix: a permission's id is derived from (subjectType, subject, scope, domain), so changing any of those is a *different* record, not an update. The endpoint creates the new grant and removes the superseded one in that order, so an edit can never leave the old grant behind still conferring access.
|
|
||||||
|
|
||||||
## v1.34.0
|
|
||||||
- feat: the per-host SSO **Allowed groups** field now autocompletes from the SSO directory's groups. Suggestions previously came only from local groups, permission subjects and `conf.auth` maps — none of which can match an SSO-gated host, because its allow-list is checked against the `groups` claim the SSO issues. New `conf.sso` block (`url` + read-only `apiToken`, minted by theta-suite's bootstrap); results are cached for 5 minutes and the endpoint degrades silently to the old local-only list when unset.
|
|
||||||
- fix: the SSO group lookup authenticates with `Authorization: Bearer <token>`, not the `auth-token` header — the latter is for browser session UUIDs and would be rejected for a minted API token.
|
|
||||||
- docs: `docs/concepts-hosts.md` gains a "Putting a host behind single sign-on" section covering the per-host `/__proxy_auth` flow, the wildcard redirect URI the IdP must allow, and where group suggestions come from.
|
|
||||||
- docs: `secrets.js.example` documents the new `sso` block.
|
|
||||||
|
|
||||||
## v1.33.0
|
|
||||||
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare JSON/text response
|
|
||||||
- feat: DNS page is now admin-only (hidden from non-admins; API already admin-gated)
|
|
||||||
- feat: navbar — username no longer underlined; only the active link is bold + underlined
|
|
||||||
|
|
||||||
## v1.13.3
|
|
||||||
- fix: remove missing DEPLOYMENT.md and docs/ from Docker build context
|
|
||||||
|
|
||||||
## v1.13.2
|
|
||||||
- chore: Update CI pipeline integration
|
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. Format loosely
|
All notable changes to this project are documented here. Format loosely
|
||||||
@@ -27,45 +6,6 @@ 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
|
## [1.9.0] - 2026-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ COPY nodejs/api.md ./api.md
|
|||||||
# docs/ mirrors the repo's own top-level docs/ folder.
|
# docs/ mirrors the repo's own top-level docs/ folder.
|
||||||
COPY README.md /README.md
|
COPY README.md /README.md
|
||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
|
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||||
|
COPY docs /docs
|
||||||
|
|
||||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|||||||
@@ -162,23 +162,6 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Example secrets configuration for the theta42/proxy.
|
||||||
|
//
|
||||||
|
// The proxy is an OIDC client of an SSO Manager (or any OIDC provider) AND a
|
||||||
|
// direct LDAP client for user lookups. This file supplies that wiring.
|
||||||
|
//
|
||||||
|
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
|
||||||
|
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points the
|
||||||
|
// CONF_SECRETS env var at it so @simpleworkjs/conf reads it. No app_* env
|
||||||
|
// should be passed — app_* env beats this file in @simpleworkjs/conf, so the
|
||||||
|
// file is authoritative only if the matching app_* env is absent.
|
||||||
|
//
|
||||||
|
// Bare-metal: ops/install.sh seeds this file at /etc/proxy/secrets.js on first
|
||||||
|
// run (with placeholders for the values it can't guess) and points the
|
||||||
|
// systemd unit's CONF_SECRETS env var at it. Fill in your values, then
|
||||||
|
// `sudo systemctl restart proxy`. Values here override conf/base.js and win
|
||||||
|
// over <environment>.js.
|
||||||
|
//
|
||||||
|
// Only the keys the app reads are listed below. The `stack` key is read by the
|
||||||
|
// theta-env orchestrator (setup.sh) and ignored by the app.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'Dynamic Proxy', // shown in the UI
|
||||||
|
logo: '/static/img/theta42.svg', // nav image; point at your own file under public/ to white-label
|
||||||
|
|
||||||
|
// OpenID Connect — point at your SSO Manager. Issuer + authorization/
|
||||||
|
// endSession are browser-facing URLs; token/userinfo can be the internal
|
||||||
|
// URL if the SSO is on the same docker network (avoids a TLS hairpin).
|
||||||
|
oidc: {
|
||||||
|
enabled: true,
|
||||||
|
issuer: 'https://sso.example.com',
|
||||||
|
authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
|
||||||
|
tokenEndpoint: 'http://sso-manager:3001/oauth/token',
|
||||||
|
userinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
|
||||||
|
endSessionEndpoint: 'https://sso.example.com/oauth/logout',
|
||||||
|
clientId: '391136c8-9631-47c4-aac6-d6b760b7a9ae', // registered on the SSO
|
||||||
|
clientSecret: 'b29cce9c-de0c-4acc-b76a-494168f0381d', // from the SSO client record
|
||||||
|
redirectUri: 'https://proxy.example.com/api/auth/oidc/callback',
|
||||||
|
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||||
|
groupsClaim: 'groups',
|
||||||
|
usernameClaim: 'preferred_username',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a
|
||||||
|
// self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path
|
||||||
|
// for strict verification. bindPassword MUST match the
|
||||||
|
// serviceAccountPass in the SSO's sso-secrets.js (the proxy binds as that
|
||||||
|
// service account).
|
||||||
|
ldap: {
|
||||||
|
url: 'ldaps://sso-manager:636',
|
||||||
|
bindDN: 'cn=ldapclient,ou=people,dc=example,dc=com',
|
||||||
|
bindPassword: 'proxy-service-pass',
|
||||||
|
searchBase: 'ou=people,dc=example,dc=com',
|
||||||
|
userFilter: '(objectClass=inetOrgPerson)',
|
||||||
|
userNameAttribute: 'uid',
|
||||||
|
tlsOptions: {
|
||||||
|
rejectUnauthorized: false, // true + ca for a CA-signed cert
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Authorization. adminUsers is the local anti-lockout admin (matches
|
||||||
|
// auth.adminUsers in conf/base.js). adminGroups: SSO/LDAP groups whose
|
||||||
|
// members are always global admins.
|
||||||
|
auth: {
|
||||||
|
adminGroups: [],
|
||||||
|
adminUsers: ['proxyadmin'],
|
||||||
|
groupRoleMap: {},
|
||||||
|
// Optional: the local anti-lockout admin's initial password, used
|
||||||
|
// ONLY the first time that account is created. Leave unset and it
|
||||||
|
// defaults to the username itself ("proxyadmin2") — fine for a quick
|
||||||
|
// local test, but change it (or set this) before exposing the proxy
|
||||||
|
// publicly. Once the account exists, this key is never read again;
|
||||||
|
// change the password via the app itself (or delete the Redis user
|
||||||
|
// to force it to be re-bootstrapped with a new value here).
|
||||||
|
localAdminPass: 'proxyadmin-test-pass',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Orchestrator-only (ignored by the app) ───────────────────────────────
|
||||||
|
// Read by the theta-env setup.sh (e.g. to seed the OAuth client). Omit for
|
||||||
|
// bare-metal use.
|
||||||
|
stack: {
|
||||||
|
ssoHost: 'sso.example.com', // public SSO hostname
|
||||||
|
proxyHost: 'proxy.example.com', // public proxy hostname
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -68,38 +68,6 @@ host form whenever the name you're entering already has a matching
|
|||||||
wildcard available to reuse — including the wildcard's own bare base
|
wildcard available to reuse — including the wildcard's own bare base
|
||||||
domain (e.g. `example.com` itself, not just `something.example.com`).
|
domain (e.g. `example.com` itself, not just `something.example.com`).
|
||||||
|
|
||||||
## Putting a host behind single sign-on
|
|
||||||
|
|
||||||
Each host can be gated on its own, independently of the proxy's management UI.
|
|
||||||
On the host's **Auth** tab pick **Single sign-on (SSO)** and, optionally, fill in
|
|
||||||
the **Allowed users** / **Allowed groups** lists. Empty lists mean any
|
|
||||||
authenticated user is allowed; otherwise the identity must match one of them.
|
|
||||||
|
|
||||||
The proxy runs the OIDC flow itself at `/__proxy_auth` on the protected host and
|
|
||||||
keeps a Redis-backed session in a `__proxy_sso` cookie, so the app behind it
|
|
||||||
needs no changes.
|
|
||||||
|
|
||||||
**The IdP must allow the per-host callback.** Each protected host calls back to
|
|
||||||
`https://<that-host>/__proxy_auth/callback`, which is a different URL for every
|
|
||||||
host, all against the proxy's one OAuth client. Register a wildcard redirect URI
|
|
||||||
on that client — the SSO Manager supports `*` (one label) and `**` (any number):
|
|
||||||
|
|
||||||
```
|
|
||||||
https://**.example.com/__proxy_auth/callback
|
|
||||||
https://example.com/__proxy_auth/callback
|
|
||||||
```
|
|
||||||
|
|
||||||
theta-suite's bootstrap registers both automatically, and backfills them onto an
|
|
||||||
existing client. Without them, switching a host to SSO fails at the IdP with
|
|
||||||
`400 redirect_uri is not registered for this client`.
|
|
||||||
|
|
||||||
**Group suggestions come from the SSO.** The Allowed groups field autocompletes
|
|
||||||
from the SSO directory's groups when `sso.url` and `sso.apiToken` are set in the
|
|
||||||
proxy's config (theta-suite's bootstrap mints that read-only token). Without it
|
|
||||||
the field can only suggest the proxy's local groups, which for an SSO-gated host
|
|
||||||
are rarely the ones you want — the allow-list is matched against the `groups`
|
|
||||||
claim in the SSO's token, so only SSO groups can ever match.
|
|
||||||
|
|
||||||
## Load Balancing
|
## Load Balancing
|
||||||
|
|
||||||
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
|
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ module.exports = app;
|
|||||||
|
|
||||||
// Hold onto the auth middleware
|
// Hold onto the auth middleware
|
||||||
const middleware = require('./middleware/auth');
|
const middleware = require('./middleware/auth');
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
const buildInfo = require('./utils/build_info');
|
|
||||||
|
|
||||||
// Grab the projects PubSub
|
// Grab the projects PubSub
|
||||||
app.contoller = require('./controller');
|
app.contoller = require('./controller');
|
||||||
@@ -117,19 +115,6 @@ app.use(async function(err, req, res, next) {
|
|||||||
res.status(status);
|
res.status(status);
|
||||||
// Only expose safe, non-internal fields to the client.
|
// Only expose safe, non-internal fields to the client.
|
||||||
const body = { name: err.name, message: err.message };
|
const body = { name: err.name, message: err.message };
|
||||||
// Browser navigation gets the HTML error page (shared with SSO); API
|
|
||||||
// clients get JSON.
|
|
||||||
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
|
|
||||||
res.render('error', {
|
|
||||||
title: conf.environment !== 'production' ? 'dev' : '',
|
|
||||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
|
||||||
name: conf.name,
|
|
||||||
logo: conf.logo,
|
|
||||||
...buildInfo,
|
|
||||||
error: err,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json(body);
|
res.json(body);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error('error in the catch-all error handler', error);
|
console.error('error in the catch-all error handler', error);
|
||||||
|
|||||||
+64
-77
@@ -4,91 +4,34 @@
|
|||||||
* 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
|
/**
|
||||||
// @simpleworkjs/bao-conf deep-merges secret/proxy/conf from OpenBao over it.
|
* Get port from environment and store in Express.
|
||||||
// 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');
|
||||||
* Get port from environment and store in Express.
|
app.set('port', port);
|
||||||
*/
|
|
||||||
|
|
||||||
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
|
/**
|
||||||
app.set('port', port);
|
* Create HTTP server.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
var server = http.createServer(app);
|
||||||
* Create HTTP server.
|
|
||||||
*/
|
|
||||||
|
|
||||||
var server = http.createServer(app);
|
var io = require('socket.io')(server);
|
||||||
|
app.io = io;
|
||||||
|
|
||||||
var io = require('socket.io')(server);
|
/**
|
||||||
app.io = io;
|
* Listen on provided port, on all network interfaces.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
server.listen(port);
|
||||||
* Listen on provided port, on all network interfaces.
|
server.on('error', onError);
|
||||||
*/
|
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.
|
||||||
@@ -109,3 +52,47 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,18 +45,6 @@ module.exports = {
|
|||||||
usernameClaim: 'preferred_username',
|
usernameClaim: 'preferred_username',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Read-only SSO management API access, used to populate the per-host SSO
|
|
||||||
// allow-list autocomplete with the groups that actually exist in the
|
|
||||||
// directory. Without it the "Allowed groups" field can only suggest groups
|
|
||||||
// the proxy already knows locally, which for an SSO-gated host is usually
|
|
||||||
// none of the ones the operator wants. `apiToken` is a machine token minted
|
|
||||||
// by the theta-suite bootstrap and lives in secrets.js; leaving it unset
|
|
||||||
// simply falls back to the local-only suggestions.
|
|
||||||
sso: {
|
|
||||||
url: '', // e.g. https://sso.example.com
|
|
||||||
apiToken: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Authorization: how groups map to roles, and which groups are global admin.
|
// Authorization: how groups map to roles, and which groups are global admin.
|
||||||
// Per-user overrides are Grant records managed in the app.
|
// Per-user overrides are Grant records managed in the app.
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const baoConf = require('@simpleworkjs/bao-conf');
|
|
||||||
const Table = require('.');
|
const Table = require('.');
|
||||||
const ModelPs = require('../utils/model_pubsub');
|
const ModelPs = require('../utils/model_pubsub');
|
||||||
|
|
||||||
@@ -140,26 +139,11 @@ class DnsProvider extends Table{
|
|||||||
let __intraModel = this.__intraModel(data.dnsProvider);
|
let __intraModel = this.__intraModel(data.dnsProvider);
|
||||||
Provider = __intraModel.Provider;
|
Provider = __intraModel.Provider;
|
||||||
|
|
||||||
if (!data.id) data.id = crypto.randomBytes(8).toString("hex");
|
|
||||||
|
|
||||||
let secrets = {};
|
|
||||||
for (let key in Provider._keyMap) {
|
|
||||||
if (Provider._keyMap[key].isPrivate && data[key] !== undefined) {
|
|
||||||
secrets[key] = data[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is here test if the given API key is valid
|
// This is here test if the given API key is valid
|
||||||
let provider = new __intraModel.Provider(data, ...args);
|
let provider = new __intraModel.Provider(data, ...args);
|
||||||
let domains = await provider.listDomains();
|
let domains = await provider.listDomains();
|
||||||
|
|
||||||
for (let key in secrets) data[key] = '********';
|
|
||||||
|
|
||||||
let instance = await super.create.call(__intraModel, data, ...args);
|
let instance = await super.create.call(__intraModel, data, ...args);
|
||||||
|
|
||||||
if (Object.keys(secrets).length > 0) {
|
|
||||||
await baoConf.set(`proxy/dns-providers/${instance.id}`, secrets);
|
|
||||||
}
|
|
||||||
try{
|
try{
|
||||||
await instance.updateDomains(domains);
|
await instance.updateDomains(domains);
|
||||||
}catch(updateError){
|
}catch(updateError){
|
||||||
@@ -205,63 +189,7 @@ class DnsProvider extends Table{
|
|||||||
let instance = await super.get(data, ...args);
|
let instance = await super.get(data, ...args);
|
||||||
let __intraModel = this.__intraModel(instance.dnsProvider);
|
let __intraModel = this.__intraModel(instance.dnsProvider);
|
||||||
|
|
||||||
let resolved = await super.get.call(__intraModel, data, ...args);
|
return await super.get.call(__intraModel, data, ...args);
|
||||||
try {
|
|
||||||
let secrets = await baoConf.get(`proxy/dns-providers/${resolved.id}`);
|
|
||||||
if (secrets) Object.assign(resolved, secrets);
|
|
||||||
} catch(e) {}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findall(...args){
|
|
||||||
let instances = await super.findall(...args);
|
|
||||||
for (let inst of instances) {
|
|
||||||
try {
|
|
||||||
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
|
|
||||||
if (secrets) Object.assign(inst, secrets);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
return instances;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async find(...args){
|
|
||||||
let instances = await super.find(...args);
|
|
||||||
for (let inst of instances) {
|
|
||||||
try {
|
|
||||||
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
|
|
||||||
if (secrets) Object.assign(inst, secrets);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
return instances;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(data){
|
|
||||||
let Provider = this.constructor.Provider || providers[this.dnsProvider];
|
|
||||||
let secrets = {};
|
|
||||||
if (Provider) {
|
|
||||||
for (let key in Provider._keyMap) {
|
|
||||||
if (Provider._keyMap[key].isPrivate && data[key] !== undefined && data[key] !== '********') {
|
|
||||||
secrets[key] = data[key];
|
|
||||||
data[key] = '********';
|
|
||||||
} else if (Provider._keyMap[key].isPrivate && data[key] === '********') {
|
|
||||||
delete data[key]; // Do not update the masked value if it's sent back
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = await super.update(data);
|
|
||||||
|
|
||||||
if (Object.keys(secrets).length > 0) {
|
|
||||||
let existing = await baoConf.get(`proxy/dns-providers/${this.id}`) || {};
|
|
||||||
await baoConf.set(`proxy/dns-providers/${this.id}`, { ...existing, ...secrets });
|
|
||||||
Object.assign(this, secrets);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(...args){
|
|
||||||
await baoConf.request('DELETE', `proxy/dns-providers/${this.id}`).catch(()=>{});
|
|
||||||
return await super.remove(...args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static listProviders(){
|
static listProviders(){
|
||||||
|
|||||||
Generated
+93
-68
@@ -1,18 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.35.0",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.35.0",
|
"version": "1.7.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.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
@@ -22,7 +21,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": "^6.0.1",
|
"ejs": "^3.1.10",
|
||||||
"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",
|
||||||
@@ -298,18 +297,6 @@
|
|||||||
"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",
|
||||||
@@ -459,6 +446,12 @@
|
|||||||
"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",
|
||||||
@@ -524,20 +517,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.3.0",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^1.0.5",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"http-errors": "^2.0.1",
|
"http-errors": "^2.0.0",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.0",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"qs": "^6.15.2",
|
"qs": "^6.14.1",
|
||||||
"raw-body": "^3.0.2",
|
"raw-body": "^3.0.1",
|
||||||
"type-is": "^2.1.0"
|
"type-is": "^2.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -547,19 +540,6 @@
|
|||||||
"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",
|
||||||
@@ -580,16 +560,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@@ -868,15 +848,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "6.0.1",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||||
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||||
"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.12.18"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -1088,6 +1071,42 @@
|
|||||||
"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",
|
||||||
@@ -1464,6 +1483,23 @@
|
|||||||
"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",
|
||||||
@@ -1770,6 +1806,12 @@
|
|||||||
"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",
|
||||||
@@ -2271,34 +2313,17 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "2.1.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^1.0.5",
|
||||||
"media-typer": "^1.1.0",
|
"media-typer": "^1.1.0",
|
||||||
"mime-types": "^3.0.0"
|
"mime-types": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 0.6"
|
||||||
},
|
|
||||||
"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": {
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.35.0",
|
"version": "1.9.0",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
"@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.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
@@ -32,7 +31,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": "^6.0.1",
|
"ejs": "^3.1.10",
|
||||||
"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",
|
||||||
|
|||||||
@@ -3,12 +3,6 @@ nav.navbar{
|
|||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
|
||||||
.top-nav a.active{
|
|
||||||
font-weight: bold;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -466,19 +466,13 @@ app.permission = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function update(id, args, callback){
|
|
||||||
app.api.put('permission/' + encodeURIComponent(id), args, function(error, data){
|
|
||||||
callback(error, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(id, callback){
|
function remove(id, callback){
|
||||||
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
||||||
callback(error, data);
|
callback(error, data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {list, subjects, add, update, remove};
|
return {list, subjects, add, remove};
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
|||||||
+2
-41
@@ -58,55 +58,16 @@ function hashHostSecrets(body){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The SSO's directory groups, for the per-host SSO allow-list autocomplete.
|
|
||||||
// The host's allow-list is checked against the `groups` claim the SSO puts in
|
|
||||||
// the token (see utils/host_sso.js), so the only suggestions that can ever
|
|
||||||
// match are the SSO's own groups -- the local groups below are a fallback, not
|
|
||||||
// the real answer. Requires conf.sso.apiToken; degrades to [] without it, and
|
|
||||||
// never fails the request (the form still works, just without suggestions).
|
|
||||||
//
|
|
||||||
// Cached for a few minutes: this is typeahead fodder, and every host editor
|
|
||||||
// opening the form would otherwise hit the SSO.
|
|
||||||
let ssoGroupCache = {at: 0, groups: []};
|
|
||||||
const SSO_GROUP_TTL = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
async function ssoGroups(){
|
|
||||||
let sso = conf.sso || {};
|
|
||||||
if(!sso.url || !sso.apiToken) return [];
|
|
||||||
if(Date.now() - ssoGroupCache.at < SSO_GROUP_TTL) return ssoGroupCache.groups;
|
|
||||||
try{
|
|
||||||
let res = await fetch(`${sso.url.replace(/\/$/, '')}/api/group`, {
|
|
||||||
// A minted API token (`sso_<id>_<secret>`) authenticates as a bearer
|
|
||||||
// token; the SSO's `auth-token` header is for browser session UUIDs
|
|
||||||
// only and would be rejected here.
|
|
||||||
headers: {Authorization: `Bearer ${sso.apiToken}`, Accept: 'application/json'},
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
if(!res.ok) throw new Error(`SSO group list failed (${res.status})`);
|
|
||||||
let body = await res.json();
|
|
||||||
// The SSO returns { results: [...] } -- either CN strings or objects.
|
|
||||||
let groups = (body && body.results || []).map(g => (typeof g === 'string' ? g : g && g.name)).filter(Boolean);
|
|
||||||
ssoGroupCache = {at: Date.now(), groups};
|
|
||||||
return groups;
|
|
||||||
}catch(error){
|
|
||||||
console.error(`[auth-suggestions] could not list SSO groups: ${error.message}`);
|
|
||||||
// Cache the failure briefly so a down SSO doesn't stall every form open.
|
|
||||||
ssoGroupCache = {at: Date.now(), groups: ssoGroupCache.groups};
|
|
||||||
return ssoGroupCache.groups;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Autocomplete source for the per-host auth allow-lists (SSO users/groups).
|
// Autocomplete source for the per-host auth allow-lists (SSO users/groups).
|
||||||
// Available to any authenticated host editor (not just global admins). Groups
|
// Available to any authenticated host editor (not just global admins). Groups
|
||||||
// are the SSO directory's groups plus local groups, existing permission
|
// are derived from local groups, existing permission group-subjects, and the
|
||||||
// group-subjects, and the conf.auth admin/role-map groups.
|
// conf.auth admin/role-map groups.
|
||||||
router.get('/auth-suggestions', async function(req, res, next){
|
router.get('/auth-suggestions', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let users = [];
|
let users = [];
|
||||||
try{ users = (await User.list()) || []; }catch(error){ /* none */ }
|
try{ users = (await User.list()) || []; }catch(error){ /* none */ }
|
||||||
|
|
||||||
let groups = new Set();
|
let groups = new Set();
|
||||||
for(let g of await ssoGroups()) groups.add(g);
|
|
||||||
try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ }
|
try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ }
|
||||||
try{
|
try{
|
||||||
for(let p of await Permission.listDetail()){
|
for(let p of await Permission.listDetail()){
|
||||||
|
|||||||
@@ -57,47 +57,6 @@ router.post('/', async function(req, res, next){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit an existing grant.
|
|
||||||
//
|
|
||||||
// The record id is derived from (subjectType, subject, scope, domain)
|
|
||||||
// -- Permission.mkId -- so changing any of those is a DIFFERENT record, not an
|
|
||||||
// in-place update. Changing only the role is a true update. Handle both here so
|
|
||||||
// the UI can offer a single "edit" instead of making the operator delete and
|
|
||||||
// re-add, and so a subject/scope change can never leave the old grant behind
|
|
||||||
// still conferring access.
|
|
||||||
router.put('/:id', async function(req, res, next){
|
|
||||||
try{
|
|
||||||
let existing = await Permission.get(req.params.id);
|
|
||||||
if(!existing) return res.status(404).json({message: `Permission ${req.params.id} not found.`});
|
|
||||||
|
|
||||||
let next_ = {
|
|
||||||
subjectType: req.body.subjectType !== undefined ? req.body.subjectType : existing.subjectType,
|
|
||||||
subject: req.body.subject !== undefined ? req.body.subject : existing.subject,
|
|
||||||
scope: req.body.scope !== undefined ? req.body.scope : existing.scope,
|
|
||||||
domain: req.body.domain !== undefined ? req.body.domain : existing.domain,
|
|
||||||
role: req.body.role !== undefined ? req.body.role : existing.role,
|
|
||||||
created_by: reqUsername(req),
|
|
||||||
};
|
|
||||||
if(next_.scope === 'global') next_.domain = '*';
|
|
||||||
|
|
||||||
// create() upserts on the new id, so this is safe in either direction;
|
|
||||||
// remove the old record afterwards only when the identity actually moved.
|
|
||||||
let permission = await Permission.create(next_);
|
|
||||||
let newId = Permission.mkId(next_);
|
|
||||||
if(newId !== req.params.id){
|
|
||||||
try{ await existing.remove(); }catch(error){ /* already replaced */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
message: `Updated ${next_.subjectType} "${next_.subject}" to ${next_.role}` +
|
|
||||||
(next_.scope === 'global' ? ' globally.' : ` on ${next_.domain}.`),
|
|
||||||
...permission,
|
|
||||||
});
|
|
||||||
}catch(error){
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:id', async function(req, res, next){
|
router.delete('/:id', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let permission = await Permission.get(req.params.id);
|
let permission = await Permission.get(req.params.id);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ router.get('/dns', async function(req, res, next) {
|
|||||||
|
|
||||||
|
|
||||||
router.get('/users', async function(req, res, next) {
|
router.get('/users', async function(req, res, next) {
|
||||||
res.redirect(301, '/hosts');
|
res.render('users', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/permissions', async function(req, res, next) {
|
router.get('/permissions', async function(req, res, next) {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
const { describe, test, beforeEach, afterEach, after, mock } = require('node:test');
|
|
||||||
const assert = require('node:assert');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
const baoConf = require('@simpleworkjs/bao-conf');
|
|
||||||
const Table = require('../../models/index');
|
|
||||||
const DnsProvider = Table.models.DnsProvider;
|
|
||||||
const DuckDns = require('../../models/dns_provider/duckdns');
|
|
||||||
|
|
||||||
describe('DnsProvider Vault Integration', () => {
|
|
||||||
let originalSet, originalGet, originalRequest;
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
if (Table._redis && Table._redis.quit) {
|
|
||||||
await Table._redis.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Mock baoConf
|
|
||||||
originalSet = baoConf.set;
|
|
||||||
originalGet = baoConf.get;
|
|
||||||
originalRequest = baoConf.request;
|
|
||||||
|
|
||||||
const vaultStore = {};
|
|
||||||
baoConf.set = mock.fn(async (path, data) => { vaultStore[path] = data; return true; });
|
|
||||||
baoConf.get = mock.fn(async (path) => vaultStore[path] || {});
|
|
||||||
baoConf.request = mock.fn(async () => ({}));
|
|
||||||
|
|
||||||
mock.method(DuckDns.prototype, 'listDomains', async () => []);
|
|
||||||
mock.method(DnsProvider.prototype, 'updateDomains', async () => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
baoConf.set = originalSet;
|
|
||||||
baoConf.get = originalGet;
|
|
||||||
baoConf.request = originalRequest;
|
|
||||||
mock.restoreAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('create() writes isPrivate keys to OpenBao and get() retrieves them', async () => {
|
|
||||||
const payload = {
|
|
||||||
name: 'My Duck',
|
|
||||||
dnsProvider: 'DuckDns',
|
|
||||||
token: 'super-secret-vault-token',
|
|
||||||
subdomains: 'myduck',
|
|
||||||
created_by: 'admin'
|
|
||||||
};
|
|
||||||
|
|
||||||
const instance = await DnsProvider.create(payload);
|
|
||||||
|
|
||||||
// 1. Should have called OpenBao set
|
|
||||||
assert.strictEqual(baoConf.set.mock.callCount(), 1);
|
|
||||||
const [path, secrets] = baoConf.set.mock.calls[0].arguments;
|
|
||||||
|
|
||||||
assert.strictEqual(path, `proxy/dns-providers/${instance.id}`);
|
|
||||||
assert.deepStrictEqual(secrets, { token: 'super-secret-vault-token' });
|
|
||||||
|
|
||||||
// 2. The returned instance should have the secret injected back
|
|
||||||
assert.strictEqual(instance.token, 'super-secret-vault-token');
|
|
||||||
|
|
||||||
// 3. get() should fetch public data from Redis and merge secrets from OpenBao
|
|
||||||
// (baoConf.get is already mocked to return from vaultStore)
|
|
||||||
const fetched = await DnsProvider.get(instance.id);
|
|
||||||
assert.strictEqual(fetched.token, 'super-secret-vault-token');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+2
-1
@@ -37,7 +37,8 @@ module.exports = {
|
|||||||
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
nav: [
|
nav: [
|
||||||
{href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []},
|
{href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []},
|
||||||
{href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: ['admin']},
|
{href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: []},
|
||||||
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['admin']},
|
||||||
{href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']},
|
{href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']},
|
||||||
{href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']},
|
{href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
// Require an admin to see this page.
|
// Require login to see this page.
|
||||||
app.auth.forceLogin(['admin']);
|
app.auth.forceLogin();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<div class="container mt-5">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-6 text-center">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
|
|
||||||
</div>
|
|
||||||
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
|
|
||||||
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
<% if (error.status === 404) { %>
|
|
||||||
The page you are looking for doesn't exist or has been moved.
|
|
||||||
<% } else { %>
|
|
||||||
An unexpected error occurred. Please try again later.
|
|
||||||
<% } %>
|
|
||||||
</p>
|
|
||||||
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
|
|
||||||
<i class="fa-solid fa-house me-2"></i>Return to Home
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%- include('bottom') %>
|
|
||||||
+54
-45
@@ -16,14 +16,6 @@
|
|||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|
||||||
function loadGroups() {
|
|
||||||
app.group.list(function(error, data){
|
|
||||||
if(error) return app.messages.action(error, $('#groups-list'), 'danger');
|
|
||||||
$.scope.LocalGroup.empty();
|
|
||||||
for(let g of (data.results || [])) $.scope.LocalGroup.push(g);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usernames for the "add member" autocomplete (reuses the permission
|
// Usernames for the "add member" autocomplete (reuses the permission
|
||||||
// subjects endpoint, which is admin-only like this page).
|
// subjects endpoint, which is admin-only like this page).
|
||||||
function loadUserSuggestions(){
|
function loadUserSuggestions(){
|
||||||
@@ -36,16 +28,23 @@
|
|||||||
|
|
||||||
function removeGroup(name){
|
function removeGroup(name){
|
||||||
app.group.remove(name, function(error, data){
|
app.group.remove(name, function(error, data){
|
||||||
if(error) return app.messages.action(error, $('#groups-list'), 'danger');
|
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
|
||||||
$.scope.LocalGroup.remove(name);
|
$.scope.LocalGroup.remove(name);
|
||||||
loadGroups();
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshGroups(){
|
||||||
|
$.scope.LocalGroup.empty();
|
||||||
|
app.group.list(function(error, data){
|
||||||
|
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
|
||||||
|
for(let g of data.results) $.scope.LocalGroup.push(g);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMember(group, username){
|
function removeMember(group, username){
|
||||||
app.group.removeMember(group, username, function(error, data){
|
app.group.removeMember(group, username, function(error, data){
|
||||||
if(error) return app.messages.action(error, $('#groups-list'), 'danger');
|
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
|
||||||
loadGroups();
|
// websocket update echoes the new member list.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,14 +55,17 @@
|
|||||||
let username = ($input.val() || '').trim();
|
let username = ($input.val() || '').trim();
|
||||||
if(!username) return;
|
if(!username) return;
|
||||||
app.group.addMember(group, username, function(error, data){
|
app.group.addMember(group, username, function(error, data){
|
||||||
if(error) return app.messages.action(error, $('#groups-list'), 'danger');
|
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
|
||||||
$input.val('');
|
$input.val('');
|
||||||
loadGroups();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
loadGroups();
|
app.group.list(function(error, data){
|
||||||
|
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
|
||||||
|
for(let g of data.results) $.scope.LocalGroup.push(g);
|
||||||
|
});
|
||||||
|
|
||||||
loadUserSuggestions();
|
loadUserSuggestions();
|
||||||
|
|
||||||
$.scope.LocalGroup.__take = function($el){
|
$.scope.LocalGroup.__take = function($el){
|
||||||
@@ -72,13 +74,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
app.subscribe(/^model:LocalGroup:create/, function(data){
|
app.subscribe(/^model:LocalGroup:create/, function(data){
|
||||||
loadGroups();
|
$.scope.LocalGroup.remove(data.name);
|
||||||
});
|
$.scope.LocalGroup.unshift(data);
|
||||||
app.subscribe(/^model:LocalGroup:update/, function(data, topic){
|
// Also refresh to ensure the full list is up to date
|
||||||
loadGroups();
|
setTimeout(refreshGroups, 500);
|
||||||
});
|
});
|
||||||
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
|
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
|
||||||
loadGroups();
|
$.scope.LocalGroup.remove(topic.split(':')[3]);
|
||||||
|
// Also refresh to ensure the full list is up to date
|
||||||
|
setTimeout(refreshGroups, 500);
|
||||||
|
});
|
||||||
|
app.subscribe(/^model:LocalGroup:update/, function(data, topic){
|
||||||
|
$.scope.LocalGroup.update(topic.split(':')[3], data);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -86,59 +93,61 @@
|
|||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<datalist id="groupUsers"></datalist>
|
<datalist id="groupUsers"></datalist>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row" style="display:none">
|
||||||
<div class="col-md-4 mb-4">
|
<div class="col-md-4">
|
||||||
<div class="card shadow">
|
<div class="card shadow-lg">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header text-center">
|
||||||
<div><i class="fa-solid fa-users-gear me-2"></i><strong>Add Group</strong></div>
|
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span>
|
||||||
<a href="/docs/access" class="text-reset" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
<span class="card-title">Add Group</span>
|
||||||
|
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form action="group/" onsubmit="formAJAX(this)" evalAJAX="loadGroups(); this.reset();">
|
<form action="group/" onsubmit="formAJAX(this)">
|
||||||
<div class="mb-3">
|
<div class="form-group">
|
||||||
<label class="form-label fw-bold">Group name</label>
|
<label class="control-label">Group name</label>
|
||||||
<input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" required />
|
<input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" />
|
||||||
<div class="form-text">
|
<div class="text-muted" style="font-size:.8rem">
|
||||||
Lowercased to a slug. Use the name as a Subject (type "group") on the Permissions page.
|
Lowercased to a slug. Use the name as a Subject (type "group")
|
||||||
|
on the Permissions page.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-plus me-1"></i> Add Group</button>
|
<button type="submit" class="btn btn-info">Add Group</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8 mb-4">
|
<div class="col-md-8">
|
||||||
<div class="row row-cols-1 g-3" id="groups-list">
|
<div class="row row-cols-1 g-3">
|
||||||
<div jq-repeat="LocalGroup" jq-repeat-index="name" style="display:none" class="col">
|
<div jq-repeat="LocalGroup" jq-repeat-index="name" style="display:none" class="col">
|
||||||
<div class="card shadow-sm border">
|
<div class="card shadow-lg">
|
||||||
<div class="card-header d-flex align-items-center">
|
<div class="card-header d-flex align-items-center">
|
||||||
<span class="card-icon me-2"><i class="fa-solid fa-users text-primary"></i></span>
|
<span class="card-icon me-2"><i class="fa-solid fa-users"></i></span>
|
||||||
<strong class="me-2">{{ name }}</strong>
|
<span class="card-title">{{ name }}</span>
|
||||||
<span class="badge text-bg-secondary">{{ memberCount }} member(s)</span>
|
<span class="badge text-bg-secondary ms-2">{{ memberCount }} member(s)</span>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger ms-auto" onclick="removeGroup('{{name}}')">
|
<button type="button" class="btn btn-sm btn-outline-danger ms-auto" onclick="removeGroup('{{name}}')">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="mb-2">
|
||||||
{{#memberList}}
|
{{#memberList}}
|
||||||
<span class="badge text-bg-info member-pill me-1 mb-1 fs-6">
|
<span class="badge text-bg-info member-pill me-1 mb-1 fs-6">
|
||||||
{{ username }}
|
{{ username }}
|
||||||
<i class="fa-solid fa-xmark ms-1 text-danger" onclick="removeMember('{{group}}','{{username}}')"></i>
|
<i class="fa-solid fa-xmark ms-1" onclick="removeMember('{{group}}','{{username}}')"></i>
|
||||||
</span>
|
</span>
|
||||||
{{/memberList}}
|
{{/memberList}}
|
||||||
{{^memberList}}
|
{{^memberList}}
|
||||||
<span class="text-muted small">No members yet.</span>
|
<span class="text-muted">No members yet.</span>
|
||||||
{{/memberList}}
|
{{/memberList}}
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group input-group-sm member-add" data-group="{{name}}">
|
<div class="input-group member-add" data-group="{{name}}">
|
||||||
<input type="text" class="form-control" list="groupUsers" placeholder="Add username..." autocomplete="off"
|
<input type="text" class="form-control" list="groupUsers" placeholder="username" autocomplete="off"
|
||||||
onkeydown="if(event.key==='Enter'){event.preventDefault();addMember(this.nextElementSibling);}" />
|
onkeydown="if(event.key==='Enter'){event.preventDefault();addMember(this.nextElementSibling);}" />
|
||||||
<button type="button" class="btn btn-success" onclick="addMember(this)">
|
<button type="button" class="btn btn-success" onclick="addMember(this)">
|
||||||
<i class="fa-solid fa-user-plus me-1"></i> Add
|
<i class="fa-solid fa-user-plus"></i> Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+82
-150
@@ -4,24 +4,17 @@
|
|||||||
app.auth.forceLogin();
|
app.auth.forceLogin();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<style type="text/css">
|
||||||
|
label.control-label{
|
||||||
// Kept alongside the rendered scope so the Edit modal can pre-fill from the
|
font-weight: bold;
|
||||||
// record without a second round-trip ($.scope exposes push/empty/remove,
|
margin-bottom: 1px;
|
||||||
// not a lookup by key).
|
|
||||||
var permissionsById = {};
|
|
||||||
|
|
||||||
function loadPermissions() {
|
|
||||||
app.permission.list(function(error, data){
|
|
||||||
if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
|
|
||||||
permissionsById = {};
|
|
||||||
$.scope.Permission.empty();
|
|
||||||
for(let p of (data.results || [])){
|
|
||||||
permissionsById[p.id] = p;
|
|
||||||
$.scope.Permission.push(p);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
.card-title{ font-weight: bold; }
|
||||||
|
.member-pill{ cursor: default; }
|
||||||
|
.member-pill i{ cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
// Fill the username/group datalists that back the Subject autocomplete.
|
// Fill the username/group datalists that back the Subject autocomplete.
|
||||||
function loadSubjectSuggestions(){
|
function loadSubjectSuggestions(){
|
||||||
@@ -46,131 +39,63 @@
|
|||||||
|
|
||||||
function permissionAddOpen(){
|
function permissionAddOpen(){
|
||||||
app.modal.open({title: 'Add Permission', bodyHtml:
|
app.modal.open({title: 'Add Permission', bodyHtml:
|
||||||
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close(); loadPermissions();">'
|
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close();">'
|
||||||
+ '<div class="mb-3">'
|
+ '<div class="form-group">'
|
||||||
+ '<label class="form-label fw-bold">Subject type</label>'
|
+ '<label class="control-label">Subject type</label>'
|
||||||
+ '<select class="form-select" name="subjectType" onchange="subjectTypeChanged(this)">'
|
+ '<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">'
|
||||||
+ '<option value="user">User</option>'
|
+ '<option value="user">User</option>'
|
||||||
+ '<option value="group">Group</option>'
|
+ '<option value="group">Group</option>'
|
||||||
+ '</select>'
|
+ '</select>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="mb-3">'
|
+ '<div class="form-group">'
|
||||||
+ '<label class="form-label fw-bold">Subject (username or group)</label>'
|
+ '<label class="control-label">Subject (username or group)</label>'
|
||||||
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
|
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="mb-3">'
|
+ '<div class="form-group">'
|
||||||
+ '<label class="form-label fw-bold">Scope</label>'
|
+ '<label class="control-label">Scope</label>'
|
||||||
+ '<select class="form-select" name="scope">'
|
+ '<select class="form-control" name="scope">'
|
||||||
+ '<option value="domain">Domain</option>'
|
+ '<option value="domain">Domain</option>'
|
||||||
+ '<option value="global">Global</option>'
|
+ '<option value="global">Global</option>'
|
||||||
+ '</select>'
|
+ '</select>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="mb-3">'
|
+ '<div class="form-group">'
|
||||||
+ '<label class="form-label fw-bold">Domain (for domain scope)</label>'
|
+ '<label class="control-label">Domain (for domain scope)</label>'
|
||||||
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
|
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
|
||||||
+ '<div class="form-text text-muted">'
|
+ '<div class="field-hint text-muted">'
|
||||||
+ 'Wildcards: <code>*.example.com</code> matches one label, '
|
+ 'Wildcards: <code>*.example.com</code> matches one label, '
|
||||||
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
|
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
|
||||||
+ '<code>**</code> matches every domain.'
|
+ '<code>**</code> matches every domain.'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="mb-3">'
|
+ '<div class="form-group">'
|
||||||
+ '<label class="form-label fw-bold">Role</label>'
|
+ '<label class="control-label">Role</label>'
|
||||||
+ '<select class="form-select" name="role">'
|
+ '<select class="form-control" name="role">'
|
||||||
+ '<option value="viewer">Viewer (read)</option>'
|
+ '<option value="viewer">Viewer (read)</option>'
|
||||||
+ '<option value="manager">Manager (full over domain)</option>'
|
+ '<option value="manager">Manager (full over domain)</option>'
|
||||||
+ '<option value="admin">Admin (global only)</option>'
|
+ '<option value="admin">Admin (global only)</option>'
|
||||||
+ '</select>'
|
+ '</select>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<hr />'
|
+ '<hr />'
|
||||||
+ '<div class="d-flex justify-content-end gap-2">'
|
+ '<button type="submit" class="btn btn-info">Add Permission</button>'
|
||||||
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
|
|
||||||
+ '<button type="submit" class="btn btn-primary">Add Permission</button>'
|
|
||||||
+ '</div>'
|
|
||||||
+ '</form>',
|
+ '</form>',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit an existing grant. A permission's id is derived from
|
|
||||||
// (subjectType, subject, scope, domain), so changing any of those replaces
|
|
||||||
// the record rather than updating it -- the API handles that and removes the
|
|
||||||
// superseded grant, which is why this is a single Edit rather than making
|
|
||||||
// the operator delete and re-add (and risk leaving the old grant in place).
|
|
||||||
function permissionEditOpen(id){
|
|
||||||
var p = permissionsById[id] || {};
|
|
||||||
function sel(v, want){ return v === want ? ' selected' : ''; }
|
|
||||||
app.modal.open({title: 'Edit Permission', bodyHtml:
|
|
||||||
'<div class="mb-3">'
|
|
||||||
+ '<label class="form-label fw-bold">Subject type</label>'
|
|
||||||
+ '<select class="form-select" id="perm-edit-subjectType">'
|
|
||||||
+ '<option value="user"' + sel(p.subjectType, 'user') + '>User</option>'
|
|
||||||
+ '<option value="group"' + sel(p.subjectType, 'group') + '>Group</option>'
|
|
||||||
+ '</select>'
|
|
||||||
+ '</div>'
|
|
||||||
+ '<div class="mb-3">'
|
|
||||||
+ '<label class="form-label fw-bold">Subject (username or group)</label>'
|
|
||||||
+ '<input type="text" class="form-control" id="perm-edit-subject" list="subjectUsers" autocomplete="off" value="' + app.util.escapeHtml(p.subject || '') + '" />'
|
|
||||||
+ '</div>'
|
|
||||||
+ '<div class="mb-3">'
|
|
||||||
+ '<label class="form-label fw-bold">Scope</label>'
|
|
||||||
+ '<select class="form-select" id="perm-edit-scope">'
|
|
||||||
+ '<option value="domain"' + sel(p.scope, 'domain') + '>Domain</option>'
|
|
||||||
+ '<option value="global"' + sel(p.scope, 'global') + '>Global</option>'
|
|
||||||
+ '</select>'
|
|
||||||
+ '</div>'
|
|
||||||
+ '<div class="mb-3">'
|
|
||||||
+ '<label class="form-label fw-bold">Domain (for domain scope)</label>'
|
|
||||||
+ '<input type="text" class="form-control" id="perm-edit-domain" autocomplete="off" value="' + app.util.escapeHtml(p.domain || '') + '" />'
|
|
||||||
+ '<div class="form-text text-muted">'
|
|
||||||
+ 'Wildcards: <code>*.example.com</code> matches one label, '
|
|
||||||
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
|
|
||||||
+ '<code>**</code> matches every domain.'
|
|
||||||
+ '</div>'
|
|
||||||
+ '</div>'
|
|
||||||
+ '<div class="mb-3">'
|
|
||||||
+ '<label class="form-label fw-bold">Role</label>'
|
|
||||||
+ '<select class="form-select" id="perm-edit-role">'
|
|
||||||
+ '<option value="viewer"' + sel(p.role, 'viewer') + '>Viewer (read)</option>'
|
|
||||||
+ '<option value="manager"' + sel(p.role, 'manager') + '>Manager (full over domain)</option>'
|
|
||||||
+ '<option value="admin"' + sel(p.role, 'admin') + '>Admin (global only)</option>'
|
|
||||||
+ '</select>'
|
|
||||||
+ '</div>'
|
|
||||||
+ '<hr />'
|
|
||||||
+ '<div class="d-flex justify-content-end gap-2">'
|
|
||||||
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
|
|
||||||
+ '<button type="button" class="btn btn-primary" onclick="permissionEditSave(\'' + id + '\')">Save changes</button>'
|
|
||||||
+ '</div>',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function permissionEditSave(id){
|
|
||||||
var payload = {
|
|
||||||
subjectType: $('#perm-edit-subjectType').val(),
|
|
||||||
subject: ($('#perm-edit-subject').val() || '').trim(),
|
|
||||||
scope: $('#perm-edit-scope').val(),
|
|
||||||
domain: ($('#perm-edit-domain').val() || '').trim(),
|
|
||||||
role: $('#perm-edit-role').val(),
|
|
||||||
};
|
|
||||||
if(!payload.subject){
|
|
||||||
return app.messages.toast('Subject is required', 'warning');
|
|
||||||
}
|
|
||||||
app.permission.update(id, payload, function(error, data){
|
|
||||||
if(error) return app.messages.action((data && data.message) || error, $('#permissions-list'), 'danger');
|
|
||||||
app.modal.close();
|
|
||||||
loadPermissions();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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, $('#permissions-list'), 'danger');
|
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
|
||||||
|
// The websocket echo removes the row; drop it locally too for snappiness.
|
||||||
$.scope.Permission.remove(id);
|
$.scope.Permission.remove(id);
|
||||||
loadPermissions();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
loadPermissions();
|
// Existing permissions.
|
||||||
|
app.permission.list(function(error, data){
|
||||||
|
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
|
||||||
|
for(let p of data.results) $.scope.Permission.push(p);
|
||||||
|
});
|
||||||
|
|
||||||
loadSubjectSuggestions();
|
loadSubjectSuggestions();
|
||||||
|
|
||||||
$.scope.Permission.__take = function($el, item, list){
|
$.scope.Permission.__take = function($el, item, list){
|
||||||
@@ -180,10 +105,12 @@
|
|||||||
|
|
||||||
// Live updates (model:Permission:*), so adds/removes reflect for everyone.
|
// Live updates (model:Permission:*), so adds/removes reflect for everyone.
|
||||||
app.subscribe(/^model:Permission:create/, function(data){
|
app.subscribe(/^model:Permission:create/, function(data){
|
||||||
loadPermissions();
|
$.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){
|
app.subscribe(/^model:Permission:remove/, function(data, topic){
|
||||||
loadPermissions();
|
$.scope.Permission.remove(topic.split(':')[3]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -192,51 +119,56 @@
|
|||||||
<datalist id="subjectUsers"></datalist>
|
<datalist id="subjectUsers"></datalist>
|
||||||
<datalist id="subjectGroups"></datalist>
|
<datalist id="subjectGroups"></datalist>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row" style="display:none">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow">
|
<div class="card shadow-lg">
|
||||||
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header text-center">
|
||||||
<div>
|
<span class="card-icon float-start">
|
||||||
<i class="fa-solid fa-user-shield me-2"></i><strong>Permissions List</strong>
|
<i class="fa-solid fa-list-check"></i>
|
||||||
</div>
|
</span>
|
||||||
<div>
|
<span class="card-title">Permissions</span>
|
||||||
<a href="/docs/access" class="text-reset me-3" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
<span class="float-end">
|
||||||
<button type="button" class="btn btn-sm btn-primary" onclick="permissionAddOpen()">
|
<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-user-shield me-1"></i> Add Permission
|
<button type="button" class="btn btn-sm btn-success" onclick="permissionAddOpen()">
|
||||||
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
|
Add Permission
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body">
|
||||||
<ul class="list-group list-group-flush" id="permissions-list">
|
<div class="table-responsive">
|
||||||
<li class="list-group-item d-flex align-items-center justify-content-between py-3" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
|
<table class="table table-striped mb-0">
|
||||||
<div class="d-flex align-items-center">
|
<thead>
|
||||||
<div class="me-3 fs-4 text-primary">
|
<tr>
|
||||||
<i class="fa-solid fa-user-check"></i>
|
<th>Subject</th>
|
||||||
</div>
|
<th>Scope</th>
|
||||||
<div>
|
<th>Domain</th>
|
||||||
<h6 class="mb-1 fw-bold">
|
<th>Role</th>
|
||||||
<span class="badge bg-secondary me-2">{{ subjectType }}</span> {{ subject }}
|
<th class="text-end">Actions</th>
|
||||||
</h6>
|
</tr>
|
||||||
<div class="small text-muted">
|
</thead>
|
||||||
<span class="me-3"><strong>Scope:</strong> {{ scope }}</span>
|
<tbody id="permission-cards">
|
||||||
<span class="me-3"><strong>Domain:</strong> <code>{{ domain }}</code></span>
|
<tr jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
|
||||||
<span><strong>Role:</strong> <span class="badge bg-info text-dark">{{ role }}</span></span>
|
<td>
|
||||||
</div>
|
<span class="badge text-bg-secondary">{{ subjectType }}</span>
|
||||||
</div>
|
{{ subject }}
|
||||||
</div>
|
</td>
|
||||||
<div class="d-flex gap-2">
|
<td>{{ scope }}</td>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="permissionEditOpen('{{id}}')">
|
<td>{{ domain }}</td>
|
||||||
<i class="fa-solid fa-pen me-1"></i> Edit
|
<td>{{ role }}</td>
|
||||||
</button>
|
<td class="text-end">
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removePermission('{{id}}')">
|
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
|
||||||
<i class="fa-solid fa-trash me-1"></i> Delete
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
Delete
|
||||||
</div>
|
</button>
|
||||||
</li>
|
</td>
|
||||||
</ul>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<% if(ui.profileUrl){ %>
|
<% if(ui.profileUrl){ %>
|
||||||
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
|
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|||||||
+65
-31
@@ -58,18 +58,38 @@
|
|||||||
+ '<div class="form-group">'
|
+ '<div class="form-group">'
|
||||||
+ '<label class="control-label">User-name</label>'
|
+ '<label class="control-label">User-name</label>'
|
||||||
+ '<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />'
|
+ '<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />'
|
||||||
|
+ '<div class="invalid-feedback d-none" data-field-error="username"></div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="form-group">'
|
+ '<div class="form-group">'
|
||||||
+ '<label class="control-label">Password</label>'
|
+ '<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"/>'
|
+ '<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>'
|
||||||
|
+ '<div class="invalid-feedback d-none" data-field-error="password"></div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="form-group">'
|
+ '<div class="form-group">'
|
||||||
+ '<label class="control-label">Again</label>'
|
+ '<label class="control-label">Again</label>'
|
||||||
+ '<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>'
|
+ '<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>'
|
||||||
|
+ '<div class="invalid-feedback d-none" data-field-error="passwordMatch"></div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<hr />'
|
+ '<hr />'
|
||||||
+ '<button type="submit" class="btn btn-info">Add</button>'
|
+ '<button type="submit" class="btn btn-info">Add</button>'
|
||||||
+ '</form>',
|
+ '</form>',
|
||||||
|
onValidationError: function(errors){
|
||||||
|
// Clear all field errors first
|
||||||
|
$('[data-field-error]').addClass('d-none').text('');
|
||||||
|
$('.form-control.is-invalid').removeClass('is-invalid');
|
||||||
|
|
||||||
|
// Show action message at top
|
||||||
|
let errorMsg = 'Please fix the following errors:';
|
||||||
|
for(let field in errors){
|
||||||
|
let $field = $('[name="' + field + '"]');
|
||||||
|
$field.addClass('is-invalid');
|
||||||
|
$field.siblings('[data-field-error]').removeClass('d-none').text(errors[field]);
|
||||||
|
errorMsg += ' ' + field + ': ' + errors[field] + ';';
|
||||||
|
}
|
||||||
|
$('.modal-body .actionMessage').first().length ?
|
||||||
|
$('.modal-body .actionMessage').first().text(errorMsg).removeClass('d-none').addClass('alert alert-danger') :
|
||||||
|
app.messages.action(errorMsg, $('.modal-body'), 'danger');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,39 +128,53 @@
|
|||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-lg-2 g-3" id="user-cards">
|
<div class="table-responsive">
|
||||||
<div class="col" jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
|
<table class="table table-striped mb-0">
|
||||||
<div class="card shadow-sm h-100">
|
<thead>
|
||||||
<div class="card-body">
|
<tr>
|
||||||
<h6 class="d-flex align-items-center mb-2">
|
<th>Username</th>
|
||||||
<i class="fa-solid fa-user me-2"></i>
|
<th>Auth Type</th>
|
||||||
{{ username }}
|
<th>Password</th>
|
||||||
{{#isExternal}}
|
<th>Actions</th>
|
||||||
<span class="badge text-bg-secondary ms-2" title="Provisioned via SSO login; no local password to manage here.">
|
</tr>
|
||||||
<i class="fa-solid fa-cloud"></i> External (SSO)
|
</thead>
|
||||||
</span>
|
<tbody id="user-cards">
|
||||||
{{/isExternal}}
|
<tr jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
|
||||||
</h6>
|
<td>
|
||||||
|
<strong>{{ username }}</strong>
|
||||||
{{^isExternal}}
|
</td>
|
||||||
<form class="input-group input-group-sm mb-2" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
|
<td>
|
||||||
<input type="password" name="password" class="form-control" placeholder="Change password" aria-label="Update password">
|
{{#isExternal}}
|
||||||
<button class="btn btn-warning" type="submit">Change</button>
|
<span class="badge text-bg-secondary" title="Provisioned via SSO login; no local password to manage here.">
|
||||||
</form>
|
<i class="fa-solid fa-cloud"></i> External (SSO)
|
||||||
{{/isExternal}}
|
</span>
|
||||||
{{#isExternal}}
|
{{/isExternal}}
|
||||||
<p class="text-muted small mb-2">Authenticates via SSO -- cannot be edited here.</p>
|
{{^isExternal}}
|
||||||
{{/isExternal}}
|
<span class="badge text-bg-primary">Local</span>
|
||||||
|
{{/isExternal}}
|
||||||
<button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
|
</td>
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
<td>
|
||||||
Delete
|
{{^isExternal}}
|
||||||
</button>
|
<form class="input-group input-group-sm" style="max-width: 350px;" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
|
||||||
</div>
|
<input type="password" name="password" class="form-control" placeholder="Change password" aria-label="Update password">
|
||||||
</div>
|
<button class="btn btn-warning" type="submit">Change</button>
|
||||||
|
</form>
|
||||||
|
{{/isExternal}}
|
||||||
|
{{#isExternal}}
|
||||||
|
<span class="text-muted">Authenticates via SSO — cannot be edited here.</span>
|
||||||
|
{{/isExternal}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,18 +42,6 @@ module.exports = {
|
|||||||
usernameClaim: 'preferred_username',
|
usernameClaim: 'preferred_username',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Read-only access to the SSO's management API, used to populate the
|
|
||||||
// per-host SSO allow-list autocomplete with the directory's actual groups.
|
|
||||||
// A host gated on SSO matches its allow-list against the `groups` claim the
|
|
||||||
// SSO issues, so only SSO groups can ever match -- without this the field
|
|
||||||
// can only suggest the proxy's own local groups. `apiToken` is a machine
|
|
||||||
// token minted by theta-suite's bootstrap; leaving it blank simply falls
|
|
||||||
// back to local-only suggestions.
|
|
||||||
sso: {
|
|
||||||
url: 'http://sso-manager:3001',
|
|
||||||
apiToken: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a
|
// Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a
|
||||||
// self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path
|
// self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path
|
||||||
// for strict verification. bindPassword MUST match the
|
// for strict verification. bindPassword MUST match the
|
||||||
|
|||||||
Reference in New Issue
Block a user