Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 782ef69fb8 | |||
| 0e955abc73 |
@@ -4,6 +4,23 @@ 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.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
|
## [1.7.0] - 2026-07-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Binary file not shown.
@@ -152,10 +152,36 @@ class Resource extends Model {
|
|||||||
owner: { type: 'string' },
|
owner: { type: 'string' },
|
||||||
description: { type: 'text' },
|
description: { type: 'text' },
|
||||||
metadata: { type: 'json', default: {} },
|
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' },
|
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
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 {
|
class ResourceEdge extends Model {
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.5.1",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.5.1",
|
"version": "1.7.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.5",
|
"@simpleworkjs/frontend": "^0.2.6",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -1280,9 +1280,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@simpleworkjs/frontend": {
|
"node_modules/@simpleworkjs/frontend": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
|
||||||
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
|
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.5",
|
"@simpleworkjs/frontend": "^0.2.6",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -43,25 +43,33 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.body.owner = req.body.owner || req.user.uid;
|
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;
|
let r;
|
||||||
if (req.body.kind === 'oauth') {
|
if (req.body.kind === 'oauth') {
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
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;
|
req.body.created_by = req.body.owner;
|
||||||
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||||
r = await OAuthClient.add(req.body);
|
r = await OAuthClient.add(req.body);
|
||||||
} else {
|
} else {
|
||||||
r = await Resource.create(req.body);
|
r = await Resource.create(req.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
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' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
if (r.kind === 'host' || r.kind === 'service') {
|
||||||
|
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
const createGroup = async (suffix, accessLevel) => {
|
||||||
const cn = `${r.slug}_${suffix}`;
|
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
|
||||||
try {
|
try {
|
||||||
await Group.add({
|
await Group.add({
|
||||||
name: cn,
|
name: cn,
|
||||||
@@ -103,7 +111,10 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
r = await Resource.get(req.params.id);
|
r = await Resource.get(req.params.id);
|
||||||
}
|
}
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
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) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ router.get('/directory', function(req, res) {
|
|||||||
res.render('directory', {...values});
|
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
|
// Route removed since it's now in directory
|
||||||
|
|
||||||
router.get('/onboarding', async function(req, res, next) {
|
router.get('/onboarding', async function(req, res, next) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+331
-284
@@ -78,252 +78,269 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
|
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 resourcesById = {};
|
||||||
var allGroups = [];
|
var allGroups = [];
|
||||||
var allEdges = [];
|
var allEdges = [];
|
||||||
@@ -331,6 +348,11 @@
|
|||||||
|
|
||||||
$(document).ready(async function() {
|
$(document).ready(async function() {
|
||||||
await loadResources();
|
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() {
|
async function loadResources() {
|
||||||
@@ -480,7 +502,10 @@
|
|||||||
populateHostDropdown($('#res-host-id').val());
|
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();
|
const id = $('#res-id').val();
|
||||||
if (!id && $('#res-name').val()) {
|
if (!id && $('#res-name').val()) {
|
||||||
const name = $('#res-name').val();
|
const name = $('#res-name').val();
|
||||||
@@ -489,18 +514,18 @@
|
|||||||
if (kind === 'service') prefix = 'app_';
|
if (kind === 'service') prefix = 'app_';
|
||||||
if (kind === 'host') prefix = 'host_';
|
if (kind === 'host') prefix = 'host_';
|
||||||
if (kind === 'site') prefix = 'site_';
|
if (kind === 'site') prefix = 'site_';
|
||||||
|
|
||||||
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||||
$('#res-slug').val(slug);
|
$('#res-slug').val(slug);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function openAddModal(parentId, parentKind) {
|
function openAddModal(parentId, parentKind) {
|
||||||
$('#resourceModalTitle').html('<i class="fa-solid fa-plus"></i> Add Resource');
|
openResourceModal('Add Resource', null);
|
||||||
$('#res-id').val('');
|
$('#res-id').val('');
|
||||||
$('#res-name').val('');
|
$('#res-name').val('');
|
||||||
$('#res-slug').val('');
|
$('#res-slug').val('');
|
||||||
|
|
||||||
let defaultKind = 'service';
|
let defaultKind = 'service';
|
||||||
if (parentKind === 'site') defaultKind = 'host';
|
if (parentKind === 'site') defaultKind = 'host';
|
||||||
if (parentKind === 'host') defaultKind = 'service';
|
if (parentKind === 'host') defaultKind = 'service';
|
||||||
@@ -531,12 +556,9 @@
|
|||||||
$('#res-is-external').prop('checked', false);
|
$('#res-is-external').prop('checked', false);
|
||||||
$('#res-is-public').prop('checked', false);
|
$('#res-is-public').prop('checked', false);
|
||||||
$('#res-is-current-site').prop('checked', false);
|
$('#res-is-current-site').prop('checked', false);
|
||||||
$('#edit-only-section').hide();
|
|
||||||
|
|
||||||
populateHostDropdown(parentId || '');
|
populateHostDropdown(parentId || '');
|
||||||
toggleFormFields();
|
toggleFormFields();
|
||||||
|
|
||||||
resourceModal.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var ldapGroupsCache = null;
|
var ldapGroupsCache = null;
|
||||||
@@ -573,31 +595,59 @@
|
|||||||
if (selectedId) $target.val(selectedId);
|
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) {
|
function refreshGroupsUI(resourceId) {
|
||||||
const myGroups = allGroups.filter(g => g.resourceId === 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) {
|
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) {
|
function refreshEdgesUI(resourceId) {
|
||||||
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === 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) {
|
for (const e of myEdges) {
|
||||||
const isParent = e.parentId === resourceId;
|
const isParent = e.parentId === resourceId;
|
||||||
const targetId = isParent ? e.childId : e.parentId;
|
const targetId = isParent ? e.childId : e.parentId;
|
||||||
const target = resourcesById[targetId];
|
const target = resourcesById[targetId];
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
|
|
||||||
$.scope.edges.push({
|
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||||
id: e.id,
|
const $span = $('<span></span>');
|
||||||
isParent: isParent,
|
$span.append(isParent
|
||||||
relation: e.relation,
|
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
|
||||||
targetName: target.name + ' (' + target.slug + ')'
|
: '<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');
|
const $target = $('#new-edge-target');
|
||||||
$target.empty().append('<option value="">-- Select Resource --</option>');
|
$target.empty().append('<option value="">-- Select Resource --</option>');
|
||||||
Object.values(resourcesById).forEach(r => {
|
Object.values(resourcesById).forEach(r => {
|
||||||
@@ -610,8 +660,8 @@
|
|||||||
async function openEditModal(id) {
|
async function openEditModal(id) {
|
||||||
const r = resourcesById[id];
|
const r = resourcesById[id];
|
||||||
if (!r) return;
|
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-id').val(r.id);
|
||||||
$('#res-name').val(r.name);
|
$('#res-name').val(r.name);
|
||||||
$('#res-slug').val(r.slug);
|
$('#res-slug').val(r.slug);
|
||||||
@@ -649,14 +699,11 @@
|
|||||||
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
|
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
|
||||||
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
||||||
toggleFormFields();
|
toggleFormFields();
|
||||||
|
|
||||||
$('#edit-only-section').show();
|
|
||||||
|
|
||||||
refreshGroupsUI(r.id);
|
refreshGroupsUI(r.id);
|
||||||
refreshEdgesUI(r.id);
|
refreshEdgesUI(r.id);
|
||||||
|
refreshChildrenUI(r.id);
|
||||||
await loadLdapGroups();
|
await loadLdapGroups();
|
||||||
|
|
||||||
resourceModal.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveResource() {
|
async function saveResource() {
|
||||||
@@ -704,22 +751,22 @@
|
|||||||
res = await app.api.post('directory-admin/resources', data);
|
res = await app.api.post('directory-admin/resources', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
resourceModal.hide();
|
app.modal.close();
|
||||||
await loadResources();
|
await loadResources();
|
||||||
|
|
||||||
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
|
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>'});
|
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) {
|
} catch (err) {
|
||||||
console.error(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() {
|
async function rotateSecret() {
|
||||||
const id = $('#res-id').val();
|
const id = $('#res-id').val();
|
||||||
if (!id) return;
|
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;
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -727,7 +774,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>'});
|
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) {
|
} catch (err) {
|
||||||
console.error(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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,7 +783,7 @@
|
|||||||
const groupCn = $('#new-group-cn').val().trim();
|
const groupCn = $('#new-group-cn').val().trim();
|
||||||
const accessLevel = $('#new-group-level').val();
|
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 {
|
try {
|
||||||
const res = await app.api.post('directory-admin/groups', {
|
const res = await app.api.post('directory-admin/groups', {
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -748,7 +795,7 @@
|
|||||||
$('#new-group-cn').val('');
|
$('#new-group-cn').val('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add group', $('#resourceModal'), 'danger');
|
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,7 +806,7 @@
|
|||||||
refreshGroupsUI($('#res-id').val());
|
refreshGroupsUI($('#res-id').val());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to remove group', $('#resourceModal'), 'danger');
|
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,7 +816,7 @@
|
|||||||
const targetId = $('#new-edge-target').val();
|
const targetId = $('#new-edge-target').val();
|
||||||
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
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 };
|
const data = { relation };
|
||||||
if (dir === 'parent') {
|
if (dir === 'parent') {
|
||||||
@@ -787,7 +834,7 @@
|
|||||||
$('#new-edge-target').val('');
|
$('#new-edge-target').val('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add edge', $('#resourceModal'), 'danger');
|
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,14 +845,14 @@
|
|||||||
refreshEdgesUI($('#res-id').val());
|
refreshEdgesUI($('#res-id').val());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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) {
|
async function deleteResource(id) {
|
||||||
// Called from the outer table's row button, not from inside
|
// Called from the outer table's row button, not from inside the resource
|
||||||
// #resourceModal — target the page's own card so the confirm/error
|
// modal — target the page's own card so the confirm/error renders
|
||||||
// renders somewhere actually visible.
|
// somewhere actually visible.
|
||||||
const $target = $('#resources-list');
|
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');
|
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;
|
if (!ok) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user