Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d52601de | |||
| ecd21c4984 | |||
| 5ba2ace835 | |||
| 25b0d57a97 |
@@ -1303,6 +1303,39 @@ Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, o
|
|||||||
|
|
||||||
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
|
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
|
||||||
|
|
||||||
|
## Configuration Endpoints
|
||||||
|
|
||||||
|
Base path: `/api/conf`
|
||||||
|
|
||||||
|
All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it.
|
||||||
|
|
||||||
|
### Get Configuration
|
||||||
|
|
||||||
|
**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
|
||||||
|
"discovery": { },
|
||||||
|
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Configuration
|
||||||
|
|
||||||
|
**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
|
||||||
|
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ "success": true }`
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All endpoints return errors in this format:
|
All endpoints return errors in this format:
|
||||||
|
|||||||
@@ -4,6 +4,102 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.17.2] - 2026-08-01
|
||||||
|
|
||||||
|
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
|
||||||
|
Terms-of-Service configuration the `/conf` page was missing. Seven issues:
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
|
||||||
|
modal no longer asks for a Slug (it derived a stable, unique handle from the
|
||||||
|
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
|
||||||
|
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
|
||||||
|
optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`,
|
||||||
|
`views/plugins.ejs`)
|
||||||
|
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
|
||||||
|
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
|
||||||
|
is still a cron string, so the server is unchanged. (`views/plugins.ejs`)
|
||||||
|
- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app,
|
||||||
|
and admin OpenBao policies granted `list` only on `secret/metadata/.../*`
|
||||||
|
(nested paths), never on the directory path itself — so listing a directory's
|
||||||
|
*contents* (which checks `list` on the directory, e.g. `secret/metadata/users/<uid>`
|
||||||
|
or the mount root `secret/metadata`) was denied. `vault_broker.js`'s
|
||||||
|
`userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory
|
||||||
|
path, and `ensurePolicy` now always re-writes the policy (idempotent) so
|
||||||
|
already-created `user-<uid>` policies pick up the new grant on the next
|
||||||
|
vault-page visit. The matching `sso-admin` mount-root grant ships in
|
||||||
|
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
|
||||||
|
always-write so re-running `./setup.sh` applies policy edits.
|
||||||
|
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
|
||||||
|
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
|
||||||
|
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
|
||||||
|
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
|
||||||
|
buttons. The header/label are now populated by JS (the `Members` label
|
||||||
|
already had a setter pointing at a missing id); the Admin Actions block is
|
||||||
|
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
|
||||||
|
and the correct Activate/Deactivate button shows. (`views/profile.ejs`)
|
||||||
|
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
|
||||||
|
masked secret values and rendered them as fields, but `PUT /:id` only saves
|
||||||
|
non-secret config — so an edited secret was silently dropped. The Edit modal
|
||||||
|
now shows **non-secret fields only** (secrets have their own Edit-Secrets
|
||||||
|
modal), removing the confusion. (`views/plugins.ejs`)
|
||||||
|
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
|
||||||
|
binary was not installed in the app image. `Dockerfile.openldap` now `apk
|
||||||
|
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
|
||||||
|
the opaque node-nmap spawn-missing error into an actionable `lastError`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
|
||||||
|
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
|
||||||
|
config files. It now has an SMS card on `/conf` (API username, DID, API
|
||||||
|
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
|
||||||
|
the API password masked (`********`) and leave-blank-to-keep — mirroring the
|
||||||
|
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
|
||||||
|
saved change takes effect live without a restart. (`routes/api_conf.js`,
|
||||||
|
`views/conf.ejs`)
|
||||||
|
- **Terms of Service editor moved to `/conf`** from the admin Overview
|
||||||
|
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
|
||||||
|
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
|
||||||
|
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
|
||||||
|
stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`)
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- The `/vault` 403 fix is split across two repos: the sso-side per-user/app
|
||||||
|
policy grants and `ensurePolicy`-always-write ship here; the `sso-admin`
|
||||||
|
mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1.
|
||||||
|
Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user
|
||||||
|
policies self-heal on the next vault-page visit.
|
||||||
|
|
||||||
|
## [1.17.1] - 2026-08-01
|
||||||
|
|
||||||
|
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
|
||||||
|
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
|
||||||
|
no longer returned in cleartext by `GET /api/conf` or round-tripped through the
|
||||||
|
form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime
|
||||||
|
(unchanged) — only how they're surfaced to the admin changes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********`
|
||||||
|
(was: returned in cleartext). Non-secret fields (host, port, user, from,
|
||||||
|
secure, issuer, token lifetimes) are returned as before.
|
||||||
|
- **`POST /api/conf`** now treats a blank or `********` secret-field submission
|
||||||
|
as "keep the current stored value" — so an admin editing the From address or
|
||||||
|
token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT
|
||||||
|
secret. Only a genuinely new, non-blank value overwrites. The preserved values
|
||||||
|
are re-applied to live `conf` immediately, as before.
|
||||||
|
- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry
|
||||||
|
a "leave unchanged to keep the current value stored in OpenBao" hint; the page
|
||||||
|
copy notes secret fields are masked. No JSON-textarea editing is involved —
|
||||||
|
SMTP is and remains configured through structured form fields.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before
|
||||||
|
this release (via `POST /api/conf` → `baoConf.set('sso-manager/conf')`, and
|
||||||
|
overlaid back at boot by `bao-conf.init`). This release closes the
|
||||||
|
cleartext-exposure gap; it does not move the storage path.
|
||||||
|
- No theta-suite policy change required — `secret/sso-manager/conf` was already
|
||||||
|
granted to the `sso-broker` policy.
|
||||||
|
|
||||||
## [1.17.0] - 2026-08-01
|
## [1.17.0] - 2026-08-01
|
||||||
|
|
||||||
A real **plugin system**: the half-built discovery plugins (statically
|
A real **plugin system**: the half-built discovery plugins (statically
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ RUN apk add --no-cache \
|
|||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
redis \
|
redis \
|
||||||
|
nmap \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ When the environment is initialized via `setup.sh`, OpenBao is automatically uns
|
|||||||
|
|
||||||
The SSO Manager Vault can be accessed in two ways:
|
The SSO Manager Vault can be accessed in two ways:
|
||||||
|
|
||||||
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly.
|
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
|
||||||
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
||||||
|
|
||||||
### API Example
|
### API Example
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.17.0",
|
"version": "1.17.2",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,7 +66,17 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
scan.on('error', function(error) {
|
scan.on('error', function(error) {
|
||||||
reject(error);
|
// node-nmap's spawn-missing-binary message ("NMAP not found at command
|
||||||
|
// location: nmap") is opaque to an admin reading lastError. Translate
|
||||||
|
// it into something actionable. (The Dockerfile installs nmap in the
|
||||||
|
// app image; this only fires if someone runs outside the container or
|
||||||
|
// strips the package.)
|
||||||
|
var msg = (error && error.message) || String(error);
|
||||||
|
if (/nmap.*not found|command location/i.test(msg)) {
|
||||||
|
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
scan.startScan();
|
scan.startScan();
|
||||||
|
|||||||
@@ -12,12 +12,33 @@ router.use(async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned
|
||||||
|
// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or
|
||||||
|
// mask-valued submission preserves the stored value so an admin editing an
|
||||||
|
// unrelated field (e.g. the From address) doesn't have to re-enter — or leak —
|
||||||
|
// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline.
|
||||||
|
const MASK = '********';
|
||||||
|
const SECRET_PATHS = [
|
||||||
|
['smtp', 'pass'],
|
||||||
|
['oauth', 'jwtSecret'],
|
||||||
|
['voipms', 'password'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function maskSecrets(obj) {
|
||||||
|
const out = JSON.parse(JSON.stringify(obj));
|
||||||
|
for (const [grp, key] of SECRET_PATHS) {
|
||||||
|
if (out[grp] && out[grp][key]) out[grp][key] = MASK;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const editable = {
|
const editable = maskSecrets({
|
||||||
smtp: conf.smtp || {},
|
smtp: conf.smtp || {},
|
||||||
discovery: conf.discovery || {},
|
discovery: conf.discovery || {},
|
||||||
oauth: conf.oauth || {}
|
oauth: conf.oauth || {},
|
||||||
};
|
voipms: conf.voipms || {}
|
||||||
|
});
|
||||||
res.json(editable);
|
res.json(editable);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,18 +59,31 @@ function applyToLiveConf(src) {
|
|||||||
router.post('/', async (req, res, next) => {
|
router.post('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const existing = await baoConf.get('sso-manager/conf') || {};
|
const existing = await baoConf.get('sso-manager/conf') || {};
|
||||||
// Deep merge req.body into existing
|
const incoming = req.body || {};
|
||||||
for (const key of Object.keys(req.body)) {
|
|
||||||
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
|
// Preserve secret fields the admin left blank (or left showing the mask):
|
||||||
existing[key] = { ...(existing[key] || {}), ...req.body[key] };
|
// drop them from the incoming merge so the stored value survives. Only a
|
||||||
|
// genuinely new, non-blank, non-mask value overwrites.
|
||||||
|
for (const [grp, key] of SECRET_PATHS) {
|
||||||
|
if (incoming[grp] && incoming[grp][key] !== undefined) {
|
||||||
|
const submitted = incoming[grp][key];
|
||||||
|
if (submitted === '' || submitted === MASK) delete incoming[grp][key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep merge incoming into existing
|
||||||
|
for (const key of Object.keys(incoming)) {
|
||||||
|
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
|
||||||
|
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
|
||||||
} else {
|
} else {
|
||||||
existing[key] = req.body[key];
|
existing[key] = incoming[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await baoConf.set('sso-manager/conf', existing);
|
await baoConf.set('sso-manager/conf', existing);
|
||||||
// Reflect the saved values in the live conf immediately (the next boot's
|
// Reflect the saved values in the live conf immediately (the next boot's
|
||||||
// bao-conf.init() would pick them up too, but this keeps running readers
|
// bao-conf.init() would pick them up too, but this keeps running readers
|
||||||
// current without a restart, as the old conf_manager did).
|
// current without a restart, as the old conf_manager did). `existing`
|
||||||
|
// carries the preserved secret values, so live conf keeps them too.
|
||||||
applyToLiveConf(existing);
|
applyToLiveConf(existing);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
|
|||||||
@@ -20,6 +20,29 @@ const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../ser
|
|||||||
|
|
||||||
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
|
// Derive a stable, unique slug from an instance name when the caller didn't
|
||||||
|
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
|
||||||
|
// and prefixes `plugin-` if the result would otherwise start with a character
|
||||||
|
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
|
||||||
|
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
|
||||||
|
function slugify(name) {
|
||||||
|
let s = String(name || '').toLowerCase().trim();
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
if (!s) s = 'plugin';
|
||||||
|
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
|
||||||
|
return s.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeSlug(name, isTaken) {
|
||||||
|
const base = slugify(name);
|
||||||
|
if (!await isTaken(base)) return base;
|
||||||
|
for (let i = 2; i <= 16; i++) {
|
||||||
|
const cand = `${base}-${i}`.slice(0, 64);
|
||||||
|
if (!await isTaken(cand)) return cand;
|
||||||
|
}
|
||||||
|
return null; // exhausted
|
||||||
|
}
|
||||||
|
|
||||||
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
|
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
|
||||||
// (app_super_admin is always allowed by permission.byGroup).
|
// (app_super_admin is always allowed by permission.byGroup).
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
@@ -82,7 +105,15 @@ router.post('/', async (req, res, next) => {
|
|||||||
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
|
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
|
||||||
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
|
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
|
// Slug is optional: derive it from the name when absent. When supplied,
|
||||||
|
// validate it (admins editing via API may still pass one explicitly).
|
||||||
|
let finalSlug = slug;
|
||||||
|
if (finalSlug) {
|
||||||
|
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
|
||||||
|
} else {
|
||||||
|
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
|
||||||
|
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
|
||||||
|
}
|
||||||
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
|
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
|
||||||
|
|
||||||
// `config` from the client is a flat object of all field values (secret +
|
// `config` from the client is a flat object of all field values (secret +
|
||||||
@@ -100,7 +131,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
pluginType,
|
pluginType,
|
||||||
category: manifest.category,
|
category: manifest.category,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug: finalSlug,
|
||||||
enabled,
|
enabled,
|
||||||
cron: cron || '0 * * * *',
|
cron: cron || '0 * * * *',
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -54,11 +54,14 @@ async function bao(method, path, body) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
|
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
|
||||||
|
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
|
||||||
|
// a list grant on a directory path) propagate on the next vault-page visit
|
||||||
|
// without an operator re-running setup.sh. Skipping on an existing policy
|
||||||
|
// would strand the old, narrower HCL forever.
|
||||||
async function ensurePolicy(name, hcl) {
|
async function ensurePolicy(name, hcl) {
|
||||||
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||||
if (existing.status === 200) return;
|
if (existing.status !== 200 && existing.status !== 404) {
|
||||||
if (existing.status !== 404) {
|
|
||||||
const t = await existing.text().catch(() => '');
|
const t = await existing.text().catch(() => '');
|
||||||
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
||||||
}
|
}
|
||||||
@@ -80,7 +83,11 @@ async function mintToken(policies) {
|
|||||||
function userPolicyHcl(uid) {
|
function userPolicyHcl(uid) {
|
||||||
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
||||||
// into a policy path, so reject anything but a safe charset.
|
// into a policy path, so reject anything but a safe charset.
|
||||||
|
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
|
||||||
|
// contents of the namespace: `.../*` covers nested paths but NOT the
|
||||||
|
// directory itself, so without it the /vault secrets list 403s.
|
||||||
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
|
||||||
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +116,10 @@ async function getOrCreateAdminToken(uid) {
|
|||||||
|
|
||||||
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||||
function appPolicyHcl(name) {
|
function appPolicyHcl(name) {
|
||||||
|
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
|
||||||
|
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
|
||||||
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
|
||||||
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-4
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
loadConf();
|
loadConf();
|
||||||
|
loadTos();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadConf() {
|
async function loadConf() {
|
||||||
@@ -28,6 +29,13 @@
|
|||||||
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
|
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate SMS (VoIP.ms)
|
||||||
|
if (data.voipms) {
|
||||||
|
$('#voipms-username').val(data.voipms.username || '');
|
||||||
|
$('#voipms-did').val(data.voipms.did || '');
|
||||||
|
$('#voipms-password').val(data.voipms.password || '');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
|
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
@@ -53,6 +61,11 @@
|
|||||||
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
|
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
|
||||||
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
|
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
voipms: {
|
||||||
|
username: $('#voipms-username').val(),
|
||||||
|
did: $('#voipms-did').val(),
|
||||||
|
password: $('#voipms-password').val()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,6 +87,50 @@
|
|||||||
el.type = 'password';
|
el.type = 'password';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Terms of Service editor ──────────────────────────────────────────
|
||||||
|
// Moved here from the admin Overview dashboard — it's a configuration
|
||||||
|
// control, so it belongs on the System Configuration page. The API is
|
||||||
|
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
|
||||||
|
// matches this page's gate). app.tos.get/update are the shared frontend
|
||||||
|
// helpers (@simpleworkjs/frontend).
|
||||||
|
async function loadTos() {
|
||||||
|
try {
|
||||||
|
const tos = await app.tos.get();
|
||||||
|
document.getElementById('tos-content').value = tos.content;
|
||||||
|
document.getElementById('tos-meta').textContent =
|
||||||
|
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to load ToS:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTos() {
|
||||||
|
const content = document.getElementById('tos-content').value.trim();
|
||||||
|
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
||||||
|
const msgEl = document.getElementById('tos-result');
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
msgEl.className = 'alert alert-danger mt-2';
|
||||||
|
msgEl.textContent = 'Terms of Service text cannot be empty.';
|
||||||
|
msgEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.tos.update({content, resetAcceptance}, function(error, data) {
|
||||||
|
if (error) {
|
||||||
|
msgEl.className = 'alert alert-danger mt-2';
|
||||||
|
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
|
||||||
|
msgEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msgEl.className = 'alert alert-success mt-2';
|
||||||
|
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
|
||||||
|
msgEl.style.display = '';
|
||||||
|
document.getElementById('tos-reset-acceptance').checked = false;
|
||||||
|
loadTos();
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
@@ -82,8 +139,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||||
<p class="text-muted mb-0">
|
<p class="text-muted mb-0">
|
||||||
Manage runtime configuration such as SMTP settings and OAuth parameters.
|
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
|
||||||
These secrets are stored securely in OpenBao Vault.
|
settings. These are stored securely in OpenBao and take effect immediately.
|
||||||
|
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
|
||||||
|
are masked — leave them unchanged to keep the stored value.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -115,9 +174,10 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Password</label>
|
<label class="form-label">Password</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control" id="smtp-pass">
|
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">From Address</label>
|
<label class="form-label">From Address</label>
|
||||||
@@ -144,9 +204,10 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">JWT Secret</label>
|
<label class="form-label">JWT Secret</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control" id="oauth-jwtsecret">
|
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Access Token Lifetime (seconds)</label>
|
<label class="form-label">Access Token Lifetime (seconds)</label>
|
||||||
@@ -160,6 +221,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card shadow-sm border-0 h-100">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">API Username</label>
|
||||||
|
<input type="text" class="form-control" id="voipms-username">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">DID (sender number)</label>
|
||||||
|
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">API Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card shadow-sm border-0 h-100">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
|
||||||
|
<small class="text-muted" id="tos-meta"></small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
|
||||||
|
<textarea class="form-control" id="tos-content" rows="8"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
||||||
|
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
|
||||||
|
<div id="tos-result" style="display:none" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -162,50 +162,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Terms of Service ──────────────────────────────────────────────────
|
|
||||||
async function loadTos() {
|
|
||||||
try {
|
|
||||||
const tos = await app.tos.get();
|
|
||||||
document.getElementById('tos-content').value = tos.content;
|
|
||||||
document.getElementById('tos-meta').textContent =
|
|
||||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
|
||||||
} catch(e) {
|
|
||||||
console.error('Failed to load ToS:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTos() {
|
|
||||||
const content = document.getElementById('tos-content').value.trim();
|
|
||||||
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
|
||||||
const msgEl = document.getElementById('tos-result');
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
msgEl.className = 'alert alert-danger mt-2';
|
|
||||||
msgEl.textContent = 'Terms of Service text cannot be empty.';
|
|
||||||
msgEl.style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.tos.update({content, resetAcceptance}, function(error, data) {
|
|
||||||
if (error) {
|
|
||||||
msgEl.className = 'alert alert-danger mt-2';
|
|
||||||
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
|
|
||||||
msgEl.style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
msgEl.className = 'alert alert-success mt-2';
|
|
||||||
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
|
|
||||||
msgEl.style.display = '';
|
|
||||||
document.getElementById('tos-reset-acceptance').checked = false;
|
|
||||||
loadTos();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
toggleFilterInputs();
|
toggleFilterInputs();
|
||||||
loadTos();
|
|
||||||
loadMetrics();
|
loadMetrics();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -385,30 +345,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TOS Card -->
|
|
||||||
<div class="card shadow mb-5">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
|
|
||||||
<small class="text-muted" id="tos-meta"></small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
|
|
||||||
<textarea class="form-control shadow-sm" id="tos-content" rows="12"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-check mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
|
||||||
<label class="form-check-label" for="tos-reset-acceptance">
|
|
||||||
Require all users to re-accept these terms
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary shadow-sm" onclick="saveTos()">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
|
||||||
</button>
|
|
||||||
<div id="tos-result" style="display:none" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actionable Metrics Card -->
|
<!-- Actionable Metrics Card -->
|
||||||
<div class="card shadow mb-5">
|
<div class="card shadow mb-5">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
|||||||
+63
-13
@@ -128,12 +128,17 @@
|
|||||||
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
|
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
|
||||||
// the field ids so the New and Edit modals don't collide. `values` (optional)
|
// the field ids so the New and Edit modals don't collide. `values` (optional)
|
||||||
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
|
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
|
||||||
function configFormHtml(type, prefix, values) {
|
// `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
|
||||||
|
// secret fields are never shown there (secrets have their own modal); the New
|
||||||
|
// modal passes true so initial secrets can be set at create time.
|
||||||
|
function configFormHtml(type, prefix, values, includeSecrets) {
|
||||||
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
||||||
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
||||||
|
if (includeSecrets === undefined) includeSecrets = true;
|
||||||
var v = values || {};
|
var v = values || {};
|
||||||
var html = '';
|
var html = '';
|
||||||
schema.forEach(function(f) {
|
schema.forEach(function(f) {
|
||||||
|
if (!includeSecrets && f.secret) return;
|
||||||
var val = v[f.key];
|
var val = v[f.key];
|
||||||
if (val === undefined || val === null) val = '';
|
if (val === undefined || val === null) val = '';
|
||||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||||
@@ -149,6 +154,53 @@
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
|
||||||
|
// The stored value is always a 5-field cron string. A `<select>` picks a
|
||||||
|
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
|
||||||
|
// element ids (np-/ed-) so the two modals don't collide.
|
||||||
|
var CRON_PRESETS = [
|
||||||
|
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
|
||||||
|
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
|
||||||
|
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
|
||||||
|
{ key: 'custom', label: 'Custom…', cron: null },
|
||||||
|
];
|
||||||
|
function cronKeyFor(cron) {
|
||||||
|
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
|
||||||
|
return m ? m.key : 'custom';
|
||||||
|
}
|
||||||
|
function cronSelectHtml(prefix, current) {
|
||||||
|
current = current || '0 * * * *';
|
||||||
|
var key = cronKeyFor(current);
|
||||||
|
var opts = CRON_PRESETS.map(function(p){
|
||||||
|
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
|
||||||
|
}).join('');
|
||||||
|
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
|
||||||
|
var rawVal = key === 'custom' ? current : current;
|
||||||
|
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
|
||||||
|
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
|
||||||
|
}
|
||||||
|
function onCronChange(prefix) {
|
||||||
|
var sel = document.getElementById(prefix + 'cron-select');
|
||||||
|
var raw = document.getElementById(prefix + 'cron');
|
||||||
|
if (!sel || !raw) return;
|
||||||
|
if (sel.value === 'custom') {
|
||||||
|
raw.style.display = '';
|
||||||
|
} else {
|
||||||
|
raw.style.display = 'none';
|
||||||
|
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||||
|
if (preset) raw.value = preset.cron;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cronFromForm(prefix) {
|
||||||
|
var sel = document.getElementById(prefix + 'cron-select');
|
||||||
|
if (sel && sel.value !== 'custom') {
|
||||||
|
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||||
|
if (preset) return preset.cron;
|
||||||
|
}
|
||||||
|
var raw = document.getElementById(prefix + 'cron');
|
||||||
|
return (raw && raw.value.trim()) || '0 * * * *';
|
||||||
|
}
|
||||||
|
|
||||||
// Collect a flat {field: value} object from the rendered config form.
|
// Collect a flat {field: value} object from the rendered config form.
|
||||||
function collectConfig(type, prefix) {
|
function collectConfig(type, prefix) {
|
||||||
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
||||||
@@ -179,10 +231,9 @@
|
|||||||
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
|
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
|
||||||
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
|
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
|
||||||
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
|
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
|
||||||
'<div class="mb-3"><label class="form-label">Slug <span class="text-danger">*</span></label>' +
|
'<div class="mb-3"><label class="form-label">Schedule</label>' +
|
||||||
'<input type="text" class="form-control font-monospace" id="np-slug" placeholder="proxmox-homelab"></div>' +
|
cronSelectHtml('np-', '0 * * * *') +
|
||||||
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
|
'<div class="form-text">A slug is derived automatically from the name.</div></div>' +
|
||||||
'<input type="text" class="form-control font-monospace" id="np-cron" value="0 * * * *"></div>' +
|
|
||||||
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
|
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
|
||||||
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
|
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
|
||||||
});
|
});
|
||||||
@@ -197,13 +248,11 @@
|
|||||||
var type = document.getElementById('np-type').value;
|
var type = document.getElementById('np-type').value;
|
||||||
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
|
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
|
||||||
var name = document.getElementById('np-name').value.trim();
|
var name = document.getElementById('np-name').value.trim();
|
||||||
var slug = document.getElementById('np-slug').value.trim();
|
var cron = cronFromForm('np-');
|
||||||
var cron = document.getElementById('np-cron').value.trim() || '0 * * * *';
|
|
||||||
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
||||||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return app.messages.action('Slug must be lowercase letters/digits/_/- (max 64).', app.modal.body(), 'danger');
|
|
||||||
var config = collectConfig(type, 'np-');
|
var config = collectConfig(type, 'np-');
|
||||||
try {
|
try {
|
||||||
await app.api.post('plugins', { pluginType: type, name: name, slug: slug, cron: cron, config: config });
|
await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
|
||||||
app.modal.close();
|
app.modal.close();
|
||||||
app.messages.toast('Plugin created and scheduled.', 'success');
|
app.messages.toast('Plugin created and scheduled.', 'success');
|
||||||
loadPlugins();
|
loadPlugins();
|
||||||
@@ -224,9 +273,10 @@
|
|||||||
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '"') + '"></div>' +
|
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '"') + '"></div>' +
|
||||||
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
|
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
|
||||||
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
|
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
|
||||||
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
|
'<div class="mb-3"><label class="form-label">Schedule</label>' +
|
||||||
'<input type="text" class="form-control font-monospace" id="ed-cron" value="' + (p.cron || '0 * * * *') + '"></div>' +
|
cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
|
||||||
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', Object.assign({}, p.config, p.secrets)) + '</div>',
|
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
|
||||||
|
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
|
||||||
footer: {
|
footer: {
|
||||||
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
|
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
|
||||||
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
|
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
|
||||||
@@ -238,7 +288,7 @@
|
|||||||
var p = pluginsById[id];
|
var p = pluginsById[id];
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
var name = document.getElementById('ed-name').value.trim();
|
var name = document.getElementById('ed-name').value.trim();
|
||||||
var cron = document.getElementById('ed-cron').value.trim() || '0 * * * *';
|
var cron = cronFromForm('ed-');
|
||||||
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
||||||
var config = collectConfig(p.pluginType, 'ed-');
|
var config = collectConfig(p.pluginType, 'ed-');
|
||||||
try {
|
try {
|
||||||
|
|||||||
+23
-22
@@ -9,6 +9,7 @@
|
|||||||
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
||||||
|
$('#profile-uid-header').text(user.uid);
|
||||||
$.scope.user.update(user);
|
$.scope.user.update(user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,7 +242,7 @@
|
|||||||
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<i class="fa-regular fa-id-card"></i>
|
<i class="fa-regular fa-id-card"></i>
|
||||||
Profile: <strong>{{user.uid}}</strong>
|
Profile: <strong id="profile-uid-header"></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
|
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
|
||||||
@@ -278,7 +279,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
|
||||||
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group
|
<i class="fa-solid fa-people-group"></i> Members of <span id="personal-group-uid-label"></span>'s Group
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -329,27 +330,27 @@
|
|||||||
<p class="text-muted small mb-0">
|
<p class="text-muted small mb-0">
|
||||||
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
|
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 border-top pt-3">
|
<div class="mt-3 border-top pt-3">
|
||||||
<h6 class="text-muted">Admin Actions</h6>
|
<h6 class="text-muted">Admin Actions</h6>
|
||||||
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
|
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
|
||||||
{{#isActive}}
|
{{#isActive}}
|
||||||
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
||||||
<i class="fa-solid fa-lock"></i> Deactivate
|
<i class="fa-solid fa-lock"></i> Deactivate
|
||||||
</button>
|
</button>
|
||||||
{{/isActive}}
|
{{/isActive}}
|
||||||
{{#isInactive}}
|
{{#isInactive}}
|
||||||
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
||||||
<i class="fa-solid fa-lock-open"></i> Activate
|
<i class="fa-solid fa-lock-open"></i> Activate
|
||||||
</button>
|
</button>
|
||||||
{{/isInactive}}
|
{{/isInactive}}
|
||||||
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
||||||
<i class="fa-solid fa-user-secret"></i> Impersonate
|
<i class="fa-solid fa-user-secret"></i> Impersonate
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
|
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||||
<i class="fa-solid fa-user-slash"></i> Delete User
|
<i class="fa-solid fa-user-slash"></i> Delete User
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user