From 3a307c1563bbe4c9ee5c77970257feb94374cd25 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 25 Jul 2026 22:57:34 -0400 Subject: [PATCH 1/4] Unify the front-end UI shell across the theta42 apps views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now byte-identical across sso-manager-node, proxy and jump-host. Everything per-app moved into utils/ui.js, exposed to every render as `ui` via app.locals (nav items + their group gates, footer repo/docs/ToS links, favicon, profile/logout targets, update-banner on/off + label). Client framework changes: - One gating model everywhere: app-base.js reveals .group-required- for each of the current user user/me groups. sso-manager-node sends LDAP DNs in memberOf, the OIDC clients send CNs in groups; both normalise to CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so proxy nav-admin items are now group-required-admin. - user/me is fetched once per page load and cached (app.auth.loadUser); nav, forceLogin and group-required elements all read that one promise. - isLoggedIn is dual-mode (Promise + node-style callback), so the async and callback call styles both work from one shared top.ejs. - forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects to /login?redirect=, and still enforces required groups. - logOut only clears the session; the caller decides where to go next. - post/put/delete are dual-mode Promise/callback, which also removes the undefined `callback2` reference that threw on a non-function callback. Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps. proxy specifics: - .group-required base rule added to styles.css; the admin nav items lost their inline display:none in favour of it. - The brand link points at / instead of #. Co-Authored-By: Claude Opus 5 --- nodejs/app.js | 5 + nodejs/package-lock.json | 82 ++++++- nodejs/package.json | 2 +- nodejs/public/css/styles.css | 4 + nodejs/public/lib/js/app-base.js | 360 ++++++++++++++++++++++++------- nodejs/utils/ui.js | 45 ++++ nodejs/views/bottom.ejs | 52 +++-- nodejs/views/top.ejs | 106 +++++---- 8 files changed, 492 insertions(+), 164 deletions(-) create mode 100644 nodejs/utils/ui.js diff --git a/nodejs/app.js b/nodejs/app.js index 547ff43..1652b01 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -69,6 +69,11 @@ app.use(express.json()); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); +// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs). +// Set as an app local so every res.render has it, including routes that don't +// spread the routers' `values` object. +app.locals.ui = require('./utils/ui'); + // Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to // the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the // per-host session cookie. Mounted before the page router. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 3cd3efb..89bfa27 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxy-api", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxy-api", - "version": "1.2.2", + "version": "1.3.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", @@ -20,7 +20,7 @@ "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", "compression": "^1.8.1", - "ejs": "^6.0.1", + "ejs": "^3.1.10", "express": "^5.2.1", "express-rate-limit": "^8.5.2", "extend": "^3.0.2", @@ -436,6 +436,12 @@ "node": ">=12.0.0" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -832,15 +838,18 @@ "license": "MIT" }, "node_modules/ejs": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", - "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, "bin": { "ejs": "bin/cli.js" }, "engines": { - "node": ">=0.12.18" + "node": ">=0.10.0" } }, "node_modules/encodeurl": { @@ -1052,6 +1061,42 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1428,6 +1473,23 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jq-repeat": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz", @@ -1734,6 +1796,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 4e94f99..d089908 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -30,7 +30,7 @@ "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", "compression": "^1.8.1", - "ejs": "^6.0.1", + "ejs": "^3.1.10", "express": "^5.2.1", "express-rate-limit": "^8.5.2", "extend": "^3.0.2", diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 804357f..a34ad78 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -18,3 +18,7 @@ body { .card-title{ font-weight: bold; } + +.group-required{ + display: none; +} diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 1463c93..2b323a3 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -1,3 +1,12 @@ +// Shared client framework for the theta42 apps. +// +// This file is byte-identical across sso-manager-node, proxy and jump-host — +// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and +// the /api/user/me response), never from edits to this file. Edit all three +// copies together. +// +// jQuery 4 safe: no $.isFunction, no $.holdReady. + var app = {}; app.pubsub = (function(){ @@ -45,7 +54,7 @@ app.pubsub = (function(){ app.socket = (function(app){ // $.getScript('/socket.io/socket.io.js') // - + var socket; $(document).ready(function(){ socket = io({ @@ -75,10 +84,26 @@ app.socket = (function(app){ app.api = (function(app){ var baseURL = '/api/' - function post(url, data, callback){ - if(typeof callback !== 'function') callback = callback2; + // post/put/delete are dual-mode: pass a callback for the node-style + // (error, data, status) form, or omit it to get a Promise that resolves + // with the parsed body and rejects with the error body. get/options return + // the jqXHR, which is itself thenable, so `await app.api.get(...)` works. + + function body(method, url, data, callback){ + if(typeof callback !== 'function'){ + return new Promise(function(resolve, reject){ + $.ajax({ + type: method, + url: baseURL+url, + headers: { 'auth-token': app.auth.getToken() }, + data: JSON.stringify(data), + contentType: 'application/json; charset=utf-8', + dataType: 'json', + }).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); }); + }); + } return $.ajax({ - type: 'POST', + type: method, url: baseURL+url, headers:{ 'auth-token': app.auth.getToken() @@ -87,40 +112,37 @@ app.api = (function(app){ contentType: "application/json; charset=utf-8", dataType: "json", complete: function(res, text){ - callback ? callback( + callback( text !== 'success' ? res.statusText : null, JSON.parse(res.responseText), res.status - ) : function(){} + ); } }); } + function post(url, data, callback){ + return body('POST', url, data, callback); + } + function put(url, data, callback){ - if(typeof callback !== 'function') callback = callback2; - return $.ajax({ - type: 'PUT', - url: baseURL+url, - headers:{ - 'auth-token': app.auth.getToken() - }, - data: JSON.stringify(data), - contentType: "application/json; charset=utf-8", - dataType: "json", - complete: function(res, text){ - callback ? callback( - text !== 'success' ? res.statusText : null, - JSON.parse(res.responseText), - res.status - ) : function(){} - } - }); + return body('PUT', url, data, callback); } - function remove(url, callback, callback2){ - if(typeof callback !== 'function') callback = callback2; + function remove(url, callback){ + if(typeof callback !== 'function'){ + return new Promise(function(resolve, reject){ + $.ajax({ + type: 'DELETE', + url: baseURL+url, + headers: { 'auth-token': app.auth.getToken() }, + contentType: 'application/json; charset=utf-8', + dataType: 'json', + }).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); }); + }); + } return $.ajax({ - type: 'delete', + type: 'DELETE', url: baseURL+url, headers:{ 'auth-token': app.auth.getToken() @@ -128,11 +150,11 @@ app.api = (function(app){ contentType: "application/json; charset=utf-8", dataType: "json", complete: function(res, text){ - callback ? callback( + callback( text !== 'success' ? res.statusText : null, JSON.parse(res.responseText), res.status - ) : function(){} + ); } }); } @@ -179,7 +201,11 @@ app.api = (function(app){ })(app) app.auth = (function(app){ - var user = {} + // One in-flight/cached GET /api/user/me per page load. Every gating + // decision (nav items, per-view forceLogin, group-required elements) reads + // this same promise instead of re-fetching. + var userPromise = null; + function setToken(token){ localStorage.setItem('APIToken', token); } @@ -188,18 +214,95 @@ app.auth = (function(app){ return localStorage.getItem('APIToken'); } - function isLoggedIn(callback){ - if(getToken()){ - return app.api.get('user/me', function(error, data){ - // data now carries effective rights (isAdmin, global, domains). - if(!error) app.auth.user = app.auth.perms = data; - return callback(error, data); - }); - }else{ - callback(null, false); + async function getUser(){ + try{ + return await app.api.get('user/me'); + }catch(error){ + if(error && error.status === 401) return null; + throw error; } } + // Cached current user, or false when there's no token at all. Callers that + // need a fresh copy (after a login or a profile change) pass force. + function loadUser(force){ + if(force || !userPromise){ + userPromise = getToken() ? getUser() : Promise.resolve(null); + userPromise = userPromise.then(function(user){ + app.auth.user = app.auth.perms = user || null; + return user; + }); + } + return userPromise; + } + + // The apps report group membership two ways: sso-manager-node returns LDAP + // DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both + // normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag) + // is exposed as a synthetic `admin` group so one gating model covers both. + function groupCNs(user){ + var raw = (user && (user.memberOf || user.groups)) || []; + if(!Array.isArray(raw)) raw = [raw]; + var names = raw.map(function(group){ + return String(group).split(',')[0].replace(/^cn=/i, ''); + }); + if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin'); + return names; + } + + async function memberOf(groupNameToFind, user){ + user = user || await loadUser(); + if(!user) return false; + groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]; + + return groupCNs(user).some(function(group){ + return groupNameToFind.includes(group); + }); + } + + // True when the logged-in user is a global admin (per user/me). Sync — only + // meaningful once isLoggedIn/forceLogin has resolved. + function isAdmin(){ + return !!(app.auth.perms && app.auth.perms.isAdmin); + } + + // Dual-mode: returns a Promise resolving to the user (or false), and calls + // an optional node-style callback with the same result. + function isLoggedIn(callback){ + var promise = loadUser().then(function(user){ + return user || false; + }); + + if(typeof callback === 'function'){ + promise.then(function(user){ + callback(null, user); + }, function(error){ + callback(error, false); + }); + } + + return promise; + } + + function logIn(args, callback){ + app.api.post('auth/login', args, function(error, data){ + if(data.login){ + setToken(data.token); + } + loadUser(true); + callback(error, !!data.token); + }); + } + + // Clears the session only — the caller decides where to go next (the nav's + // Log Out button uses ui.logoutRedirect). + function logOut(callback){ + localStorage.removeItem('APIToken'); + userPromise = null; + app.auth.user = app.auth.perms = null; + if(typeof callback === 'function') callback(); + } + // Constrain a redirect target to a same-origin absolute path. Rejects // absolute URLs (open redirect), protocol-relative "//host" and "/\host", // and non-path schemes like "javascript:" (XSS). Falls back to "/". @@ -230,46 +333,66 @@ app.auth = (function(app){ return true; } - // True when the logged-in user is a global admin (per user/me). - function isAdmin(){ - return !!(app.auth.perms && app.auth.perms.isAdmin); - } - - function logIn(args, callback){ - app.api.post('auth/login', args, function(error, data){ - if(data.login){ - setToken(data.token); - } - callback(error, !!data.token); - }); - } - - function logOut(callback){ - localStorage.removeItem('APIToken'); - callback(); - } - - function forceLogin(){ - // jQuery 4 removed $.holdReady; rely on the redirect below to keep an - // unauthenticated user off the page instead of pausing document ready. - app.auth.isLoggedIn(function(error, isLoggedIn){ - if(error || !isLoggedIn){ - app.auth.logOut(function(){}) - location.replace(`/login${location.href.replace(location.origin, '')}`); - } - }); + // Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or + // unauthorised user is kept off the page by a redirect / an error panel + // rather than by pausing document ready. + // + // `requiredGroups` is a group CN or an OR-list of them; the synthetic + // `admin` group covers the OIDC clients' isAdmin flag. + async function forceLogin(requiredGroups){ + var user = await loadUser(); + + if(!user){ + logOut(function(){}); + location.replace('/login?redirect=' + encodeURIComponent( + location.pathname + location.search + )); + return false; + } + + if(user.onboardingRequired && location.pathname !== '/onboarding'){ + location.replace('/onboarding'); + return false; + } + + if(requiredGroups && !await memberOf(requiredGroups, user)){ + app.util.actionMessage( + `

