diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index fe757d0..c4a5ea0 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.7.0", + "version": "1.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.7.0", + "version": "1.8.3", "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.6", + "@simpleworkjs/frontend": "^0.2.7", "@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.6", - "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz", - "integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz", + "integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/nodejs/package.json b/nodejs/package.json index e0c929e..4cbbd55 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.6", + "@simpleworkjs/frontend": "^0.2.7", "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 1640742..7528f11 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -7,6 +7,12 @@ body { display: flex; flex-direction: column; min-height: 100vh; + /* Height of the fixed navbar (plus the update banner, while shown -- + see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page + sticky element offset itself below both fixed elements via + `top: var(--sw-content-offset)` instead of colliding with them at the + viewport's true top:0. */ + --sw-content-offset: 4.5rem; } #spa-shell { diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 3d4398d..88f4290 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -584,10 +584,31 @@ app.util = (function(app){ document.body.removeChild(element); } + // Scroll a just-added/-edited element into view and flash its + // background, so the user's eye lands on the row that changed instead of + // it silently appearing/updating somewhere off-screen. Takes a jQuery + // object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`). + function revealItem(el){ + var node = el && el.jquery ? el[0] : el; + if (!node) return; + if (typeof node.scrollIntoView === 'function') { + node.scrollIntoView({behavior: 'smooth', block: 'center'}); + } + var prevTransition = node.style.transition; + var prevBg = node.style.backgroundColor; + node.style.transition = 'background-color 1.5s ease'; + node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)'; + setTimeout(function(){ + node.style.backgroundColor = prevBg; + setTimeout(function(){ node.style.transition = prevTransition; }, 1500); + }, 300); + } + return { downloadFile: downloadFile, getUrlParameter: getUrlParameter, escapeHtml: escapeHtml, + revealItem: revealItem, } })(app); diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index c2fd2dc..6d49cb2 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -29,31 +29,25 @@ - - - - + - - + - - -
KindNameEnvHostResource IP / Address Actions
+
{{{indentHtml}}} {{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}} - + {{#metadata.isProduction}}Prod{{/metadata.isProduction}} + {{^metadata.isProduction}}Dev{{/metadata.isProduction}} + {{hostName}} +
{{name}} -
{{slug}} + {{slug}}
- {{#metadata.isProduction}}Prod{{/metadata.isProduction}} - {{^metadata.isProduction}}Dev{{/metadata.isProduction}} - {{hostName}} {{#metadata.ip}}
IP: {{metadata.ip}}
{{/metadata.ip}} {{#metadata.address}}
URL: {{metadata.address}}
{{/metadata.address}} @@ -259,7 +253,8 @@ `; var groupsTabHtml = ` -
+
+
    @@ -606,7 +601,94 @@ // 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) { + // uid from a member/owner DN, e.g. "cn=jdoe,ou=people,..." -> "jdoe". + function uidFromDn(dn) { + const m = dn.match(/cn=[a-zA-Z0-9_\-@.]+/); + return m ? m[0].replace('cn=', '') : dn; + } + + var directoryUserCache = null; + async function loadDirectoryUsers() { + if (!directoryUserCache) { + const data = await app.user.list(); + directoryUserCache = data.results; + } + return directoryUserCache; + } + + // Renders one associated group's member/owner management block: a + // collapsible member list (remove button per member) plus an "add member" + // dropdown of users not already in the group. Fetches the live LDAP group + // detail (member/owner DN arrays) rather than relying on `allGroups`, which + // only holds the resource<->group association record, not membership. + async function renderGroupMembership(g, resourceId) { + const $li = $('
  • '); + const $head = $('
    '); + const $span = $(''); + $span.append(''); + $('').text(g.groupCn).appendTo($span); + $('').text(g.accessLevel).appendTo($span); + $head.append($span); + + const collapseId = 'group-members-' + g.id; + const $actions = $(''); + $('') + .attr('data-bs-target', '#' + collapseId) + .appendTo($actions); + $('') + .on('click', function(){ removeGroup(g.id); }) + .appendTo($actions); + $head.append($actions); + $li.append($head); + + const $collapse = $('
    ').attr('id', collapseId); + $li.append($collapse); + + let detail, userlist; + try { + [detail, userlist] = await Promise.all([ + app.group.get(g.groupCn).then(d => d.results), + loadDirectoryUsers(), + ]); + } catch (err) { + console.error(err); + $collapse.append('

    Failed to load group membership.

    '); + return $li; + } + + const memberDns = Array.isArray(detail.member) ? detail.member : (detail.member ? [detail.member] : []); + const $memberList = $('
      '); + if (!memberDns.length) { + $memberList.append('
    • No members.
    • '); + } + for (const dn of memberDns) { + const uid = uidFromDn(dn); + const $mLi = $('
    • '); + $('').append('').append(document.createTextNode(uid)).appendTo($mLi); + $('') + .on('click', function(){ removeResourceGroupMember(g.groupCn, uid, resourceId); }) + .appendTo($mLi); + $memberList.append($mLi); + } + $collapse.append($memberList); + + const toAdd = userlist.filter(function(u){ return !memberDns.includes(u.dn); }); + const $dropdown = $(''); + $('') + .appendTo($dropdown); + const $menu = $(''); + for (const u of toAdd) { + $('').text(u.uid) + .on('click', function(e){ e.preventDefault(); addResourceGroupMember(g.groupCn, u.uid, resourceId); }) + .appendTo($menu); + } + $dropdown.append($menu); + $collapse.append($dropdown); + + return $li; + } + + async function refreshGroupsUI(resourceId) { const myGroups = allGroups.filter(g => g.resourceId === resourceId); const $list = $('#groups-list').empty(); if (!myGroups.length) { @@ -614,16 +696,30 @@ return; } for (const g of myGroups) { - const $li = $('
    • '); - const $span = $(''); - $span.append(''); - $('').text(g.groupCn).appendTo($span); - $('').text(g.accessLevel).appendTo($span); - $li.append($span); - $('') - .on('click', function(){ removeGroup(g.id); }) - .appendTo($li); - $list.append($li); + $list.append(await renderGroupMembership(g, resourceId)); + } + } + + async function addResourceGroupMember(groupCn, uid, resourceId) { + try { + const data = await app.api.put('group/' + groupCn + '/' + uid, {}); + await refreshGroupsUI(resourceId); + app.messages.action(data.message, $('#groups-tab-container'), 'success'); + app.util.revealItem($('#groups-list')); + } catch (err) { + app.messages.action((err && err.message) || 'Failed to add member', $('#groups-tab-container'), 'danger'); + } + } + + async function removeResourceGroupMember(groupCn, uid, resourceId) { + const confirmed = await app.messages.confirm('Remove "' + uid + '" from "' + groupCn + '"?', $('#groups-tab-container'), 'warning'); + if (!confirmed) return; + try { + const data = await app.api.delete('group/' + groupCn + '/' + uid); + await refreshGroupsUI(resourceId); + app.messages.action(data.message, $('#groups-tab-container'), 'success'); + } catch (err) { + app.messages.action((err && err.message) || 'Failed to remove member', $('#groups-tab-container'), 'danger'); } } @@ -757,6 +853,8 @@ } await loadResources(); + const savedId = id || (res.results && res.results.id); + if (savedId) setTimeout(function(){ app.util.revealItem($('#resource-row-' + savedId)); }, 400); if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) { // Deliberately no app.modal.close() before this -- app.modal is a diff --git a/nodejs/views/groups.ejs b/nodejs/views/groups.ejs index 2eb7625..0523dce 100644 --- a/nodejs/views/groups.ejs +++ b/nodejs/views/groups.ejs @@ -64,9 +64,7 @@ $.scope.groupCard.update('cn', group, processGroup(data.results)); app.messages.action(message, $("#group-card-"+group), 'success'); $('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show'); - setTimeout(function(group){ - $("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0); - }, 400, group); + setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400); } function applySort() { @@ -91,10 +89,11 @@ $('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : '')); } - async function tableAJAX() { + async function tableAJAX(revealCn) { let data = await app.group.list(); allGroups = data.results.map(processGroup); applyFilters(); + if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100); } async function removeMember(groupCN, uid, btn) { @@ -150,7 +149,7 @@
      -
      +
      @@ -173,7 +172,7 @@
      -
      +
      diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index 1fe6879..0ea839c 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -82,16 +82,24 @@