Compare commits

...

11 Commits

Author SHA1 Message Date
wmantly b54da5c64c Fix resource modal's LDAP-groups autocomplete going empty after first open (#122)
loadLdapGroups()'s cache guard (if (ldapGroupsCache) return;) also skipped
the DOM-repopulation step on every call after the first, but
#ldap-groups-datalist is rebuilt fresh and empty on every app.modal.open()
-- so the "Associated LDAP Groups" tab's group-name autocomplete silently
lost all its suggestions starting on the second Add/Edit. Now the fetch
stays cached, but the datalist is always repopulated.

Verified live: opened the resource modal on Proxy twice in a row, confirmed
the datalist has all 17 options both times (would have been 0 on the
second open with the old code).
2026-07-28 18:35:15 -04:00
wmantly 782ef69fb8 Release 1.8.0: resource modal standardization, site-slug group prefixing (#121) 2026-07-28 17:45:08 -04:00
wmantly 0e955abc73 Standardize the resource modal: tabs, footer, linkable URL, Children tab, site-slug group prefixing (#120)
* Add Resource audit fields (created/updated by/on) and site-slug group prefixing

Resource had no created_by/created_on/updated_by/updated_on fields at all,
unlike proxy's Host and jump-host's ApiToken which already track this --
needed for the upcoming resource-modal footer. @simpleworkjs/orm has no
auto-timestamp hook, so these are set explicitly in the directory-admin
route handlers on every create/update.

Also: when a host/service resource is created, its two auto-created LDAP
groups (<slug>_access/_admin) now get prefixed with the nearest ancestor
site's slug (via a new Resource.findAncestorSiteSlug walk), so groups from
different sites don't collide/look identical. Falls back to today's
unprefixed naming when a resource has no site ancestor.

Included the checked-in dev inventory.sqlite's ALTER TABLE for the new
columns, since @simpleworkjs/orm's sync() only creates missing tables, never
alters existing ones -- the raw model change alone would have broken every
Resource read/write against this file with "no such column: created_by".

* Migrate Resource modal onto app.modal's tabs/footer/URL, add Children tab

The Directory's resource modal was a separate, hand-rolled, always-in-DOM
Bootstrap modal, independent of the shared app.modal singleton -- migrating
it onto app.modal (now published with tabs/footer/url support in
@simpleworkjs/frontend 0.2.6) is the pilot for standardizing entity modals
across the stack.

- General/Details/Associated LDAP Groups/Children tabs, replacing the old
  single long form (Details keeps every kind-conditional container
  unchanged; toggleFormFields() didn't need to change at all).
- Footer shows created/updated by/on (via the new Resource audit fields)
  and the Save button; Groups/Children tabs are hidden in add-mode since
  they need an existing resource id.
- New Children tab lists a resource's existing children (reusing the
  already-loaded edges/resourcesById data, no new endpoint) and an "Add
  Child Resource" button that reuses openAddModal's existing preset-parent
  support. Folded the pre-existing generic "Relationships (Graph Edges)"
  section in underneath, under an "advanced" subheading, rather than
  dropping it or giving it a 5th tab of its own.
- GET /directory/:slug (mirroring the existing /users/:uid precedent) plus
  a client-side app.modal.deepLinkSlug() check makes a resource's modal
  linkable and directly loadable.
- Converted the groups/edges lists from jq-repeat to plain manual DOM
  rendering: jq-repeat's MutationObserver-based scope (re)registration for
  an element that's destroyed and recreated on every modal open runs
  asynchronously, so populating synchronously right after open() (as
  refreshGroupsUI/refreshEdgesUI must) raced it -- on the second and later
  opens, the old scope's destroy() ran after the new data was pushed onto
  it, silently discarding it. Manual rendering (matching the new Children
  tab) sidesteps the race entirely.
- The #res-name/#res-kind auto-slug handler is now bound via
  app.modal.on() (delegated) instead of directly -- a direct bind would
  have silently stopped firing after the first Add/Edit, since the modal
  body is rebuilt from scratch on every open().

Verified live against the running dev stack: tabs/footer/groups/children
all render and populate correctly (including on a second open, confirming
the jq-repeat race fix), the address bar updates to /directory/{slug} and
reverts on close, browser Back closes the modal via popstate without a
page reload, and a resource created under a Site gets correctly
site-slug-prefixed LDAP groups.
2026-07-28 17:42:05 -04:00
wmantly 69883836e1 Merge pull request #119 from theta42/release/1.7.0
Release 1.7.0
2026-07-28 13:35:05 -04:00
wmantly 17df21041a Release 1.7.0: fresh-install fixes (loading message, missing messages, login context, directory UX)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:30:31 -04:00
wmantly c19fffe3c9 Merge pull request #118 from theta42/feat/directory-tree-only-and-click-detail
Directory: tree view is now the only view; click a name for detail
2026-07-28 13:29:45 -04:00
wmantly b6abfe8f03 Directory: tree view is now the only view; click a name for detail
- Removed the list/tree view toggle -- tree (with indentation/parent
  arrows) is always used. Simplifies renderTable() back down to one
  code path instead of branching on a view mode nobody was toggling
  away from in practice.
- Clicking a resource's name now opens the same modal the pencil/edit
  button does, rather than requiring the small icon click. The edit
  modal already surfaces full detail (parent, addresses, OAuth config,
  groups, edges) for every resource kind, so this reuses it rather than
  building a second, read-only view that would drift from the real one.

Verified live: tree view renders correctly with no toggle present, and
clicking a name (tested on the theta-proxy OAuth resource) opens the
detail modal with the correct parent already selected -- also
confirming the earlier "OAuth client has no parent" fix end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:27:12 -04:00
wmantly 420ccfab3b Merge pull request #117 from theta42/feat/login-redirect-context
Explain why the user landed on the login page
2026-07-28 13:12:31 -04:00
wmantly 8ed4505dc0 Explain why the user landed on the login page
Landing here with ?redirect= and no explanation is exactly what happens
when another app's "Log in with SSO" sends an unauthenticated user
through /oauth/authorize, which bounces them here. Shows a contextual
banner: a specific message when the redirect target is an OAuth
authorize URL, a generic "you'll be sent back" message otherwise.

Verified live for both cases (OAuth-authorize redirect and a plain
redirect) against a local stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:09:53 -04:00
wmantly 451054f0c2 Merge pull request #116 from theta42/fix/loading-message-and-missing-messages
Fix HTML-escaped loading indicator and missing success messages
2026-07-28 13:07:43 -04:00
wmantly 3a46680c8b Fix HTML-escaped loading indicator and missing success messages
Two regressions surfaced by a fresh production install:

- formAJAX's "loading" indicator passed a raw <div class="spinner-border">
  string to app.messages.action, which HTML-escapes its message by design
  (@simpleworkjs/frontend) -- so every form submit briefly showed the
  literal markup as text instead of a spinner. Replaced with plain text
  ("Saving…"), which needs no escaping workaround.

- POST /api/user/ (create) and PUT /api/user/password didn't include a
  `message` field, so the success toast/banner rendered with an empty
  body -- a green notification with nothing in it right after adding a
  user. Added messages matching the convention already used by every
  other route in this file (activate/deactivate, group membership, etc).

Verified live: created a user through the actual modal, confirmed the
POST response now carries a message, and confirmed app.messages.toast
renders plain text cleanly with no escaping artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:52:09 -04:00
12 changed files with 557 additions and 355 deletions
+35
View File
@@ -4,6 +4,41 @@ All notable changes to this project are documented here. Format loosely
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`.
## [1.8.1] - 2026-07-28
### Fixed
- **The resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit** — `loadLdapGroups()`'s fetch-once cache guard (`if (ldapGroupsCache) return;`) also skipped repopulating the `<datalist>` on every call after the first, but the modal body (including that `<datalist>`) is rebuilt fresh and empty on every `app.modal.open()`. Now the fetch is still cached, but the datalist is always repopulated.
## [1.8.0] - 2026-07-28
### Added
- **Directory resource modal: General / Details / Associated LDAP Groups / Children tabs**, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
- **Resource audit trail**: `created_by`/`created_on`/`updated_by`/`updated_on`, shown in the modal's new footer (mirrors the convention already used by proxy's `Host` and jump-host's `ApiToken`). Existing resources predating this change show "—" until next edited.
- **Linkable resource URLs**: `GET /directory/:slug` plus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to `/directory/{slug}` while its modal is open and reverts on close (including via the browser Back button).
- **Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug** (e.g. `site_local_myhost_access` instead of `myhost_access`), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
### Changed
- `@simpleworkjs/frontend` bumped to 0.2.6: `app.modal` gained the `tabs`/`footer`/`url` options (all opt-in, existing callers unaffected) plus `showTab`/`on`/`deepLinkSlug`/`formatAudit`/`footerButtons` helpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
### Fixed
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a `jq-repeat`/DOM-rebuild timing race, now rendered manually instead).
### Operational note
The new `Resource` audit fields require a schema migration on any existing deployment: `ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER;` (adjust types for non-sqlite dialects) — `@simpleworkjs/orm`'s `sync()` only creates missing tables, it never alters existing ones.
## [1.7.0] - 2026-07-28
### Fixed
- **`formAJAX`'s loading indicator showed literal HTML** ("&lt;div class=..."), not a spinner — it passed raw markup to `app.messages.action`, which HTML-escapes its message by design. Replaced with plain text.
- **`POST /api/user/` (create) and `PUT /api/user/password` had no `message` field** in their response, so the success notification rendered empty. Added messages matching every other route's convention.
- **The user landing on `/login` with a `?redirect=` had no explanation why** — happens whenever another app's "Log in with SSO" bounces an unauthenticated user through `/oauth/authorize`. Now shows a contextual banner explaining what's happening.
### Changed
- **Directory: tree view is now the only view** (the list/tree toggle is gone) — simpler, one code path.
- **Directory: clicking a resource's name opens its detail modal**, not just the pencil/edit icon.
Found via a fresh production install's feedback — see the [theta-env v1.13.0 release](https://github.com/theta42/theta-env/releases) for the full cross-repo summary.
## [1.6.3] - 2026-07-28
### Fixed
Binary file not shown.
+26
View File
@@ -152,10 +152,36 @@ class Resource extends Model {
owner: { type: 'string' },
description: { type: 'text' },
metadata: { type: 'json', default: {} },
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
// are set explicitly by the route handler on every create/update (see
// routes/api_directory_admin.js). Existing rows predating this change
// simply read back undefined -- callers must render a fallback.
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
};
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
// whose kind === 'site', returning its slug (or null if none exists -- a
// top-level resource with no site parent keeps its unprefixed group name).
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
if (visited.has(resourceId)) return null;
visited.add(resourceId);
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
for (const edge of parentEdges) {
const parent = await this.get(edge.parentId);
if (!parent) continue;
if (parent.kind === 'site') return parent.slug;
const found = await this.findAncestorSiteSlug(parent.id, visited);
if (found) return found;
}
return null;
}
}
class ResourceEdge extends Model {
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.5.1",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.5.1",
"version": "1.7.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
@@ -14,7 +14,7 @@
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
@@ -1280,9 +1280,9 @@
}
},
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.6.3",
"version": "1.8.1",
"description": "A very simple LDAP management and SSO system",
"author": [
{
@@ -26,7 +26,7 @@
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
+4 -7
View File
@@ -679,13 +679,10 @@ function formAJAX(btn){
return false;
}
app.messages.action(
`<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>`,
$form,
'info'
);
// Plain text: app.messages.action HTML-escapes its message (by design,
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
// render literally instead of as an element.
app.messages.action('Saving…', $form, 'info');
app.api[method]($form.attr('action'), formData, function(error, data){
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
+17 -6
View File
@@ -43,25 +43,33 @@ router.post('/resources', async (req, res, next) => {
}
req.body.owner = req.body.owner || req.user.uid;
const now = Date.now();
req.body.created_by = req.body.created_by || req.user.uid;
req.body.created_on = now;
req.body.updated_by = req.user.uid;
req.body.updated_on = now;
let r;
if (req.body.kind === 'oauth') {
const { OAuthClient } = require('../models/oauth_client');
// Pass created_by explicitly for the wrapper
// Pass created_by explicitly for the wrapper (overrides the generic
// assignment above -- this is OAuthClient-wrapper-specific behavior).
req.body.created_by = req.body.owner;
// In the UI we might pass slug, but OAuthClient wrapper expects name
r = await OAuthClient.add(req.body);
} else {
r = await Resource.create(req.body);
}
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
}
if (r.kind === 'host' || r.kind === 'service') {
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
const createGroup = async (suffix, accessLevel) => {
const cn = `${r.slug}_${suffix}`;
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
try {
await Group.add({
name: cn,
@@ -103,7 +111,10 @@ router.put('/resources/:id', async (req, res, next) => {
r = await Resource.get(req.params.id);
}
if (!r) return res.status(404).json({ error: 'Not found' });
req.body.updated_by = req.user.uid;
req.body.updated_on = Date.now();
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
+8
View File
@@ -60,6 +60,14 @@ router.get('/directory', function(req, res) {
res.render('directory', {...values});
});
// Linkable deep-link to a single resource's modal, e.g. from the resource
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
// use of :slug at all -- the client reads location.pathname itself and opens
// the matching resource's modal once the page's own data has loaded.
router.get('/directory/:slug', function(req, res) {
res.render('directory', {...values});
});
// Route removed since it's now in directory
router.get('/onboarding', async function(req, res, next) {
+2 -2
View File
@@ -49,7 +49,7 @@ router.post('/', async function(req, res, next){
}
}
return res.json({results: user});
return res.json({results: user, message: `User ${user.uid} created.`});
}catch(error){
next(error);
}
@@ -107,7 +107,7 @@ router.put('/password', async function(req, res, next){
const verif = await UserVerification.getOrCreate(req.user.uid);
await verif.update({ password_must_change: false });
User.clearCache();
return res.json({results: result});
return res.json({results: result, message: 'Password changed.'});
}catch(error){
next(error);
}
+63
View File
@@ -0,0 +1,63 @@
'use strict';
// findAncestorSiteSlug has no LDAP dependency (unlike most of this test
// suite, which needs a live LDAP server) -- it's pure Resource/ResourceEdge
// graph traversal against the ORM, so it's tested directly here rather than
// through the (LDAP-gated) directory-admin HTTP routes.
const { initORM } = require('../models');
const { Resource, ResourceEdge } = require('../models/resource');
const marker = 'test_site_slug_' + Date.now();
const created = [];
async function makeResource(kind, name) {
const r = await Resource.create({ kind, name, slug: `${marker}_${name}` });
created.push(r);
return r;
}
beforeAll(async () => {
await initORM();
});
afterAll(async () => {
for (const r of created) {
try { await r.delete(); } catch (_) {}
}
});
describe('Resource.findAncestorSiteSlug', () => {
test('returns the direct parent site\'s slug', async () => {
const site = await makeResource('site', 'site-direct');
const host = await makeResource('host', 'host-direct');
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBe(site.slug);
});
test('walks up through an intermediate host to find the owning site', async () => {
const site = await makeResource('site', 'site-nested');
const host = await makeResource('host', 'host-nested');
const service = await makeResource('service', 'service-nested');
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
await ResourceEdge.create({ parentId: host.id, childId: service.id, relation: 'hosts' });
await expect(Resource.findAncestorSiteSlug(service.id)).resolves.toBe(site.slug);
});
test('returns null for a top-level resource with no site ancestor', async () => {
const host = await makeResource('host', 'host-orphan');
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBeNull();
});
test('does not loop forever on a cyclic parent chain', async () => {
const a = await makeResource('host', 'host-cycle-a');
const b = await makeResource('host', 'host-cycle-b');
await ResourceEdge.create({ parentId: a.id, childId: b.id, relation: 'hosts' });
await ResourceEdge.create({ parentId: b.id, childId: a.id, relation: 'hosts' });
await expect(Resource.findAncestorSiteSlug(a.id)).resolves.toBeNull();
});
});
+378 -332
View File
@@ -15,12 +15,6 @@
<option value="kind">Kind</option>
<option value="env">Environment</option>
</select>
<div class="btn-group btn-group-sm shadow-sm" role="group">
<input type="radio" class="btn-check" name="viewMode" id="view-list" value="list" autocomplete="off" checked onchange="renderTable()">
<label class="btn btn-outline-secondary" for="view-list"><i class="fa-solid fa-list"></i></label>
<input type="radio" class="btn-check" name="viewMode" id="view-tree" value="tree" autocomplete="off" onchange="renderTable()">
<label class="btn btn-outline-secondary" for="view-tree"><i class="fa-solid fa-folder-tree"></i></label>
</div>
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
<i class="fas fa-plus"></i> Add Resource
</button>
@@ -49,7 +43,12 @@
{{{indentHtml}}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
</td>
<td><strong>{{name}}</strong><br><small class="text-muted">{{slug}}</small></td>
<td>
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong>
</a>
<br><small class="text-muted">{{slug}}</small>
</td>
<td>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
@@ -79,252 +78,269 @@
</div>
</div>
<!-- Edit Resource Modal -->
<div class="modal fade" id="resourceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header shadow">
<h5 class="modal-title" id="resourceModalTitle">Resource</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="res-id">
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Name</label>
<input type="text" id="res-name" class="form-control shadow-sm">
</div>
<div class="col-6">
<label class="form-label">Slug</label>
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Kind</label>
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
<option value="site">Site</option>
<option value="host">Host</option>
<option value="service">Service (App)</option>
<option value="oauth">OAuth Integration</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
</div>
</div>
<div class="row mb-3" id="site-details-container" style="display: none;">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="res-is-current-site">
<label class="form-check-label" for="res-is-current-site">
Mark as Current Site
</label>
</div>
</div>
</div>
<div class="row mb-3" id="host-parent-container" style="display: none;">
<div class="col-12">
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
<select id="res-host-id" class="form-select shadow-sm border-primary">
<option value="">-- Select Parent --</option>
</select>
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">IP Address</label>
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
</div>
<div class="col-6">
<label class="form-label">Host / URI Address</label>
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
</div>
</div>
<div class="row mb-3" id="host-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">VMID</label>
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
</div>
<div class="col-4">
<label class="form-label">MAC Address</label>
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
</div>
<div class="col-4">
<label class="form-label">OS / Kernel</label>
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
</div>
</div>
<div class="row mb-3" id="service-ports-container" style="display: none;">
<div class="col-6">
<label class="form-label">Internal Port</label>
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
</div>
<div class="col-6">
<label class="form-label">External Port</label>
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
<small class="text-muted">Same as Internal if empty</small>
</div>
</div>
<div class="row mb-3" id="service-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">Git Repo</label>
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
</div>
<div class="col-4">
<label class="form-label">Install Path</label>
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
</div>
<div class="col-4">
<label class="form-label">Systemd Service</label>
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
</div>
</div>
<div id="oauth-details-container" style="display: none;">
<hr>
<h5>OAuth Configuration</h5>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
</div>
<div class="col-6">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
</div>
</div>
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
<button class="btn btn-outline-warning" onclick="rotateSecret()">
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
</button>
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
</div>
</div>
<div class="row mb-3">
<div class="col-4">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-production">
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
</div>
</div>
<div class="col-4" id="external-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-external">
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
</div>
</div>
<div class="col-4" id="public-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-public">
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
</div>
<hr>
<div id="edit-only-section" style="display: none;">
<h5>Associated LDAP Groups</h5>
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="groups-list" jq-repeat="groups">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<i class="fa-solid fa-users text-muted me-2"></i>
<strong>{{groupCn}}</strong>
<span class="badge bg-primary ms-2">{{accessLevel}}</span>
</span>
<button class="btn btn-sm btn-outline-danger" onclick="removeGroup('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
</li>
</ul>
<div class="input-group shadow-sm mt-2">
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
<datalist id="ldap-groups-datalist"></datalist>
<select class="form-select" id="new-group-level" style="max-width: 140px;">
<option value="member">Member</option>
<option value="owner">Owner</option>
</select>
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
<hr>
<h5>Relationships (Graph Edges)</h5>
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="edges-list" jq-repeat="edges">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
{{#isParent}}
<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
{{/isParent}}
{{^isParent}}
<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
{{/isParent}}
</span>
<button class="btn btn-sm btn-outline-danger" onclick="removeEdge('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
</li>
</ul>
<div class="input-group shadow-sm mt-2">
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
<option value="parent">Has child</option>
<option value="child">Is child of</option>
</select>
<select class="form-select" id="new-edge-target">
<option value="">-- Select Resource --</option>
</select>
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" onclick="saveResource()">
<i class="fa-solid fa-floppy-disk"></i> Save Resource
</button>
</div>
</div>
</div>
</div>
<script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
var resourceModal = new bootstrap.Modal(document.getElementById('resourceModal'));
// --- Resource modal tab content, built once. Populated via .val() in
// openAddModal/openEditModal AFTER app.modal.open() has (re)built the
// DOM from these strings -- order matters, unlike the old always-in-DOM
// static modal. ---
var generalTabHtml = `
<div class="actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="res-id">
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Name</label>
<input type="text" id="res-name" class="form-control shadow-sm">
</div>
<div class="col-6">
<label class="form-label">Slug</label>
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Kind</label>
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
<option value="site">Site</option>
<option value="host">Host</option>
<option value="service">Service (App)</option>
<option value="oauth">OAuth Integration</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">IP Address</label>
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
</div>
<div class="col-6">
<label class="form-label">Host / URI Address</label>
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-production">
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
</div>
`;
var detailsTabHtml = `
<div class="row mb-3" id="site-details-container" style="display: none;">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="res-is-current-site">
<label class="form-check-label" for="res-is-current-site">
Mark as Current Site
</label>
</div>
</div>
</div>
<div class="row mb-3" id="host-parent-container" style="display: none;">
<div class="col-12">
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
<select id="res-host-id" class="form-select shadow-sm border-primary">
<option value="">-- Select Parent --</option>
</select>
</div>
</div>
<div class="row mb-3" id="host-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">VMID</label>
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
</div>
<div class="col-4">
<label class="form-label">MAC Address</label>
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
</div>
<div class="col-4">
<label class="form-label">OS / Kernel</label>
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
</div>
</div>
<div class="row mb-3" id="service-ports-container" style="display: none;">
<div class="col-6">
<label class="form-label">Internal Port</label>
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
</div>
<div class="col-6">
<label class="form-label">External Port</label>
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
<small class="text-muted">Same as Internal if empty</small>
</div>
</div>
<div class="row mb-3" id="service-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">Git Repo</label>
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
</div>
<div class="col-4">
<label class="form-label">Install Path</label>
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
</div>
<div class="col-4">
<label class="form-label">Systemd Service</label>
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
</div>
</div>
<div id="oauth-details-container" style="display: none;">
<hr>
<h5>OAuth Configuration</h5>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
</div>
<div class="col-6">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
</div>
</div>
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
<button class="btn btn-outline-warning" onclick="rotateSecret()">
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
</button>
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
</div>
</div>
<div class="row mb-3">
<div class="col-6" id="external-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-external">
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
</div>
</div>
<div class="col-6" id="public-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-public">
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
</div>
</div>
</div>
`;
var groupsTabHtml = `
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="groups-list"></ul>
<div class="input-group shadow-sm mt-2">
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
<datalist id="ldap-groups-datalist"></datalist>
<select class="form-select" id="new-group-level" style="max-width: 140px;">
<option value="member">Member</option>
<option value="owner">Owner</option>
</select>
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
`;
var childrenTabHtml = `
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="children-list"></ul>
<button class="btn btn-success btn-sm" onclick="openAddModal($('#res-id').val(), $('#res-kind').val())">
<i class="fa-solid fa-plus"></i> Add Child Resource
</button>
</div>
<hr>
<h6 class="text-muted">Other Relationships (advanced)</h6>
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="edges-list"></ul>
<div class="input-group shadow-sm mt-2">
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
<option value="parent">Has child</option>
<option value="child">Is child of</option>
</select>
<select class="form-select" id="new-edge-target">
<option value="">-- Select Resource --</option>
</select>
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
`;
// Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
// URL-tracked) modal DOM. Callers then populate fields via .val() and hide
// the Groups/Children tabs in add-mode (no resource id to scope them to).
function openResourceModal(title, id) {
app.modal.open({
title: title,
size: 'lg',
tabs: [
{id: 'general', label: 'General', bodyHtml: generalTabHtml, active: true},
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
],
footer: {
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
buttonsHtml: app.modal.footerButtons({onSave: 'saveResource()', saveLabel: 'Save Resource'}),
},
url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
});
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
}
function refreshChildrenUI(resourceId) {
const kids = allEdges.filter(e => e.parentId === resourceId)
.map(e => resourcesById[e.childId]).filter(Boolean);
const $list = $('#children-list').empty();
if (!kids.length) {
$list.append('<li class="list-group-item text-muted">No child resources.</li>');
return;
}
for (const kid of kids) {
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
$('<a href="#" class="text-reset text-decoration-none"></a>')
.text(kid.name + ' (' + kid.slug + ')')
.on('click', function(e){ e.preventDefault(); openEditModal(kid.id); })
.appendTo($li);
$('<span class="badge bg-secondary"></span>').text(kid.kind).appendTo($li);
$list.append($li);
}
}
var resourcesById = {};
var allGroups = [];
var allEdges = [];
@@ -332,6 +348,11 @@
$(document).ready(async function() {
await loadResources();
const slug = app.modal.deepLinkSlug('/directory');
if (slug) {
const match = Object.values(resourcesById).find(r => r.slug === slug);
if (match) openEditModal(match.id);
}
});
async function loadResources() {
@@ -376,8 +397,7 @@
function renderTable() {
const filter = $('#search-filter').val().toLowerCase();
const sort = $('#sort-by').val();
const viewMode = $('input[name="viewMode"]:checked').val();
let filtered = rawResources.filter(r => {
if (!filter) return true;
return (r.name || '').toLowerCase().includes(filter) ||
@@ -402,42 +422,38 @@
let finalRenderList = [];
if (viewMode === 'tree') {
const map = {};
const roots = [];
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
filtered.forEach(r => {
const node = map[r.id];
if (node.parentId && map[node.parentId]) {
map[node.parentId].children.push(node);
} else {
roots.push(node);
}
});
const flatten = (nodes, depth) => {
nodes.forEach(n => {
let indentHtml = '';
for(let i = 0; i < depth; i++) {
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
}
if (depth > 0) {
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
}
n.indentHtml = indentHtml;
finalRenderList.push(n);
if (n.children.length > 0) {
flatten(n.children, depth + 1);
}
});
};
flatten(roots, 0);
} else {
finalRenderList = filtered.map(r => ({ ...r, indentHtml: '' }));
}
const map = {};
const roots = [];
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
filtered.forEach(r => {
const node = map[r.id];
if (node.parentId && map[node.parentId]) {
map[node.parentId].children.push(node);
} else {
roots.push(node);
}
});
const flatten = (nodes, depth) => {
nodes.forEach(n => {
let indentHtml = '';
for(let i = 0; i < depth; i++) {
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
}
if (depth > 0) {
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
}
n.indentHtml = indentHtml;
finalRenderList.push(n);
if (n.children.length > 0) {
flatten(n.children, depth + 1);
}
});
};
flatten(roots, 0);
$.scope.resources.empty();
for (const r of finalRenderList) {
$.scope.resources.push(r);
@@ -486,7 +502,10 @@
populateHostDropdown($('#res-host-id').val());
}
$('#res-name, #res-kind').on('input change', function() {
// Delegated (not direct) since app.modal.open() rebuilds the modal body
// from scratch every call -- a direct .on() bind here would silently stop
// firing after the first Add/Edit.
app.modal.on('input change', '#res-name, #res-kind', function() {
const id = $('#res-id').val();
if (!id && $('#res-name').val()) {
const name = $('#res-name').val();
@@ -495,18 +514,18 @@
if (kind === 'service') prefix = 'app_';
if (kind === 'host') prefix = 'host_';
if (kind === 'site') prefix = 'site_';
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
$('#res-slug').val(slug);
}
});
function openAddModal(parentId, parentKind) {
$('#resourceModalTitle').html('<i class="fa-solid fa-plus"></i> Add Resource');
openResourceModal('Add Resource', null);
$('#res-id').val('');
$('#res-name').val('');
$('#res-slug').val('');
let defaultKind = 'service';
if (parentKind === 'site') defaultKind = 'host';
if (parentKind === 'host') defaultKind = 'service';
@@ -537,20 +556,22 @@
$('#res-is-external').prop('checked', false);
$('#res-is-public').prop('checked', false);
$('#res-is-current-site').prop('checked', false);
$('#edit-only-section').hide();
populateHostDropdown(parentId || '');
toggleFormFields();
resourceModal.show();
}
var ldapGroupsCache = null;
async function loadLdapGroups() {
if (ldapGroupsCache) return;
try {
const res = await app.api.get('group');
ldapGroupsCache = res.results;
if (!ldapGroupsCache) {
const res = await app.api.get('group');
ldapGroupsCache = res.results;
}
// Re-populate every call, not just the first -- #ldap-groups-datalist
// is rebuilt fresh (empty) on every app.modal.open(), so returning
// early here on a cache hit left the second and later modal opens
// with no autocomplete options at all.
const $datalist = $('#ldap-groups-datalist');
$datalist.empty();
for (const cn of ldapGroupsCache) {
@@ -579,31 +600,59 @@
if (selectedId) $target.val(selectedId);
}
// Plain DOM rendering, not jq-repeat: #groups-list is injected fresh into
// the modal body on every app.modal.open() call, and jq-repeat's
// MutationObserver-based (re)registration for a same-id element that's
// destroyed and recreated runs asynchronously -- populating it
// synchronously right after open() (as this function is called) would race
// that and silently lose the pushed data on the second and later opens.
function refreshGroupsUI(resourceId) {
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
$.scope.groups.empty();
const $list = $('#groups-list').empty();
if (!myGroups.length) {
$list.append('<li class="list-group-item text-muted">No associated groups.</li>');
return;
}
for (const g of myGroups) {
$.scope.groups.push(g);
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
const $span = $('<span></span>');
$span.append('<i class="fa-solid fa-users text-muted me-2"></i>');
$('<strong></strong>').text(g.groupCn).appendTo($span);
$('<span class="badge bg-primary ms-2"></span>').text(g.accessLevel).appendTo($span);
$li.append($span);
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
.on('click', function(){ removeGroup(g.id); })
.appendTo($li);
$list.append($li);
}
}
function refreshEdgesUI(resourceId) {
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
$.scope.edges.empty();
const $list = $('#edges-list').empty();
if (!myEdges.length) {
$list.append('<li class="list-group-item text-muted">No other relationships.</li>');
}
for (const e of myEdges) {
const isParent = e.parentId === resourceId;
const targetId = isParent ? e.childId : e.parentId;
const target = resourcesById[targetId];
if (!target) continue;
$.scope.edges.push({
id: e.id,
isParent: isParent,
relation: e.relation,
targetName: target.name + ' (' + target.slug + ')'
});
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
const $span = $('<span></span>');
$span.append(isParent
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
: '<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: ');
$('<strong></strong>').text(target.name + ' (' + target.slug + ')').appendTo($span);
$('<span class="badge bg-secondary ms-1"></span>').text(e.relation).appendTo($span);
$li.append($span);
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
.on('click', function(){ removeEdge(e.id); })
.appendTo($li);
$list.append($li);
}
const $target = $('#new-edge-target');
$target.empty().append('<option value="">-- Select Resource --</option>');
Object.values(resourcesById).forEach(r => {
@@ -616,8 +665,8 @@
async function openEditModal(id) {
const r = resourcesById[id];
if (!r) return;
$('#resourceModalTitle').html('<i class="fa-solid fa-pen-to-square"></i> Edit Resource');
openResourceModal('Edit Resource', id);
$('#res-id').val(r.id);
$('#res-name').val(r.name);
$('#res-slug').val(r.slug);
@@ -655,14 +704,11 @@
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
toggleFormFields();
$('#edit-only-section').show();
refreshGroupsUI(r.id);
refreshEdgesUI(r.id);
refreshChildrenUI(r.id);
await loadLdapGroups();
resourceModal.show();
}
async function saveResource() {
@@ -710,22 +756,22 @@
res = await app.api.post('directory-admin/resources', data);
}
resourceModal.hide();
app.modal.close();
await loadResources();
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
app.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
}
} catch (err) {
console.error(err);
app.messages.action(err.message || 'Failed to save', $('#resourceModal'), 'danger');
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
}
}
async function rotateSecret() {
const id = $('#res-id').val();
if (!id) return;
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', $('#resourceModal'), 'warning');
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', app.modal.body(), 'warning');
if (!ok) return;
try {
@@ -733,7 +779,7 @@
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
} catch (err) {
console.error(err);
app.messages.action(err.message || 'Failed to rotate secret', $('#resourceModal'), 'danger');
app.messages.action(err.message || 'Failed to rotate secret', app.modal.body(), 'danger');
}
}
@@ -742,7 +788,7 @@
const groupCn = $('#new-group-cn').val().trim();
const accessLevel = $('#new-group-level').val();
if (!groupCn) return app.messages.action('Group CN is required', $('#resourceModal'), 'danger');
if (!groupCn) return app.messages.action('Group CN is required', app.modal.body(), 'danger');
try {
const res = await app.api.post('directory-admin/groups', {
resourceId,
@@ -754,7 +800,7 @@
$('#new-group-cn').val('');
} catch (err) {
console.error(err);
app.messages.action('Failed to add group', $('#resourceModal'), 'danger');
app.messages.action('Failed to add group', app.modal.body(), 'danger');
}
}
@@ -765,7 +811,7 @@
refreshGroupsUI($('#res-id').val());
} catch (err) {
console.error(err);
app.messages.action('Failed to remove group', $('#resourceModal'), 'danger');
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
}
}
@@ -775,7 +821,7 @@
const targetId = $('#new-edge-target').val();
const relation = $('#new-edge-relation').val().trim() || 'hosts';
if (!targetId) return app.messages.action('Select a target resource', $('#resourceModal'), 'danger');
if (!targetId) return app.messages.action('Select a target resource', app.modal.body(), 'danger');
const data = { relation };
if (dir === 'parent') {
@@ -793,7 +839,7 @@
$('#new-edge-target').val('');
} catch (err) {
console.error(err);
app.messages.action('Failed to add edge', $('#resourceModal'), 'danger');
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
}
}
@@ -804,14 +850,14 @@
refreshEdgesUI($('#res-id').val());
} catch (err) {
console.error(err);
app.messages.action('Failed to remove edge', $('#resourceModal'), 'danger');
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
}
}
async function deleteResource(id) {
// Called from the outer table's row button, not from inside
// #resourceModal — target the page's own card so the confirm/error
// renders somewhere actually visible.
// Called from the outer table's row button, not from inside the resource
// modal — target the page's own card so the confirm/error renders
// somewhere actually visible.
const $target = $('#resources-list');
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
if (!ok) return;
+16
View File
@@ -7,6 +7,22 @@
}
});
// Landing here with no explanation ("why am I on the SSO login page?") is
// exactly what happens when another app's "Log in with SSO" button sends
// an unauthenticated user through /oauth/authorize, which bounces them
// here with ?redirect=. Tell them what's happening instead of leaving it
// a mystery.
$(document).ready(function(){
var redirect = <%- JSON.stringify(redirect || '') %>;
if(redirect){
var isOauth = /\/oauth\/authorize/.test(redirect);
var message = isOauth
? 'Log in to continue — an application is requesting access to your account.'
: "Log in to continue to what you were doing — you'll be sent back afterward.";
app.messages.action(message, $('.card').first(), 'info');
}
});
function setOtpMethod(method) {
$('#otpMethodInput').val(method);
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');