+ + You do not have permission to be here. + +

`, + $('#spa-shell'), + 'danger', + ); + throw new Error("User does not have permission"); + } + + return user; } + // Where to go after a successful login: the ?redirect= query param, or the + // legacy /login/ suffix form, constrained to a same-origin path. function logInRedirect(){ - window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/') + var params = new URLSearchParams(location.search); + var target = params.get('redirect') + || location.href.replace(location.origin + '/login', '').split('?')[0] + || '/'; + window.location.href = safeInternalPath(target); } return { getToken: getToken, setToken: setToken, - isLoggedIn: isLoggedIn, - consumeTokenFragment: consumeTokenFragment, + getUser: getUser, + loadUser: loadUser, + groupCNs: groupCNs, + memberOf: memberOf, isAdmin: isAdmin, + isLoggedIn: isLoggedIn, + safeInternalPath: safeInternalPath, + consumeTokenFragment: consumeTokenFragment, + user: null, perms: null, logIn: logIn, logOut: logOut, @@ -279,6 +402,11 @@ app.auth = (function(app){ })(app); +// Back-compat alias for views that awaited the cached user directly. +Object.defineProperty(app.auth, 'asyncUser', { + get: function(){ return app.auth.loadUser(); }, +}); + app.user = (function(app){ function list(callback){ app.api.get('user/?detail=true', function(error, data){ @@ -308,6 +436,8 @@ app.user = (function(app){ })(app); +// Local (app-managed) permissions and groups. Only the OIDC-client apps serve +// these endpoints; the calls are inert elsewhere. app.permission = (function(app){ function list(callback){ app.api.get('permission/', function(error, data){ @@ -381,9 +511,12 @@ app.util = (function(app){ return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); }; - function actionMessage(message, $target, type, callback){ + function actionMessage(message, $targetPassed, type, callback){ message = message || ''; - $target = $target.closest('div.card').find('.actionMessage'); + + let $target = $targetPassed.closest('div.card').find('.actionMessage'); + if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]); + type = type || 'info'; callback = callback || function(){}; @@ -400,12 +533,48 @@ app.util = (function(app){ }) }else{ if(type) $target.addClass('bg-' + type); - message = '' + message + '' + + // Messages that bring their own buttons (actionConfirm) are left + // alone; everything else gets the standard dismiss button. + if(!message.includes('${message} + + ` $target.html(message).slideDown('fast'); } setTimeout(callback,10) } + function actionConfirm(message, $target, type, callback){ + return new Promise((resolve, reject) =>{ + let id = crypto.randomUUID(); + message = ` +

+ + ${message} + + + + +

+ ` + actionMessage(message, $target, type); + $("body").on('click', `.confirm-${id}`, function(){ + actionMessage('', $target, type); + resolve(!!$(this).data('confirm')); + }); + }); + + } + $.fn.serializeObject = function() { var obj = {}; @@ -462,11 +631,42 @@ app.util = (function(app){ return { downloadFile: downloadFile, getUrlParameter: getUrlParameter, - actionMessage: actionMessage + actionMessage: actionMessage, + actionConfirm, } })(app); -$( document ).ready(function(){ +// Reveal every .group-required- element the current user's groups entitle +// them to. Elements carrying .group-required start hidden (styles.css), so a +// user who is in no groups — or who isn't logged in — simply never sees them. +app.auth.applyGroupVisibility = function(user){ + var groups = app.auth.groupCNs(user); + if(!groups.length) return; + + var style = document.getElementById('group-required-rules'); + if(!style){ + style = document.createElement('style'); + style.id = 'group-required-rules'; + document.head.appendChild(style); + } + + for(var group of groups){ + try{ + style.sheet.insertRule( + `.group-required-${CSS.escape(group)} { display: revert !important; }`, + style.sheet.cssRules.length + ); + }catch(error){ + // A group whose CN isn't a usable CSS identifier just gates nothing. + } + } +}; + +$( document ).ready(async function(){ + + // Show content the user's groups entitle them to. + app.auth.applyGroupVisibility(await app.auth.loadUser()); + $('div.row').fadeIn('slow'); //show the page //panel button's @@ -520,12 +720,14 @@ function formAJAX(btn){ var method = ($form.attr('method') || 'post').toLowerCase(); if($form.validate && !$form.validate()){ - app.util.actionMessage('Please fix the form errors.', $form, 'danger'); + app.util.actionMessage('Please fix the form errors.', $form, 'danger') return false; } - - app.util.actionMessage( - '
Loading...
', + + app.util.actionMessage( + `
+ Loading... +
`, $form, 'info' ); diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js new file mode 100644 index 0000000..f31edf2 --- /dev/null +++ b/nodejs/utils/ui.js @@ -0,0 +1,45 @@ +'use strict'; + +// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs). +// +// Those two partials are byte-identical across sso-manager-node, proxy and +// jump-host — everything that differs between the apps lives here and is +// exposed to every render as `ui` via app.locals (see app.js). Keep the key set +// in sync across the three apps; a missing key is a render-time ReferenceError, +// not a silent fallback. + +module.exports = { + // --- footer ------------------------------------------------------------- + repoUrl: 'https://github.com/theta42/proxy', + licenseUrl: 'https://github.com/theta42/proxy/blob/master/LICENSE', + // In-app docs route (routes/docs.js). Apps without one point at the + // published docs site and set docsExternal. + docsUrl: '/docs', + docsExternal: false, + // Only sso-manager-node serves a Terms of Service page; null hides the link. + tosUrl: null, + + // --- header / nav ------------------------------------------------------- + faviconUrl: '/static/favicon.svg', + // Where the current-user chip links. null renders it as a plain span (for + // apps with no profile page). + profileUrl: '/profile', + // Where "Log Out" lands. + logoutRedirect: '/', + // Admin-only "a newer release is available" banner, backed by + // GET /api/update-check. Apps without that endpoint set false. + updateCheck: true, + updateLabel: 'the proxy', + + // Nav items, in order. `groups` is an OR-list of group CNs that may see the + // item; an empty list means "always visible". Gating is done client-side by + // app-base.js, which reveals .group-required- for each group the user is + // in (plus the synthetic `admin` group when user/me reports isAdmin). + nav: [ + {href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []}, + {href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: []}, + {href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['admin']}, + {href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']}, + {href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']}, + ], +}; diff --git a/nodejs/views/bottom.ejs b/nodejs/views/bottom.ejs index 905359d..a2dadd8 100755 --- a/nodejs/views/bottom.ejs +++ b/nodejs/views/bottom.ejs @@ -1,24 +1,30 @@ - + - - - \ No newline at end of file + + + + diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index 75cf0dd..cc3c1cf 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -4,8 +4,11 @@ <%- name %> <%- title %> + - + @@ -14,8 +17,6 @@ - - @@ -28,49 +29,38 @@ + <% if(ui.updateCheck){ %> -
\ No newline at end of file +
+ From a83a5fd39a4479592e978c109e56bb5f2f0b6a94 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 25 Jul 2026 23:15:07 -0400 Subject: [PATCH 2/4] app.api.delete: accept the (url, data, callback) form formAJAX uses; defer the login-card reveal to DOM ready formAJAX always passes the serialized form as the second argument, so a DELETE-method form (the host/DNS delete buttons) landed its callback in the data slot and never ran. The login page's "reveal the card once we know you're logged out" branch touched an element further down the same page, which threw when isLoggedIn answered before the parser got there (it always did without a stored token). It now runs on DOM ready. Co-Authored-By: Claude Opus 5 --- nodejs/public/lib/js/app-base.js | 9 ++++++++- nodejs/views/login.ejs | 20 ++++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 2b323a3..d5fff48 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -129,7 +129,14 @@ app.api = (function(app){ return body('PUT', url, data, callback); } - function remove(url, callback){ + // Called both as (url, callback) and — from formAJAX, which always passes + // the serialized form as the second argument — as (url, data, callback). + // No request body is sent either way. + function remove(url, data, callback){ + if(typeof data === 'function'){ + callback = data; + data = undefined; + } if(typeof callback !== 'function'){ return new Promise(function(resolve, reject){ $.ajax({ diff --git a/nodejs/views/login.ejs b/nodejs/views/login.ejs index 85b9c97..9bd894d 100755 --- a/nodejs/views/login.ejs +++ b/nodejs/views/login.ejs @@ -4,14 +4,18 @@ // If we arrived from the OIDC callback with a token in the URL fragment, // store it and forward on before doing anything else. if(!app.auth.consumeTokenFragment()){ - app.auth.isLoggedIn(function(error, isLoggedIn){ - if(isLoggedIn){ - app.auth.logInRedirect(); - }else{ - // Reveal the login card once we know the user is not logged in. - document.getElementById('login-card-row').style.display = ''; - } - }) + // The reveal below touches an element further down this page, so wait + // for the DOM — isLoggedIn can answer before the parser gets there. + $(document).ready(function(){ + app.auth.isLoggedIn(function(error, isLoggedIn){ + if(isLoggedIn){ + app.auth.logInRedirect(); + }else{ + // Reveal the login card once we know the user is not logged in. + document.getElementById('login-card-row').style.display = ''; + } + }); + }); } From fcc09d1e4b10e56caec94ea4cc34a9c2f99e7f3f Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 25 Jul 2026 23:55:08 -0400 Subject: [PATCH 3/4] logInRedirect: keep the query string on the legacy /login/ form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC provider sends an unauthenticated authorize request through /login/oauth/authorize?client_id=…&state=…; dropping the query there loses the whole authorization request. The ?redirect= form is unaffected. Co-Authored-By: Claude Opus 5 --- nodejs/public/lib/js/app-base.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index d5fff48..7aa874d 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -379,11 +379,13 @@ app.auth = (function(app){ } // Where to go after a successful login: the ?redirect= query param, or the - // legacy /login/ suffix form, constrained to a same-origin path. + // legacy /login/ suffix form, constrained to a same-origin path. The + // suffix form keeps its query string — /login/oauth/authorize?client_id=… + // is how the OIDC provider sends an unauthenticated user through login. function logInRedirect(){ var params = new URLSearchParams(location.search); var target = params.get('redirect') - || location.href.replace(location.origin + '/login', '').split('?')[0] + || location.href.replace(location.origin + '/login', '') || '/'; window.location.href = safeInternalPath(target); } From 2e959f7c48cee8507e16867624633234aebc65d7 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sun, 26 Jul 2026 00:21:51 -0400 Subject: [PATCH 4/4] Release 1.4.0: unified front-end UI shell across the theta42 apps Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 23 +++++++++++++++++++++++ nodejs/package-lock.json | 4 ++-- nodejs/package.json | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e45db3..3acb0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. ## [Unreleased] +## [1.4.0] - 2026-07-26 + +### Changed +- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all. +- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape. +- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request. +- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`. +- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`). +- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`. + +### Fixed +- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`. +- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback. +- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready. +- **`logInRedirect` on the legacy `/login/` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string. + +### Added +- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against. +- Admin-only nav items lost their inline `display: none` in favour of that class, and the brand link points at `/` instead of `#`. + +### Verified +- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients. + ## [1.3.0] - 2026-07-25 ### Added diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 89bfa27..399d994 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxy-api", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxy-api", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index d089908..8afc018 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "proxy-api", - "version": "1.3.0", + "version": "1.4.0", "author": [ { "name": "William Mantly",