From 0e955abc739bad923c9c618373ab94bc512be67e Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 28 Jul 2026 17:42:05 -0400 Subject: [PATCH] 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 (_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. --- nodejs/config/inventory.sqlite | Bin 32768 -> 32768 bytes nodejs/models/resource.js | 26 + nodejs/package-lock.json | 12 +- nodejs/package.json | 2 +- nodejs/routes/api_directory_admin.js | 23 +- nodejs/routes/index.js | 8 + nodejs/tests/resource_site_slug.test.js | 63 +++ nodejs/views/directory.ejs | 615 +++++++++++++----------- 8 files changed, 452 insertions(+), 297 deletions(-) create mode 100644 nodejs/tests/resource_site_slug.test.js diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index a027ce22503a8f98ca97c1339e9d9a22abd1ba77..9847b23acf16fd632a06e8522c82f5c0f678b1ef 100644 GIT binary patch literal 32768 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 P`AwiEZrscr|5X71J2oxW diff --git a/nodejs/models/resource.js b/nodejs/models/resource.js index 217988a..399a4ad 100644 --- a/nodejs/models/resource.js +++ b/nodejs/models/resource.js @@ -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 { diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 49bde5b..fe757d0 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -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" diff --git a/nodejs/package.json b/nodejs/package.json index f713ec6..7ce6f84 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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", diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 46a0320..c70bd0e 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -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' }); } diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 9da6db4..05bac0f 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -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) { diff --git a/nodejs/tests/resource_site_slug.test.js b/nodejs/tests/resource_site_slug.test.js new file mode 100644 index 0000000..fafe407 --- /dev/null +++ b/nodejs/tests/resource_site_slug.test.js @@ -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(); + }); +}); diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index c1b17aa..d3ca838 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -78,252 +78,269 @@ - - -