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/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..399d994 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxy-api", - "version": "1.2.2", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxy-api", - "version": "1.2.2", + "version": "1.4.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..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", @@ -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..7aa874d 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,44 @@ 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; + // 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({ + 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 +157,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 +208,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 +221,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 +340,68 @@ 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. 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(){ - 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', '') + || '/'; + 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 +411,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 +445,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 +520,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 +542,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 +640,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 +729,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/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 = ''; + } + }); + }); } 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 +
+