Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d32fb3044 | |||
| 8c0eaf0d07 | |||
| 8c81128235 | |||
| 4f23d301bf | |||
| be666f5b2f | |||
| 2bfba93e00 | |||
| 8cee583da3 | |||
| b5fab6c91a | |||
| c7ec65e0d9 | |||
| 3f2ef57c78 | |||
| c0e04d1a56 | |||
| 130d4a2c9b | |||
| 3b729295b0 | |||
| 44c2ec3fdd | |||
| e9032ec4fb | |||
| a6e745cfc1 | |||
| 8350ab9b57 | |||
| 001fe84b99 | |||
| e05ec1d2ad | |||
| 652df4f3cf | |||
| 9370734084 | |||
| 58204da3cb | |||
| 2d7eadec60 | |||
| 0b9a1aabfd | |||
| 36c3f7a881 |
@@ -6,6 +6,58 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`app_super_admin` cross-app group** recognized as a global admin (`conf.auth.adminGroups`), same group also recognized by sso-manager-node and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Users and Permissions pages**: the always-visible sidebar "Add" forms are now an "Add User"/"Add Permission" button in the list header that opens an `app.modal` dialog, matching the hosts.ejs convention.
|
||||||
|
- **Let's Encrypt ACME account key** now defaults to the already-persisted `/data` volume instead of a CWD-relative path (`./le_key.cert` -> `/app/le_key.cert` in the container), which was lost on every image rebuild.
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Users backed by SSO/OIDC login are now marked "External (SSO)"** and read-only: their password-change control is hidden, and `PUT /password/:username` rejects with 403 server-side. Deletion stays allowed. Redis user-backend only.
|
||||||
|
- **`app.util.revealItem()`** (shared `app-base.js`): scrolls a just-added/-edited element into view and flashes its background. Wired into the Users/Permissions create flows.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **All pages now wrap their content in a standard-width container**, matching sso-manager-node instead of rendering full-bleed.
|
||||||
|
- **Users and Permissions pages converted from bare `<table>`s to the card-grid convention** already used on the Groups page.
|
||||||
|
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **API tokens: "+ New Token" modal button (replacing the always-visible inline create-form card) and a new Edit modal** — continues the cross-app API-token UI unification started in jump-host. The Edit modal's footer shows real created-by/on data; the `PUT /api-token/:id` route already fully supported editing, so no backend change was needed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Creating an API token didn't show the "save this secret now" reveal modal** — the create flow called `app.modal.close()` immediately before `app.modal.open()` (to show the secret) in the same tick; since `app.modal` is a singleton, that collided with Bootstrap's hide-transition guard and the reveal modal silently never appeared.
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Host modal: footer with created/updated-by/on metadata, linkable `/hosts/{host}` URL** — migrated onto the shared `app.modal` component (`@simpleworkjs/frontend` 0.2.6), continuing the entity-modal standardization started in sso-manager-node. Kept the existing 6-tab layout (General/TLS & Wildcard/Traffic/Headers/Access/Authentication) unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The Let's-Encrypt challenge-type/wildcard-matching visibility logic could stop reacting to the hostname field after the first Add/Edit** — its `keyup` handler was bound once directly against a selector captured at page load; now delegated so it survives the modal being rebuilt on every open.
|
||||||
|
- **The SSO allow-list autocomplete (users/groups) could go empty starting on the second Add/Edit** — its backing `<datalist>`s are rebuilt fresh on every modal open now, so the suggestions are refetched each time too.
|
||||||
|
|
||||||
|
## [1.5.3] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node's companion release. Replaced with plain text.
|
||||||
|
|
||||||
|
## [1.5.2] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Regression test**: a static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page and were fully removed in 1.5.1.
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-07-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Removed native `confirm()` calls in `revokeToken`/`rotateToken`** (`profile.ejs`), replacing them with `app.messages.confirm`. Native `confirm()` blocks all further browser events on the page (discovered live, mid browser-verification of sso-manager-node's equivalent flow, where it froze the tab).
|
||||||
|
|
||||||
## [1.5.0] - 2026-07-27
|
## [1.5.0] - 2026-07-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
};
|
||||||
+2
-1
@@ -49,7 +49,8 @@ module.exports = {
|
|||||||
// Per-user overrides are Grant records managed in the app.
|
// Per-user overrides are Grant records managed in the app.
|
||||||
auth: {
|
auth: {
|
||||||
// Members of these SSO/LDAP groups are always global admins.
|
// Members of these SSO/LDAP groups are always global admins.
|
||||||
adminGroups: [],
|
// app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
|
||||||
|
adminGroups: ['app_super_admin'],
|
||||||
// Optional default role mapping for groups, e.g.
|
// Optional default role mapping for groups, e.g.
|
||||||
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
|
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
|
||||||
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
|
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
|
||||||
|
|||||||
@@ -9,10 +9,23 @@ const tldExtract = require('tld-extract').parse_host;
|
|||||||
const LetsEncrypt = require('../utils/letsencrypt');
|
const LetsEncrypt = require('../utils/letsencrypt');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Defaults to the same persisted volume Redis uses (/data, see
|
||||||
|
// docker-entrypoint.sh's REDIS_DATA_DIR) instead of the old CWD-relative
|
||||||
|
// default (./le_key.cert -> /app/le_key.cert), which lives in the
|
||||||
|
// container's writable layer and was lost on every rebuild. Falls back to
|
||||||
|
// the old relative path when /data isn't present (e.g. local dev outside
|
||||||
|
// docker), so it stays writable there too.
|
||||||
|
const dataDir = process.env.REDIS_DATA_DIR || '/data';
|
||||||
|
const accountKeyPath = fs.existsSync(dataDir) ? path.join(dataDir, 'le_key.cert') : './le_key.cert';
|
||||||
|
|
||||||
const letsEncrypt = new LetsEncrypt({
|
const letsEncrypt = new LetsEncrypt({
|
||||||
directoryUrl: conf.environment === "production" ?
|
directoryUrl: conf.environment === "production" ?
|
||||||
LetsEncrypt.AcmeClient.directory.letsencrypt.production :
|
LetsEncrypt.AcmeClient.directory.letsencrypt.production :
|
||||||
LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
|
LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
|
||||||
|
accountKeyPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
class Host extends Table{
|
class Host extends Table{
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.4.0",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.4.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/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.5",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
@@ -310,9 +310,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@simpleworkjs/frontend": {
|
"node_modules/@simpleworkjs/frontend": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
|
||||||
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
|
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.5.0",
|
"version": "1.9.0",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||||
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
|
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js test/integration/dns_provider.test.js",
|
||||||
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js",
|
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js",
|
||||||
"test:integration": "node --test test/integration/dns_provider.test.js",
|
"test:integration": "node --test test/integration/dns_provider.test.js",
|
||||||
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
|
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js test/integration/dns_provider.test.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.5",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||||
|
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||||
|
sticky element offset itself below both fixed elements via
|
||||||
|
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||||
|
viewport's true top:0. */
|
||||||
|
--sw-content-offset: 4.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#spa-shell {
|
#spa-shell {
|
||||||
|
|||||||
@@ -584,10 +584,31 @@ app.util = (function(app){
|
|||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll a just-added/-edited element into view and flash its
|
||||||
|
// background, so the user's eye lands on the row that changed instead of
|
||||||
|
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||||
|
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||||
|
function revealItem(el){
|
||||||
|
var node = el && el.jquery ? el[0] : el;
|
||||||
|
if (!node) return;
|
||||||
|
if (typeof node.scrollIntoView === 'function') {
|
||||||
|
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
}
|
||||||
|
var prevTransition = node.style.transition;
|
||||||
|
var prevBg = node.style.backgroundColor;
|
||||||
|
node.style.transition = 'background-color 1.5s ease';
|
||||||
|
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||||
|
setTimeout(function(){
|
||||||
|
node.style.backgroundColor = prevBg;
|
||||||
|
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
escapeHtml: escapeHtml,
|
escapeHtml: escapeHtml,
|
||||||
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -679,13 +700,10 @@ function formAJAX(btn){
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.messages.action(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
`<div class="spinner-border" role="status">
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
<span class="visually-hidden">Loading...</span>
|
// render literally instead of as an element.
|
||||||
</div>`,
|
app.messages.action('Saving…', $form, 'info');
|
||||||
$form,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ router.get('/hosts', async function(req, res, next) {
|
|||||||
res.render('hosts', {...values});
|
res.render('hosts', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Linkable deep-link to a single host's modal, e.g. from the host modal's
|
||||||
|
// app.modal `url` option. No server-side use of :host -- the client reads
|
||||||
|
// location.pathname itself and opens the matching host's modal once the
|
||||||
|
// page's own data has loaded (same idiom sso-manager-node uses for
|
||||||
|
// /directory/:slug).
|
||||||
|
router.get('/hosts/:host', async function(req, res, next) {
|
||||||
|
res.render('hosts', {...values});
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/dns', async function(req, res, next) {
|
router.get('/dns', async function(req, res, next) {
|
||||||
res.render('dns', {...values});
|
res.render('dns', {...values});
|
||||||
});
|
});
|
||||||
|
|||||||
+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){
|
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
validatePassword(req.body.password);
|
validatePassword(req.body.password);
|
||||||
let user = await User.get(req.params.username);
|
let user = await User.get(req.params.username);
|
||||||
|
if(user.backing === 'oidc'){
|
||||||
|
let e = new Error('Cannot set a password for an SSO-authenticated user.');
|
||||||
|
e.status = 403;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
return res.json({results: await user.setPassword(req.body)});
|
return res.json({results: await user.setPassword(req.body)});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||||
|
// browser events on the page (found live, mid browser-automation testing, on
|
||||||
|
// sso-manager-node's equivalent secret-rotate flow) and are visually
|
||||||
|
// inconsistent with the rest of the UI. Every call site was removed in favor
|
||||||
|
// of app.messages.action/confirm/toast and app.modal.open; this test keeps
|
||||||
|
// it that way.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', '..', d));
|
||||||
|
|
||||||
|
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||||
|
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const root of ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
let m;
|
||||||
|
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||||
|
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||||
|
const line = src.slice(0, m.index).split('\n').length;
|
||||||
|
offenders.push(`${path.relative(path.join(__dirname, '..', '..'), file)}:${line} — ${m[2]}(`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(offenders, []);
|
||||||
|
});
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row mb-3" style="display:none">
|
<div class="row mb-3" style="display:none">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card shadow-lg mb-3">
|
<div class="card shadow-lg mb-3">
|
||||||
@@ -299,4 +300,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
+17
-3
@@ -33,6 +33,14 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, $.scope.LocalGroup.$this, 'danger');
|
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
|
||||||
@@ -68,16 +76,21 @@
|
|||||||
app.subscribe(/^model:LocalGroup:create/, function(data){
|
app.subscribe(/^model:LocalGroup:create/, function(data){
|
||||||
$.scope.LocalGroup.remove(data.name);
|
$.scope.LocalGroup.remove(data.name);
|
||||||
$.scope.LocalGroup.unshift(data);
|
$.scope.LocalGroup.unshift(data);
|
||||||
|
// Also refresh to ensure the full list is up to date
|
||||||
|
setTimeout(refreshGroups, 500);
|
||||||
|
});
|
||||||
|
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
|
||||||
|
$.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){
|
app.subscribe(/^model:LocalGroup:update/, function(data, topic){
|
||||||
$.scope.LocalGroup.update(topic.split(':')[3], data);
|
$.scope.LocalGroup.update(topic.split(':')[3], data);
|
||||||
});
|
});
|
||||||
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
|
|
||||||
$.scope.LocalGroup.remove(topic.split(':')[3]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<datalist id="groupUsers"></datalist>
|
<datalist id="groupUsers"></datalist>
|
||||||
|
|
||||||
<div class="row" style="display:none">
|
<div class="row" style="display:none">
|
||||||
@@ -143,4 +156,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
+386
-374
@@ -61,7 +61,7 @@
|
|||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostPopulate(){
|
function hostPopulate(onLoaded){
|
||||||
app.api.get('host?detail=1&provider=1', function(error, res){
|
app.api.get('host?detail=1&provider=1', function(error, res){
|
||||||
if(error) return app.messages.action(error, $.scope.hosts.$this, 'danger');
|
if(error) return app.messages.action(error, $.scope.hosts.$this, 'danger');
|
||||||
|
|
||||||
@@ -75,6 +75,8 @@
|
|||||||
$el.removeClass('table-success');
|
$el.removeClass('table-success');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (onLoaded) onLoaded();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +92,341 @@
|
|||||||
|
|
||||||
// ----- Add / Edit modal --------------------------------------------------
|
// ----- Add / Edit modal --------------------------------------------------
|
||||||
|
|
||||||
function hostModal(){
|
function hostModalClose(){ app.modal.close(); }
|
||||||
return bootstrap.Modal.getOrCreateInstance(document.getElementById('hostModal'));
|
|
||||||
|
// Built once; injected fresh into app.modal's body on every open() call
|
||||||
|
// (unlike the old always-in-DOM static modal). .actionMessage lives
|
||||||
|
// INSIDE the form (not a sibling, as it was before) because formAJAX's
|
||||||
|
// error/success target resolves via $form.closest('div.card') (which
|
||||||
|
// app.modal's plain, card-less .modal-content never matches) falling
|
||||||
|
// back to $form.find('.actionMessage') -- a descendant search.
|
||||||
|
var hostModalBodyHtml = `
|
||||||
|
<form id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
|
||||||
|
<div class="actionMessage m-0" style="display:none"></div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs flex-nowrap overflow-x-auto" role="tablist">
|
||||||
|
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS & Wildcard</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content pt-3">
|
||||||
|
|
||||||
|
<!-- General -->
|
||||||
|
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="host" class="form-label">Incoming host name</label>
|
||||||
|
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
|
||||||
|
<b class="invalid-feedback"></b>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
The public hostname clients request. Use <code>*.example.com</code>
|
||||||
|
for one subdomain level, <code>**.example.com</code> for any depth,
|
||||||
|
or <code>**</code> as a catch-all.
|
||||||
|
</small>
|
||||||
|
<small id="host-rename-help" class="field-help text-muted d-block" style="display:none">
|
||||||
|
Wildcard hosts, their children, and auto-created subdomain cache
|
||||||
|
entries can't be renamed here — the name is referenced elsewhere
|
||||||
|
(the wildcard's own children, or the cache entry's parent). Delete
|
||||||
|
and recreate instead.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Incoming SSL</label>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
|
||||||
|
Force HTTPS <b>(recommended)</b>
|
||||||
|
</label></div>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="forcessl" id="forcessl-false" value="false">
|
||||||
|
Allow both HTTP and HTTPS
|
||||||
|
</label></div>
|
||||||
|
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ip" class="form-label">Target IP or host name</label>
|
||||||
|
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10, app.internal.net, or sso-manager" validate="target:3" />
|
||||||
|
<b class="invalid-feedback"></b>
|
||||||
|
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only — no protocol, port, or path.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col form-group">
|
||||||
|
<label for="targetPort" class="form-label">Target TCP port</label>
|
||||||
|
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
|
||||||
|
<b class="invalid-feedback"></b>
|
||||||
|
</div>
|
||||||
|
<div class="col form-group">
|
||||||
|
<label class="form-label">Target SSL</label>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
|
||||||
|
Proxy to HTTP <b>(recommended)</b>
|
||||||
|
</label></div>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="targetssl" id="targetssl-true" value="true">
|
||||||
|
Proxy to HTTPS
|
||||||
|
</label></div>
|
||||||
|
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="targets" class="form-label">Additional Targets (Load Balancing)</label>
|
||||||
|
<textarea name="targets" class="form-control" rows="2" placeholder="10.0.0.2:8080 10.0.0.3:8080"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TLS & Wildcard -->
|
||||||
|
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
|
||||||
|
<div class="form-group autoSll">
|
||||||
|
<label class="form-label">
|
||||||
|
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
|
||||||
|
</label>
|
||||||
|
<div class="radio" id="challengeType-HTTP-01-container"><label>
|
||||||
|
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
|
||||||
|
HTTP-01
|
||||||
|
</label></div>
|
||||||
|
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
|
||||||
|
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
|
||||||
|
DNS-01 Wildcard
|
||||||
|
</label></div>
|
||||||
|
<div class="radio challengeType-container" id="challengeType-child-container"><label>
|
||||||
|
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
|
||||||
|
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
|
||||||
|
</label></div>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
Options light up based on the host name: wildcard certs need a DNS
|
||||||
|
provider for the domain; child hosts reuse a parent wildcard.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
|
||||||
|
<label class="form-label">Wildcard matching</label>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
|
||||||
|
Match only subdomains defined here <b>(recommended)</b>
|
||||||
|
</label></div>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
|
||||||
|
Match any subdomain and proxy to this host
|
||||||
|
</label></div>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
"Recommended" only routes subdomains you've explicitly registered
|
||||||
|
as their own host (optionally as a "Parent Wildcard" child of this
|
||||||
|
one, to reuse this cert). "Match any" auto-creates a temporary
|
||||||
|
route to this host's target for <i>any</i> undefined subdomain the
|
||||||
|
first time it's requested — convenient, but it means every subdomain
|
||||||
|
typo or scan attempt also gets routed here.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Traffic -->
|
||||||
|
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Rate limiting</label>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
|
||||||
|
Off <b>(recommended)</b>
|
||||||
|
</label></div>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
|
||||||
|
Limit requests per client IP
|
||||||
|
</label></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col form-group">
|
||||||
|
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
|
||||||
|
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
|
||||||
|
</div>
|
||||||
|
<div class="col form-group">
|
||||||
|
<label for="ratelimit_burst" class="form-label">Burst</label>
|
||||||
|
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Response caching</label>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
|
||||||
|
Off <b>(recommended)</b>
|
||||||
|
</label></div>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
|
||||||
|
Cache cacheable responses
|
||||||
|
</label></div>
|
||||||
|
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">HSTS</label>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
|
||||||
|
Off
|
||||||
|
</label></div>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
|
||||||
|
Send Strict-Transport-Security
|
||||||
|
</label></div>
|
||||||
|
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Headers -->
|
||||||
|
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="req_headers" class="form-label">Upstream request headers</label>
|
||||||
|
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="resp_headers" class="form-label">Response headers</label>
|
||||||
|
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Access -->
|
||||||
|
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
|
||||||
|
<h6 class="text-muted">IP access</h6>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
|
||||||
|
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
|
||||||
|
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authentication -->
|
||||||
|
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
|
||||||
|
<p class="field-help text-muted">
|
||||||
|
Pick one authentication method for this host — basic auth and
|
||||||
|
SSO can't both be enabled, to avoid ambiguity about which one
|
||||||
|
actually protected a request. Choose "Off" for a public host.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="auth_mode" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
|
||||||
|
Off (public)
|
||||||
|
</label></div>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="auth_mode" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
|
||||||
|
Basic authentication
|
||||||
|
</label></div>
|
||||||
|
<div class="radio"><label>
|
||||||
|
<input type="radio" name="auth_mode" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
|
||||||
|
Single sign-on (SSO)
|
||||||
|
</label></div>
|
||||||
|
</div>
|
||||||
|
<!-- Actually-submitted fields; kept in sync with the radios above by
|
||||||
|
hostAuthModeChanged() so only one can be true at a time. -->
|
||||||
|
<input type="hidden" name="basicauth_enabled" id="basicauth_enabled-hidden" value="false">
|
||||||
|
<input type="hidden" name="sso_enabled" id="sso_enabled-hidden" value="false">
|
||||||
|
|
||||||
|
<div id="hostTab-auth-basicFields" style="display:none">
|
||||||
|
<hr>
|
||||||
|
<h6 class="text-muted">Basic authentication</h6>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="basicauth_realm" class="form-label">Realm</label>
|
||||||
|
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="basicauth_users" class="form-label">Users</label>
|
||||||
|
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
Current: <span class="basicauth-current">none</span>.
|
||||||
|
Passwords are stored hashed and never shown here. Leave blank to keep
|
||||||
|
the current users; entering any lines replaces the whole list. To
|
||||||
|
manage individual users (delete / change password), use the table
|
||||||
|
below once the host has been saved.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hostTab-auth-ssoFields" style="display:none">
|
||||||
|
<hr>
|
||||||
|
<h6 class="text-muted">Single sign-on (SSO)</h6>
|
||||||
|
<p class="field-help text-muted">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sso_allow_users" class="form-label">Allowed users</label>
|
||||||
|
<div class="input-group mb-1">
|
||||||
|
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
|
||||||
|
<i class="fa-solid fa-plus"></i> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
|
||||||
|
<div class="input-group mb-1">
|
||||||
|
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
|
||||||
|
<i class="fa-solid fa-plus"></i> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hostTab-auth-basicUsersMgmt" style="display:none">
|
||||||
|
<hr>
|
||||||
|
<h6 class="text-muted">Manage basic-auth users</h6>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>Username</th><th>New password</th><th></th></tr></thead>
|
||||||
|
<tbody id="basicAuthUserRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<datalist id="hostSsoUsers"></datalist>
|
||||||
|
<datalist id="hostSsoGroups"></datalist>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
function openHostModal(title, submitLabel, host){
|
||||||
|
app.modal.open({
|
||||||
|
title: title,
|
||||||
|
size: 'lg',
|
||||||
|
bodyHtml: hostModalBodyHtml,
|
||||||
|
footer: {
|
||||||
|
// created_on/updated_on come back as redis-hash strings, not
|
||||||
|
// numbers -- moment's bare fallback parser mishandles that
|
||||||
|
// (silently "Invalid date"), same reason hostParseRow above
|
||||||
|
// uses the explicit "x" (unix ms) format token.
|
||||||
|
metaHtml: host ? app.modal.formatAudit(host, {formatDate: function(ms){ return moment(ms, 'x').format('YYYY-MM-DD HH:mm'); }}) : '',
|
||||||
|
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><i class="fa-solid fa-ban"></i> Cancel</button>'
|
||||||
|
+ '<button type="submit" form="hostForm" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> ' + $('<div>').text(submitLabel).html() + '</button>',
|
||||||
|
},
|
||||||
|
url: host ? {path: '/hosts/' + encodeURIComponent(host.host)} : null,
|
||||||
|
});
|
||||||
|
// The datalists were just rebuilt empty -- refill them (cheap re-fetch,
|
||||||
|
// no cache bug here since this function has no cache guard to skip it).
|
||||||
|
hostLoadAuthSuggestions();
|
||||||
}
|
}
|
||||||
function hostModalClose(){ hostModal().hide(); }
|
|
||||||
|
|
||||||
function hostShowTab(id){
|
function hostShowTab(id){
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
|
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
|
||||||
@@ -206,20 +539,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hostAddOpen(){
|
function hostAddOpen(){
|
||||||
|
openHostModal('Add host', 'Add host', null);
|
||||||
hostFormReset();
|
hostFormReset();
|
||||||
$('#hostModalTitle').text('Add host');
|
|
||||||
$('#hostModalSubmitText').text('Add host');
|
|
||||||
hostModal().show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hostEditOpen(host){
|
async function hostEditOpen(host){
|
||||||
hostFormReset();
|
|
||||||
let h = $.scope.hosts.getByKey(host);
|
let h = $.scope.hosts.getByKey(host);
|
||||||
|
openHostModal('Edit ' + host, 'Save changes', h);
|
||||||
|
hostFormReset();
|
||||||
let $f = $('#hostForm');
|
let $f = $('#hostForm');
|
||||||
|
|
||||||
$f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host));
|
$f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host));
|
||||||
$('#hostModalTitle').text('Edit ' + host);
|
|
||||||
$('#hostModalSubmitText').text('Save changes');
|
|
||||||
|
|
||||||
// Scalar fields: booleans drive the matching radio, everything else the
|
// Scalar fields: booleans drive the matching radio, everything else the
|
||||||
// input with that name. Object/array fields are handled as text below.
|
// input with that name. Object/array fields are handled as text below.
|
||||||
@@ -285,8 +615,6 @@
|
|||||||
$('#challengeType-HTTP-01').prop('checked', true);
|
$('#challengeType-HTTP-01').prop('checked', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hostModal().show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostDownloadCert(host, type){
|
function hostDownloadCert(host, type){
|
||||||
@@ -341,41 +669,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine what Let's Encrypt challenge type the given host name can use.
|
||||||
|
// Delegated (not a direct bind against a captured selector) since
|
||||||
|
// app.modal.open() rebuilds #hostForm from scratch on every call -- a
|
||||||
|
// direct .on('keyup', ...) bind here would silently stop firing after
|
||||||
|
// the first Add/Edit.
|
||||||
|
app.modal.on('keyup', '#hostForm [name=host]', async function(){
|
||||||
|
let $hostField = $(this);
|
||||||
|
// Reset the allowed types on start
|
||||||
|
$('#challengeType-child-container').addClass('challengeType-container');
|
||||||
|
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
|
||||||
|
$('#wildcard_matchAny-container').addClass('challengeType-container');
|
||||||
|
|
||||||
|
let host = $hostField.val();
|
||||||
|
|
||||||
|
// If it's a wildcard, we must check the domain has a registered provider.
|
||||||
|
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
|
||||||
|
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
|
||||||
|
$('#wildcard_matchAny-container').removeClass('challengeType-container');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a wildcard cert is available for the given host. When it is,
|
||||||
|
// make "Parent Wildcard" the default choice (it reuses an existing cert).
|
||||||
|
let wildcardParent = await hostMatchWildcard(host);
|
||||||
|
if(wildcardParent){
|
||||||
|
$('#challengeType-child-container').removeClass('challengeType-container');
|
||||||
|
$('#challengeType-child-relatedHost').text(wildcardParent.host);
|
||||||
|
$('#challengeType-wildcardChild').prop('checked', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert the form to a valid state.
|
||||||
|
$('#challengeType-child-relatedHost').text('');
|
||||||
|
$('#challengeType-HTTP-01').prop('checked', true);
|
||||||
|
});
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
// Populate the host UI table
|
// Populate the host UI table, then check for a deep-linked host
|
||||||
hostPopulate();
|
// (e.g. a direct load of /hosts/some-host, or app.modal's url option
|
||||||
hostLoadAuthSuggestions();
|
// having pushed that path) once the table's data is actually loaded.
|
||||||
|
hostPopulate(function(){
|
||||||
// Determine what Let's Encrypt challenge type the given host name can use.
|
const slug = app.modal.deepLinkSlug('/hosts');
|
||||||
let $hostField = $('#hostForm [name=host]');
|
if (slug) {
|
||||||
$hostField.on('keyup', async function(){
|
const h = $.scope.hosts.getByKey(decodeURIComponent(slug));
|
||||||
// Reset the allowed types on start
|
if (h) hostEditOpen(h.host);
|
||||||
$('#challengeType-child-container').addClass('challengeType-container');
|
|
||||||
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
|
|
||||||
$('#wildcard_matchAny-container').addClass('challengeType-container');
|
|
||||||
|
|
||||||
let host = $hostField.val();
|
|
||||||
|
|
||||||
// If it's a wildcard, we must check the domain has a registered provider.
|
|
||||||
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
|
|
||||||
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
|
|
||||||
$('#wildcard_matchAny-container').removeClass('challengeType-container');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a wildcard cert is available for the given host. When it is,
|
|
||||||
// make "Parent Wildcard" the default choice (it reuses an existing cert).
|
|
||||||
let wildcardParent = await hostMatchWildcard(host);
|
|
||||||
if(wildcardParent){
|
|
||||||
$('#challengeType-child-container').removeClass('challengeType-container');
|
|
||||||
$('#challengeType-child-relatedHost').text(wildcardParent.host);
|
|
||||||
$('#challengeType-wildcardChild').prop('checked', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revert the form to a valid state.
|
|
||||||
$('#challengeType-child-relatedHost').text('');
|
|
||||||
$('#challengeType-HTTP-01').prop('checked', true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$.scope.hosts.take = function($el, item, list){
|
$.scope.hosts.take = function($el, item, list){
|
||||||
@@ -413,6 +752,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row" style="display:none">
|
<div class="row" style="display:none">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-lg hostListPanel">
|
<div class="card shadow-lg hostListPanel">
|
||||||
@@ -547,334 +887,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add / Edit host modal ------------------------------------------------- -->
|
|
||||||
<div class="modal fade" id="hostModal" tabindex="-1" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content card border-0">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
|
|
||||||
<a href="/docs/hosts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-header actionMessage m-0" style="display:none"></div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<ul class="nav nav-tabs flex-nowrap overflow-x-auto" role="tablist">
|
|
||||||
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
|
|
||||||
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS & Wildcard</button></li>
|
|
||||||
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
|
|
||||||
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
|
|
||||||
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
|
|
||||||
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<form class="addHost" id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
|
|
||||||
<div class="tab-content pt-3">
|
|
||||||
|
|
||||||
<!-- General -->
|
|
||||||
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="host" class="form-label">Incoming host name</label>
|
|
||||||
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
|
|
||||||
<b class="invalid-feedback"></b>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
The public hostname clients request. Use <code>*.example.com</code>
|
|
||||||
for one subdomain level, <code>**.example.com</code> for any depth,
|
|
||||||
or <code>**</code> as a catch-all.
|
|
||||||
</small>
|
|
||||||
<small id="host-rename-help" class="field-help text-muted d-block" style="display:none">
|
|
||||||
Wildcard hosts, their children, and auto-created subdomain cache
|
|
||||||
entries can't be renamed here — the name is referenced elsewhere
|
|
||||||
(the wildcard's own children, or the cache entry's parent). Delete
|
|
||||||
and recreate instead.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Incoming SSL</label>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
|
|
||||||
Force HTTPS <b>(recommended)</b>
|
|
||||||
</label></div>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="forcessl" id="forcessl-false" value="false">
|
|
||||||
Allow both HTTP and HTTPS
|
|
||||||
</label></div>
|
|
||||||
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ip" class="form-label">Target IP or host name</label>
|
|
||||||
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10, app.internal.net, or sso-manager" validate="target:3" />
|
|
||||||
<b class="invalid-feedback"></b>
|
|
||||||
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only — no protocol, port, or path.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col form-group">
|
|
||||||
<label for="targetPort" class="form-label">Target TCP port</label>
|
|
||||||
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
|
|
||||||
<b class="invalid-feedback"></b>
|
|
||||||
</div>
|
|
||||||
<div class="col form-group">
|
|
||||||
<label class="form-label">Target SSL</label>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
|
|
||||||
Proxy to HTTP <b>(recommended)</b>
|
|
||||||
</label></div>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="targetssl" id="targetssl-true" value="true">
|
|
||||||
Proxy to HTTPS
|
|
||||||
</label></div>
|
|
||||||
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="targets" class="form-label">Additional Targets (Load Balancing)</label>
|
|
||||||
<textarea name="targets" class="form-control" rows="2" placeholder="10.0.0.2:8080 10.0.0.3:8080"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TLS & Wildcard -->
|
|
||||||
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
|
|
||||||
<div class="form-group autoSll">
|
|
||||||
<label class="form-label">
|
|
||||||
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
|
|
||||||
</label>
|
|
||||||
<div class="radio" id="challengeType-HTTP-01-container"><label>
|
|
||||||
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
|
|
||||||
HTTP-01
|
|
||||||
</label></div>
|
|
||||||
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
|
|
||||||
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
|
|
||||||
DNS-01 Wildcard
|
|
||||||
</label></div>
|
|
||||||
<div class="radio challengeType-container" id="challengeType-child-container"><label>
|
|
||||||
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
|
|
||||||
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
|
|
||||||
</label></div>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
Options light up based on the host name: wildcard certs need a DNS
|
|
||||||
provider for the domain; child hosts reuse a parent wildcard.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
|
|
||||||
<label class="form-label">Wildcard matching</label>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
|
|
||||||
Match only subdomains defined here <b>(recommended)</b>
|
|
||||||
</label></div>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
|
|
||||||
Match any subdomain and proxy to this host
|
|
||||||
</label></div>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
"Recommended" only routes subdomains you've explicitly registered
|
|
||||||
as their own host (optionally as a "Parent Wildcard" child of this
|
|
||||||
one, to reuse this cert). "Match any" auto-creates a temporary
|
|
||||||
route to this host's target for <i>any</i> undefined subdomain the
|
|
||||||
first time it's requested — convenient, but it means every subdomain
|
|
||||||
typo or scan attempt also gets routed here.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Traffic -->
|
|
||||||
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Rate limiting</label>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
|
|
||||||
Off <b>(recommended)</b>
|
|
||||||
</label></div>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
|
|
||||||
Limit requests per client IP
|
|
||||||
</label></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col form-group">
|
|
||||||
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
|
|
||||||
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
|
|
||||||
</div>
|
|
||||||
<div class="col form-group">
|
|
||||||
<label for="ratelimit_burst" class="form-label">Burst</label>
|
|
||||||
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Response caching</label>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
|
|
||||||
Off <b>(recommended)</b>
|
|
||||||
</label></div>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
|
|
||||||
Cache cacheable responses
|
|
||||||
</label></div>
|
|
||||||
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">HSTS</label>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
|
|
||||||
Off
|
|
||||||
</label></div>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
|
|
||||||
Send Strict-Transport-Security
|
|
||||||
</label></div>
|
|
||||||
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Headers -->
|
|
||||||
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="req_headers" class="form-label">Upstream request headers</label>
|
|
||||||
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="resp_headers" class="form-label">Response headers</label>
|
|
||||||
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Access -->
|
|
||||||
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
|
|
||||||
<h6 class="text-muted">IP access</h6>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
|
|
||||||
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
|
|
||||||
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Authentication -->
|
|
||||||
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
|
|
||||||
<p class="field-help text-muted">
|
|
||||||
Pick one authentication method for this host — basic auth and
|
|
||||||
SSO can't both be enabled, to avoid ambiguity about which one
|
|
||||||
actually protected a request. Choose "Off" for a public host.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="auth_mode" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
|
|
||||||
Off (public)
|
|
||||||
</label></div>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="auth_mode" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
|
|
||||||
Basic authentication
|
|
||||||
</label></div>
|
|
||||||
<div class="radio"><label>
|
|
||||||
<input type="radio" name="auth_mode" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
|
|
||||||
Single sign-on (SSO)
|
|
||||||
</label></div>
|
|
||||||
</div>
|
|
||||||
<!-- Actually-submitted fields; kept in sync with the radios above by
|
|
||||||
hostAuthModeChanged() so only one can be true at a time. -->
|
|
||||||
<input type="hidden" name="basicauth_enabled" id="basicauth_enabled-hidden" value="false">
|
|
||||||
<input type="hidden" name="sso_enabled" id="sso_enabled-hidden" value="false">
|
|
||||||
|
|
||||||
<div id="hostTab-auth-basicFields" style="display:none">
|
|
||||||
<hr>
|
|
||||||
<h6 class="text-muted">Basic authentication</h6>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="basicauth_realm" class="form-label">Realm</label>
|
|
||||||
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="basicauth_users" class="form-label">Users</label>
|
|
||||||
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
Current: <span class="basicauth-current">none</span>.
|
|
||||||
Passwords are stored hashed and never shown here. Leave blank to keep
|
|
||||||
the current users; entering any lines replaces the whole list. To
|
|
||||||
manage individual users (delete / change password), use the table
|
|
||||||
below once the host has been saved.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hostTab-auth-ssoFields" style="display:none">
|
|
||||||
<hr>
|
|
||||||
<h6 class="text-muted">Single sign-on (SSO)</h6>
|
|
||||||
<p class="field-help text-muted">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</p>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="sso_allow_users" class="form-label">Allowed users</label>
|
|
||||||
<div class="input-group mb-1">
|
|
||||||
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
|
|
||||||
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
|
|
||||||
<i class="fa-solid fa-plus"></i> Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
|
|
||||||
<div class="input-group mb-1">
|
|
||||||
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
|
|
||||||
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
|
|
||||||
<i class="fa-solid fa-plus"></i> Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hostTab-auth-basicUsersMgmt" style="display:none">
|
|
||||||
<hr>
|
|
||||||
<h6 class="text-muted">Manage basic-auth users</h6>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead><tr><th>Username</th><th>New password</th><th></th></tr></thead>
|
|
||||||
<tbody id="basicAuthUserRows"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<datalist id="hostSsoUsers"></datalist>
|
|
||||||
<datalist id="hostSsoGroups"></datalist>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
||||||
<i class="fa-solid fa-ban"></i> Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" form="hostForm" class="btn btn-success">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i>
|
|
||||||
<span id="hostModalSubmitText">Add host</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -9,12 +9,9 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
}
|
}
|
||||||
.card-title{
|
.card-title{ font-weight: bold; }
|
||||||
font-weight: bold;
|
.member-pill{ cursor: default; }
|
||||||
}
|
.member-pill i{ cursor: pointer; }
|
||||||
.field-hint{
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@@ -40,6 +37,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function permissionAddOpen(){
|
||||||
|
app.modal.open({title: 'Add Permission', bodyHtml:
|
||||||
|
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close();">'
|
||||||
|
+ '<div class="form-group">'
|
||||||
|
+ '<label class="control-label">Subject type</label>'
|
||||||
|
+ '<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">'
|
||||||
|
+ '<option value="user">User</option>'
|
||||||
|
+ '<option value="group">Group</option>'
|
||||||
|
+ '</select>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="form-group">'
|
||||||
|
+ '<label class="control-label">Subject (username or group)</label>'
|
||||||
|
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="form-group">'
|
||||||
|
+ '<label class="control-label">Scope</label>'
|
||||||
|
+ '<select class="form-control" name="scope">'
|
||||||
|
+ '<option value="domain">Domain</option>'
|
||||||
|
+ '<option value="global">Global</option>'
|
||||||
|
+ '</select>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="form-group">'
|
||||||
|
+ '<label class="control-label">Domain (for domain scope)</label>'
|
||||||
|
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
|
||||||
|
+ '<div class="field-hint text-muted">'
|
||||||
|
+ 'Wildcards: <code>*.example.com</code> matches one label, '
|
||||||
|
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
|
||||||
|
+ '<code>**</code> matches every domain.'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="form-group">'
|
||||||
|
+ '<label class="control-label">Role</label>'
|
||||||
|
+ '<select class="form-control" name="role">'
|
||||||
|
+ '<option value="viewer">Viewer (read)</option>'
|
||||||
|
+ '<option value="manager">Manager (full over domain)</option>'
|
||||||
|
+ '<option value="admin">Admin (global only)</option>'
|
||||||
|
+ '</select>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<hr />'
|
||||||
|
+ '<button type="submit" class="btn btn-info">Add Permission</button>'
|
||||||
|
+ '</form>',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function removePermission(id){
|
function removePermission(id){
|
||||||
app.permission.remove(id, function(error, data){
|
app.permission.remove(id, function(error, data){
|
||||||
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
|
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
|
||||||
@@ -66,6 +107,7 @@
|
|||||||
app.subscribe(/^model:Permission:create/, function(data){
|
app.subscribe(/^model:Permission:create/, function(data){
|
||||||
$.scope.Permission.remove(data.id);
|
$.scope.Permission.remove(data.id);
|
||||||
$.scope.Permission.unshift(data);
|
$.scope.Permission.unshift(data);
|
||||||
|
setTimeout(function(){ app.util.revealItem($('#permission-row-' + data.id)); }, 100);
|
||||||
});
|
});
|
||||||
app.subscribe(/^model:Permission:remove/, function(data, topic){
|
app.subscribe(/^model:Permission:remove/, function(data, topic){
|
||||||
$.scope.Permission.remove(topic.split(':')[3]);
|
$.scope.Permission.remove(topic.split(':')[3]);
|
||||||
@@ -73,67 +115,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<datalist id="subjectUsers"></datalist>
|
<datalist id="subjectUsers"></datalist>
|
||||||
<datalist id="subjectGroups"></datalist>
|
<datalist id="subjectGroups"></datalist>
|
||||||
|
|
||||||
<div class="row" style="display:none">
|
<div class="row" style="display:none">
|
||||||
<div class="col-md-4">
|
<div class="col-12">
|
||||||
<div class="card shadow-lg">
|
|
||||||
|
|
||||||
<div class="card-header text-center">
|
|
||||||
<span class="card-icon float-start">
|
|
||||||
<i class="fa-solid fa-user-shield"></i>
|
|
||||||
</span>
|
|
||||||
<span class="card-title">Add Permission</span>
|
|
||||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="permission/" onsubmit="formAJAX(this)">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">Subject type</label>
|
|
||||||
<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">
|
|
||||||
<option value="user">User</option>
|
|
||||||
<option value="group">Group</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">Subject (username or group)</label>
|
|
||||||
<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">Scope</label>
|
|
||||||
<select class="form-control" name="scope">
|
|
||||||
<option value="domain">Domain</option>
|
|
||||||
<option value="global">Global</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">Domain (for domain scope)</label>
|
|
||||||
<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />
|
|
||||||
<div class="field-hint text-muted">
|
|
||||||
Wildcards: <code>*.example.com</code> matches one label,
|
|
||||||
<code>**.example.com</code> matches any depth (incl. the apex),
|
|
||||||
<code>**</code> matches every domain.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">Role</label>
|
|
||||||
<select class="form-control" name="role">
|
|
||||||
<option value="viewer">Viewer (read)</option>
|
|
||||||
<option value="manager">Manager (full over domain)</option>
|
|
||||||
<option value="admin">Admin (global only)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<button type="submit" class="btn btn-info">Add Permission</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
|
|
||||||
<div class="card-header text-center">
|
<div class="card-header text-center">
|
||||||
@@ -141,38 +128,50 @@
|
|||||||
<i class="fa-solid fa-list-check"></i>
|
<i class="fa-solid fa-list-check"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="card-title">Permissions</span>
|
<span class="card-title">Permissions</span>
|
||||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
<span class="float-end">
|
||||||
|
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" onclick="permissionAddOpen()">
|
||||||
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
|
Add Permission
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="table-responsive">
|
<div class="card-body">
|
||||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
<div class="table-responsive">
|
||||||
<thead>
|
<table class="table table-striped mb-0">
|
||||||
<th>Type</th>
|
<thead>
|
||||||
<th>Subject</th>
|
<tr>
|
||||||
<th>Scope</th>
|
<th>Subject</th>
|
||||||
<th>Domain</th>
|
<th>Scope</th>
|
||||||
<th>Role</th>
|
<th>Domain</th>
|
||||||
<th>Delete</th>
|
<th>Role</th>
|
||||||
</thead>
|
<th class="text-end">Actions</th>
|
||||||
<tbody>
|
</tr>
|
||||||
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none">
|
</thead>
|
||||||
<td class="align-middle">{{ subjectType }}</td>
|
<tbody id="permission-cards">
|
||||||
<td class="align-middle">{{ subject }}</td>
|
<tr jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
|
||||||
<td class="align-middle">{{ scope }}</td>
|
<td>
|
||||||
<td class="align-middle">{{ domain }}</td>
|
<span class="badge text-bg-secondary">{{ subjectType }}</span>
|
||||||
<td class="align-middle">{{ role }}</td>
|
{{ subject }}
|
||||||
<td class="align-middle">
|
</td>
|
||||||
<button type="button" class="btn btn-danger" onclick="removePermission('{{id}}')">
|
<td>{{ scope }}</td>
|
||||||
<i class="fa-solid fa-trash"></i>
|
<td>{{ domain }}</td>
|
||||||
Delete
|
<td>{{ role }}</td>
|
||||||
</button>
|
<td class="text-end">
|
||||||
</td>
|
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
|
||||||
</tr>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</tbody>
|
Delete
|
||||||
</table>
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
+149
-90
@@ -69,6 +69,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card shadow-lg" id="profile-card">
|
<div class="card shadow-lg" id="profile-card">
|
||||||
@@ -102,43 +103,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<script type="text/javascript">
|
||||||
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
|
||||||
var tokensById = {};
|
var tokensById = {};
|
||||||
|
|
||||||
function showSecret(secret){
|
// Shared "reveal secret once" display -- same pattern as jump-host's
|
||||||
document.getElementById('secretValue').value = secret;
|
// showToken(), which sso-manager-node also uses.
|
||||||
secretModal.show();
|
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(){
|
// Not the checkmark-flash technique some of this codebase's other copy
|
||||||
var el = document.getElementById('secretValue');
|
// buttons use -- FontAwesome replaces <i> icons with inline <svg>, so
|
||||||
el.select(); el.setSelectionRange(0, 99999);
|
// swapping the <i>'s class silently no-ops. A toast doesn't have that
|
||||||
try { document.execCommand('copy'); } catch(_){}
|
// 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){
|
function fmtTime(ms){
|
||||||
@@ -171,93 +162,161 @@
|
|||||||
function tableAJAX(){
|
function tableAJAX(){
|
||||||
app.apiToken.list(function(error, data){
|
app.apiToken.list(function(error, data){
|
||||||
if(error) return app.messages.action(error, $.scope.apiTokenCard.$this, 'danger');
|
if(error) return app.messages.action(error, $.scope.apiTokenCard.$this, 'danger');
|
||||||
|
var tokens = data.results || [];
|
||||||
$.scope.apiTokenCard.empty();
|
$.scope.apiTokenCard.empty();
|
||||||
(data.results || []).forEach(function(token){
|
tokens.forEach(function(token){
|
||||||
$.scope.apiTokenCard.push(processToken(token));
|
$.scope.apiTokenCard.push(processToken(token));
|
||||||
});
|
});
|
||||||
|
$('#api-tokens-empty').toggle(tokens.length === 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeToken(id, name, btn){
|
async function revokeToken(id, name, btn){
|
||||||
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return;
|
const ok = await app.messages.confirm('Revoke API token "' + name + '"? It stops working immediately.', $(btn).closest('.card'), 'danger');
|
||||||
|
if(!ok) return;
|
||||||
app.apiToken.remove({id: id}, function(error, data){
|
app.apiToken.remove({id: id}, function(error, data){
|
||||||
if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
|
if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
|
||||||
$.scope.apiTokenCard.remove('id', id);
|
$.scope.apiTokenCard.remove('id', id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateToken(id, name, btn){
|
async function rotateToken(id, name, btn){
|
||||||
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return;
|
const ok = await app.messages.confirm('Rotate API token "' + name + '"? The old token stops working immediately.', $(btn).closest('.card'), 'warning');
|
||||||
|
if(!ok) return;
|
||||||
app.apiToken.rotate({id: id}, function(error, data){
|
app.apiToken.rotate({id: id}, function(error, data){
|
||||||
if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
|
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();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
tableAJAX();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="row mt-3">
|
<div class="row mt-3 justify-content-center">
|
||||||
<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="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||||
<div class="card-header">
|
<span>
|
||||||
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
<small class="text-muted font-monospace">{{ id_short }}</small>
|
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header actionMessage" style="display:none"></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">
|
<div class="card-body">
|
||||||
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
||||||
<dl class="row mb-0">
|
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
||||||
<dt class="col-sm-3">Token ID</dt>
|
<div class="card-header">
|
||||||
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
||||||
<dt class="col-sm-3">Created</dt>
|
<small class="text-muted font-monospace">{{ id_short }}</small>
|
||||||
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
</div>
|
||||||
<dt class="col-sm-3">Last used</dt>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
|
<div class="card-body">
|
||||||
<dt class="col-sm-3">Expires</dt>
|
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
||||||
<dd class="col-sm-9">{{{ expires_display }}}</dd>
|
<dl class="row mb-0">
|
||||||
</dl>
|
<dt class="col-sm-3">Token ID</dt>
|
||||||
</div>
|
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
||||||
<div class="card-footer">
|
<dt class="col-sm-3">Created</dt>
|
||||||
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
|
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -82,16 +82,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||||
|
// (fixed navbar, plus the update banner while it's shown), so any
|
||||||
|
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||||
|
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||||
|
// instead of colliding with them at the viewport's true top:0.
|
||||||
function showUpdateBanner(){
|
function showUpdateBanner(){
|
||||||
let $nav = $('nav.fixed-top');
|
let $nav = $('nav.fixed-top');
|
||||||
let $banner = $('#update-banner');
|
let $banner = $('#update-banner');
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||||
|
$('#spa-shell').css('margin-top', offset + 'px');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissUpdateBanner(){
|
function dismissUpdateBanner(){
|
||||||
$('#update-banner').hide();
|
$('#update-banner').hide();
|
||||||
$('#spa-shell').css('margin-top', '');
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-74
@@ -20,11 +20,16 @@
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
|
||||||
|
function processUser(user){
|
||||||
|
user.isExternal = user.backing === 'oidc';
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
function populateUsers(actionMessage){
|
function populateUsers(actionMessage){
|
||||||
app.user.list(function(error, data){
|
app.user.list(function(error, data){
|
||||||
if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
|
if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
|
||||||
for(let user of data.results){
|
for(let user of data.results){
|
||||||
$.scope.users.push(user);
|
$.scope.users.push(processUser(user));
|
||||||
}
|
}
|
||||||
$.scope.users.__put = function($el, item, list){
|
$.scope.users.__put = function($el, item, list){
|
||||||
$el.addClass('bg-success');
|
$el.addClass('bg-success');
|
||||||
@@ -42,6 +47,52 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 class="invalid-feedback d-none" data-field-error="username"></div>'
|
||||||
|
+ '</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 class="invalid-feedback d-none" data-field-error="password"></div>'
|
||||||
|
+ '</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 class="invalid-feedback d-none" data-field-error="passwordMatch"></div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<hr />'
|
||||||
|
+ '<button type="submit" class="btn btn-info">Add</button>'
|
||||||
|
+ '</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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
populateUsers(); //populate the table
|
populateUsers(); //populate the table
|
||||||
|
|
||||||
@@ -54,50 +105,9 @@
|
|||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row" style="display:none">
|
<div class="row" style="display:none">
|
||||||
<div class="col-md-4">
|
<div class="col-12">
|
||||||
<div class="card shadow-lg">
|
|
||||||
|
|
||||||
<div class="card-header text-center">
|
|
||||||
<span class="card-icon float-start">
|
|
||||||
<i class="fa-solid fa-user-plus"></i>
|
|
||||||
</span>
|
|
||||||
<span class="card-title">
|
|
||||||
Add New User
|
|
||||||
</span>
|
|
||||||
<span class="float-end">
|
|
||||||
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
<i class="fa-solid fa-circle-minus"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="user/" onsubmit="formAJAX(this)" evalAJAX="
|
|
||||||
$.scope.users.splice(0, 0, data);
|
|
||||||
">
|
|
||||||
<input type="hidden" class="form-control" name="delete" value="false" />
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">User-name</label>
|
|
||||||
<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">Password</label>
|
|
||||||
<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">Again</label>
|
|
||||||
<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<button type="submit" class="btn btn-info">
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
|
|
||||||
<div class="card-header text-center">
|
<div class="card-header text-center">
|
||||||
@@ -109,42 +119,64 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="float-end">
|
<span class="float-end">
|
||||||
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
<i class="fa-solid fa-circle-minus"></i>
|
<button type="button" class="btn btn-sm btn-success" onclick="userAddOpen()">
|
||||||
|
<i class="fa-solid fa-user-plus"></i>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="table-responsive">
|
<div class="card-body">
|
||||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
<div class="table-responsive">
|
||||||
<thead>
|
<table class="table table-striped mb-0">
|
||||||
<th>Name</th>
|
<thead>
|
||||||
<th>Password</th>
|
<tr>
|
||||||
<th>Delete</th>
|
<th>Username</th>
|
||||||
</thead>
|
<th>Auth Type</th>
|
||||||
<tbody>
|
<th>Password</th>
|
||||||
<tr jq-repeat="users" jq-repeat-index="username" style="display:none" >
|
<th>Actions</th>
|
||||||
<td class="align-middle">
|
</tr>
|
||||||
{{ username }}
|
</thead>
|
||||||
</td>
|
<tbody id="user-cards">
|
||||||
<td>
|
<tr jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
|
||||||
|
<td>
|
||||||
<form class="input-group" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
|
<strong>{{ username }}</strong>
|
||||||
<input type="password" name="password" class="form-control" placeholder="Change {{ username }} password" aria-label="Update password">
|
</td>
|
||||||
<button class="btn btn-warning" type="submit">Change</button>
|
<td>
|
||||||
</form>
|
{{#isExternal}}
|
||||||
|
<span class="badge text-bg-secondary" title="Provisioned via SSO login; no local password to manage here.">
|
||||||
</td>
|
<i class="fa-solid fa-cloud"></i> External (SSO)
|
||||||
<td class="align-middle">
|
</span>
|
||||||
<button type="button" class="btn btn-danger" onclick="removeUser('{{username}}')">
|
{{/isExternal}}
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
{{^isExternal}}
|
||||||
Delete
|
<span class="badge text-bg-primary">Local</span>
|
||||||
</button>
|
{{/isExternal}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td>
|
||||||
</tbody>
|
{{^isExternal}}
|
||||||
</table>
|
<form class="input-group input-group-sm" style="max-width: 350px;" 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}}
|
||||||
|
<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>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
Reference in New Issue
Block a user