Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59ae30897b | |||
| 94a7e07410 | |||
| a5de279bb4 | |||
| d8b6f6e7a3 | |||
| 208762f0d1 | |||
| b076498219 |
@@ -4,6 +4,32 @@ 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.5.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-<cn>` 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=<path>`; 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/<path>` 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.
|
||||||
|
|
||||||
|
### Fixed (sso-manager-node)
|
||||||
|
- `public/lib/js/val.js` shadowed `message` with `let` inside `validateField`, so a custom rule's return value never reached `validateMessage` and the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings the `target`/`hostname` rules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app.
|
||||||
|
- `public/js/app.js` used `$.isFunction`, removed in jQuery 4.
|
||||||
|
|
||||||
|
### Added (sso-manager-node)
|
||||||
|
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
|
||||||
|
|
||||||
|
### 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.4.0] - 2026-07-25
|
## [1.4.0] - 2026-07-25
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ app.set('trust proxy', 1);
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
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');
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
// Have express server static content( images, CSS, browser JS) from the public
|
||||||
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
||||||
// changes on every deploy and isn't cache-busted/fingerprinted.
|
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.2",
|
"version": "1.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.2",
|
"version": "1.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
@@ -4853,9 +4853,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jquery": {
|
"node_modules/jquery": {
|
||||||
"version": "3.7.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
|
||||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ app.impersonate = (function(app){
|
|||||||
|
|
||||||
app.token = (function(app){
|
app.token = (function(app){
|
||||||
function list(name, callack){
|
function list(name, callack){
|
||||||
if($.isFunction(name)){
|
if(typeof name === 'function'){
|
||||||
callack = name;
|
callack = name;
|
||||||
name = '';
|
name = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -45,7 +54,7 @@ app.pubsub = (function(){
|
|||||||
app.socket = (function(app){
|
app.socket = (function(app){
|
||||||
// $.getScript('/socket.io/socket.io.js')
|
// $.getScript('/socket.io/socket.io.js')
|
||||||
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
var socket;
|
var socket;
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
socket = io({
|
socket = io({
|
||||||
@@ -75,11 +84,17 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if (!$.isFunction(callback)) {
|
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||||
return new Promise((resolve, reject) => {
|
// 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({
|
$.ajax({
|
||||||
type: 'POST', url: baseURL+url,
|
type: method,
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
@@ -88,9 +103,11 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
@@ -104,40 +121,27 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
function put(url, data, callback){
|
function put(url, data, callback){
|
||||||
if (!$.isFunction(callback)) {
|
return body('PUT', url, data, callback);
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
$.ajax({
|
|
||||||
type: 'PUT', 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: '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(
|
|
||||||
text !== 'success' ? res.statusText : null,
|
|
||||||
JSON.parse(res.responseText),
|
|
||||||
res.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(url, callback){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if (!$.isFunction(callback)) {
|
// the serialized form as the second argument — as (url, data, callback).
|
||||||
return new Promise((resolve, reject) => {
|
// 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({
|
$.ajax({
|
||||||
type: 'DELETE', url: baseURL+url,
|
type: 'DELETE',
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@@ -147,7 +151,9 @@ app.api = (function(app){
|
|||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'DELETE',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
@@ -202,7 +208,10 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(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){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
@@ -216,35 +225,70 @@ app.auth = (function(app){
|
|||||||
try{
|
try{
|
||||||
return await app.api.get('user/me');
|
return await app.api.get('user/me');
|
||||||
}catch(error){
|
}catch(error){
|
||||||
if(error?.status === 401) return null;
|
if(error && error.status === 401) return null;
|
||||||
throw error
|
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){
|
async function memberOf(groupNameToFind, user){
|
||||||
try{
|
user = user || await loadUser();
|
||||||
user = user || await app.auth.asyncUser;
|
if(!user) return false;
|
||||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]
|
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||||
|
|
||||||
for(let group of user.memberOf){
|
return groupCNs(user).some(function(group){
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
return groupNameToFind.includes(group);
|
||||||
if(groupNameToFind.includes(group)) return true;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}catch(error){
|
|
||||||
throw(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isLoggedIn(){
|
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||||
if(getToken()){
|
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||||
user = await app.auth.asyncUser;
|
function isAdmin(){
|
||||||
return user;
|
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||||
}else{
|
}
|
||||||
return false;
|
|
||||||
|
// 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){
|
function logIn(args, callback){
|
||||||
@@ -252,62 +296,125 @@ app.auth = (function(app){
|
|||||||
if(data.login){
|
if(data.login){
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
}
|
}
|
||||||
|
loadUser(true);
|
||||||
callback(error, !!data.token);
|
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){
|
function logOut(callback){
|
||||||
localStorage.removeItem('APIToken');
|
localStorage.removeItem('APIToken');
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
userPromise = null;
|
||||||
callback();
|
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 "/".
|
||||||
|
function safeInternalPath(path){
|
||||||
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||||
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an app token handed back by the OIDC callback via the URL
|
||||||
|
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
||||||
|
// forwards to the intended page. Returns true if a token was consumed.
|
||||||
|
function consumeTokenFragment(){
|
||||||
|
if(!location.hash) return false;
|
||||||
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||||
|
var token = params.get('token');
|
||||||
|
if(!token) return false;
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
// redirect comes from the URL fragment (attacker-controllable); only
|
||||||
|
// allow a same-origin path so it can't become an open redirect / XSS.
|
||||||
|
var redirect = safeInternalPath(params.get('redirect') || '/');
|
||||||
|
// Drop the token from the address bar before navigating on.
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
window.location.href = redirect;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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){
|
async function forceLogin(requiredGroups){
|
||||||
$.holdReady(true);
|
var user = await loadUser();
|
||||||
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
|
|
||||||
|
if(!user){
|
||||||
|
logOut(function(){});
|
||||||
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
|
location.pathname + location.search
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
location.replace('/onboarding');
|
location.replace('/onboarding');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(requiredGroups){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(!await memberOf(requiredGroups)){
|
app.util.actionMessage(
|
||||||
console.log("Does not have permission!!!")
|
`<h1>
|
||||||
app.util.actionMessage(
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
`<h1>
|
<b>You do not have permission to be here.</b>
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<b>You do not have permission to be here.</b>
|
</h1>`,
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
$('#spa-shell'),
|
||||||
</h1>`,
|
'danger',
|
||||||
$('#spa-shell'),
|
);
|
||||||
'danger',
|
throw new Error("User does not have permission");
|
||||||
);
|
|
||||||
throw new Error("User does not have permission");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.holdReady(false);
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where to go after a successful login: the ?redirect= query param, or the
|
||||||
|
// legacy /login/<path> 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(){
|
function logInRedirect(){
|
||||||
window.location.href = 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 {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
|
getUser: getUser,
|
||||||
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
|
isAdmin: isAdmin,
|
||||||
isLoggedIn: isLoggedIn,
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
logInRedirect,
|
logInRedirect,
|
||||||
getUser,
|
|
||||||
memberOf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
app.auth.asyncUser = app.auth.getUser();
|
|
||||||
|
|
||||||
|
// 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){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
@@ -338,6 +445,72 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(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){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjects(callback){
|
||||||
|
app.api.get('permission/subjects', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('permission/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, callback){
|
||||||
|
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, subjects, add, remove};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.group = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('group/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('group/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(name, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember(name, username, callback){
|
||||||
|
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMember(name, username, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, add, remove, addMember, removeMember};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
app.util = (function(app){
|
app.util = (function(app){
|
||||||
|
|
||||||
function getUrlParameter(name){
|
function getUrlParameter(name){
|
||||||
@@ -370,7 +543,10 @@ app.util = (function(app){
|
|||||||
}else{
|
}else{
|
||||||
if(type) $target.addClass('bg-' + type);
|
if(type) $target.addClass('bg-' + type);
|
||||||
|
|
||||||
if(!message.includes('<button')) message += `
|
// Messages that bring their own buttons (actionConfirm) are left
|
||||||
|
// alone; everything else gets the standard dismiss button.
|
||||||
|
if(!message.includes('<button')) message = `
|
||||||
|
<span class="align-middle">${message}</span>
|
||||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-xmark"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -415,8 +591,11 @@ app.util = (function(app){
|
|||||||
for (let {name, value} of $(this).serializeArray()) {
|
for (let {name, value} of $(this).serializeArray()) {
|
||||||
console.log(name, value)
|
console.log(name, value)
|
||||||
if (obj[name] === undefined) {
|
if (obj[name] === undefined) {
|
||||||
if (!value
|
if (!value
|
||||||
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
||||||
|
// Keep empty <textarea>s so a cleared field is submitted (and
|
||||||
|
// can reset a list, e.g. the per-host IP/header controls).
|
||||||
|
&& !$(this).filter(`textarea[name="${name}"]`).length
|
||||||
){
|
){
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -466,21 +645,36 @@ app.util = (function(app){
|
|||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
$( document ).ready(async function(){
|
// Reveal every .group-required-<cn> 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;
|
||||||
|
|
||||||
// Show content if the user has the correct group
|
var style = document.getElementById('group-required-rules');
|
||||||
for(let group of (await app.auth.asyncUser)?.memberOf || []){
|
if(!style){
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'group-required-rules';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var group of groups){
|
||||||
try{
|
try{
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
style.sheet.insertRule(
|
||||||
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||||
const sheet = document.styleSheets[0];
|
style.sheet.cssRules.length
|
||||||
const selector = `.group-required-${group}`;
|
);
|
||||||
const cssText = `${selector} { display: revert !important; }`;
|
|
||||||
sheet.insertRule(cssText, sheet.cssRules.length);
|
|
||||||
}catch(error){
|
}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
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
@@ -538,8 +732,8 @@ function formAJAX(btn){
|
|||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
app.util.actionMessage(
|
||||||
`<div class="spinner-border" role="status">
|
`<div class="spinner-border" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -567,4 +761,3 @@ function formAJAX(btn){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+101
-33
@@ -61,7 +61,7 @@
|
|||||||
//checks if empty to stop processing
|
//checks if empty to stop processing
|
||||||
if(!isNaN(options) && value.length === 0) {
|
if(!isNaN(options) && value.length === 0) {
|
||||||
}else if(rule in settings.rule){
|
}else if(rule in settings.rule){
|
||||||
let message = settings.rule[rule].apply(this, [value, options]);
|
message = settings.rule[rule].apply(this, [value, options]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.validateMessage(message)
|
this.validateMessage(message)
|
||||||
@@ -93,41 +93,109 @@
|
|||||||
|
|
||||||
}( jQuery ));
|
}( jQuery ));
|
||||||
|
|
||||||
$.validateSettings({
|
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
|
||||||
rule:{
|
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
|
||||||
ip: function( value ) {
|
// incoming host may be a wildcard ("*.example.com"); the target may not.
|
||||||
value = value.split( '.' );
|
(function(){
|
||||||
|
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
||||||
if ( value.length != 4 ) {
|
// Either one bare label (Docker service names, /etc/hosts entries) or a
|
||||||
return "Malformed IP";
|
// dotted hostname with an alphabetic TLD.
|
||||||
}
|
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
|
||||||
|
var FORBIDDEN = /[\s/:]/;
|
||||||
$.each( value, function( key, value ) {
|
|
||||||
if( value > 255 || value < 0 ) {
|
function isIPv4( value ) {
|
||||||
|
var parts = value.split( '.' );
|
||||||
|
if ( parts.length !== 4 ) return false;
|
||||||
|
return parts.every( function( p ) {
|
||||||
|
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
|
||||||
|
// (any number of fragments, incl. a bare "**" global catch-all).
|
||||||
|
function isHostPattern( value ) {
|
||||||
|
if ( value.length > 253 ) return false;
|
||||||
|
return value.split( '.' ).every( function( l ) {
|
||||||
|
return l === '*' || l === '**' || LABEL.test( l );
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function forbidden( value ) {
|
||||||
|
return FORBIDDEN.test( value ) || value.includes( '://' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming host: IPv4 or a wildcard host pattern.
|
||||||
|
function checkHost( value ) {
|
||||||
|
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
||||||
|
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
||||||
|
if ( isIPv4( value ) || isHostPattern( value ) ) return;
|
||||||
|
return "Enter a valid host or wildcard (*, **)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downstream target: IPv4 or a strict hostname, no wildcard.
|
||||||
|
function checkTarget( value ) {
|
||||||
|
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
||||||
|
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
||||||
|
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
|
||||||
|
return "Enter a valid hostname or IP";
|
||||||
|
}
|
||||||
|
|
||||||
|
$.validateSettings({
|
||||||
|
rule:{
|
||||||
|
ip: function( value ) {
|
||||||
|
value = value.split( '.' );
|
||||||
|
|
||||||
|
if ( value.length != 4 ) {
|
||||||
return "Malformed IP";
|
return "Malformed IP";
|
||||||
}
|
}
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
host: function( value ) {
|
$.each( value, function( key, value ) {
|
||||||
var reg = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/;
|
if( value > 255 || value < 0 ) {
|
||||||
if ( reg.test( value ) === false ) {
|
return "Malformed IP";
|
||||||
return "Invalid";
|
}
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
user: function( value ) {
|
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
|
||||||
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
host: function( value ) {
|
||||||
if ( reg.test( value ) === false ) {
|
return checkHost( value );
|
||||||
return "Invalid";
|
},
|
||||||
}
|
|
||||||
},
|
// Downstream target — hostname or IPv4, no wildcard.
|
||||||
|
target: function( value ) {
|
||||||
password: function( value ) {
|
return checkTarget( value );
|
||||||
var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/;
|
},
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Weak password, Try again";
|
// Back-compat alias (no wildcard).
|
||||||
|
hostname: function( value ) {
|
||||||
|
return checkTarget( value );
|
||||||
|
},
|
||||||
|
|
||||||
|
user: function( value ) {
|
||||||
|
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
||||||
|
if ( reg.test( value ) === false ) {
|
||||||
|
return "Invalid";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
|
||||||
|
// or at least 3 of {lowercase, uppercase, number, symbol}.
|
||||||
|
password: function( value ) {
|
||||||
|
if ( typeof value !== 'string' || value.length < 8 ) {
|
||||||
|
return "Password must be at least 8 characters";
|
||||||
|
}
|
||||||
|
if ( value.length >= 12 ) return;
|
||||||
|
|
||||||
|
var classes = 0;
|
||||||
|
if ( /[a-z]/.test( value ) ) classes++;
|
||||||
|
if ( /[A-Z]/.test( value ) ) classes++;
|
||||||
|
if ( /[0-9]/.test( value ) ) classes++;
|
||||||
|
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
|
||||||
|
|
||||||
|
if ( classes < 3 ) {
|
||||||
|
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
})();
|
||||||
+11
-1
@@ -74,7 +74,17 @@ router.delete('/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/me', async function(req, res, next){
|
router.get('/me', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
return res.json(await User.get({uid: req.user.uid}));
|
const user = JSON.parse(JSON.stringify(await User.get({uid: req.user.uid})));
|
||||||
|
|
||||||
|
// The shared client framework gates the UI on a single effective-rights
|
||||||
|
// flag (the OIDC-client apps send the same key). Here "admin" means
|
||||||
|
// membership in app_sso_admin; group-level gating still reads memberOf.
|
||||||
|
const groups = (user.memberOf || []).map(function(dn){
|
||||||
|
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
});
|
||||||
|
user.isAdmin = groups.includes('app_sso_admin');
|
||||||
|
|
||||||
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'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.
|
||||||
|
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// --- footer -------------------------------------------------------------
|
||||||
|
repoUrl: 'https://github.com/theta42/sso-manager-node',
|
||||||
|
licenseUrl: 'https://github.com/theta42/sso-manager-node/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: '/tos',
|
||||||
|
|
||||||
|
// --- header / nav -------------------------------------------------------
|
||||||
|
faviconUrl: conf.logo,
|
||||||
|
// 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: 'SSO Manager',
|
||||||
|
|
||||||
|
// 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-<cn> for each group the user is
|
||||||
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
|
nav: [
|
||||||
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin']},
|
||||||
|
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin']},
|
||||||
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin']},
|
||||||
|
{href: '/executive', icon: 'fa-solid fa-gauge-high', label: 'Executive', groups: ['app_sso_admin']},
|
||||||
|
],
|
||||||
|
};
|
||||||
+10
-5
@@ -1,5 +1,8 @@
|
|||||||
</div><!-- end spa-shell -->
|
</div><!-- end spa-shell -->
|
||||||
|
|
||||||
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
<footer class="py-2 bg-dark text-light mt-4">
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
<span class="d-flex align-items-center gap-2">
|
<span class="d-flex align-items-center gap-2">
|
||||||
@@ -7,19 +10,21 @@
|
|||||||
<img width="64" src="/static/img/theta42.svg"/>
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
</a>
|
</a>
|
||||||
© <%- buildYear %> theta42 ·
|
© <%- buildYear %> theta42 ·
|
||||||
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="d-flex align-items-center gap-3">
|
<span class="d-flex align-items-center gap-3">
|
||||||
<a href="/docs" class="text-light text-decoration-none">
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
<i class="fa-solid fa-book"></i> Docs
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</a>
|
</a>
|
||||||
<a href="/tos" class="text-light text-decoration-none">Terms of Service</a>
|
<% if(ui.tosUrl){ %>
|
||||||
|
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||||
|
<% } %>
|
||||||
</span>
|
</span>
|
||||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+143
-132
@@ -1,138 +1,149 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title><%- name %> <%- title %></title>
|
<title><%- name %> <%- title %></title>
|
||||||
<!-- Favicon -->
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
<link rel="icon" type="image/svg+xml" href="<%- logo %>">
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
|
||||||
<!-- CSS are placed here -->
|
via app.locals in app.js). Edit all three copies together. -->
|
||||||
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
<!-- Favicon -->
|
||||||
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
|
||||||
|
<!-- CSS are placed here -->
|
||||||
|
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||||
|
|
||||||
<link rel='stylesheet' href='/static/css/styles.css' />
|
<link rel='stylesheet' href='/static/css/styles.css' />
|
||||||
<!-- Scripts are placed here -->
|
<!-- Scripts are placed here -->
|
||||||
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
||||||
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
|
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
|
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
||||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
||||||
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||||
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
</head>
|
||||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
<body>
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||||
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav top-nav">
|
<ul class="navbar-nav top-nav">
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<%# Items gated on a group start hidden (.group-required) and are
|
||||||
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
|
revealed by app-base.js for the groups the user is in. %>
|
||||||
Users
|
<% for(const item of ui.nav){ %>
|
||||||
</a>
|
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||||
</li>
|
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<%- item.label %>
|
||||||
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-viewfinder"></i>
|
</a>
|
||||||
Groups
|
</li>
|
||||||
</a>
|
<% } %>
|
||||||
</li>
|
</ul>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_directory_admin">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i>
|
<% if(ui.profileUrl){ %>
|
||||||
Directory
|
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
</a>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</li>
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
|
||||||
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
||||||
|
<i class="fas fa-sign-in"></i>
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
|
||||||
|
<i class="fas fa-sign-out"></i>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||||
|
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
||||||
|
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
||||||
|
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
|
||||||
|
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
|
||||||
|
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
|
||||||
|
<span id="update-banner-text"></span>
|
||||||
|
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
function showUpdateBanner(){
|
||||||
|
let $nav = $('nav.fixed-top');
|
||||||
|
let $banner = $('#update-banner');
|
||||||
|
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||||
|
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissUpdateBanner(){
|
||||||
|
$('#update-banner').hide();
|
||||||
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForUpdate(){
|
||||||
|
if(sessionStorage.getItem('update-banner-dismissed')) return;
|
||||||
|
app.api.get('update-check', function(error, info){
|
||||||
|
if(error || !info || !info.updateAvailable) return;
|
||||||
|
$('#update-banner-text').html(
|
||||||
|
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
|
||||||
|
'(running v' + info.currentVersion + ') — ' +
|
||||||
|
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
||||||
|
);
|
||||||
|
showUpdateBanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(document).ready(function(){
|
||||||
|
|
||||||
|
// Set the correct link to active in the top nav bar
|
||||||
|
$('.top-nav a').each(function(index){
|
||||||
|
let $this = $(this);
|
||||||
|
$this.removeClass('active');
|
||||||
|
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
||||||
|
$this.addClass('active')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set the correct login/logout button, and reveal the current user's
|
||||||
|
// name once we know who they are. Group-gated nav items are revealed
|
||||||
|
// by app-base.js off the same cached user/me.
|
||||||
|
app.auth.isLoggedIn(function(error, me){
|
||||||
|
if(me){
|
||||||
|
$('#cl-logout-button').show();
|
||||||
|
let username = me.uid || me.username;
|
||||||
|
if(username){
|
||||||
|
$('#cl-username-text').text(username);
|
||||||
|
$('#cl-username').css('display', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
if(me.isAdmin) checkForUpdate();
|
||||||
|
<% } %>
|
||||||
|
}else{
|
||||||
|
$('#cl-login-button').show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<!-- Container -->
|
||||||
<a class="nav-link" href="/executive">
|
<div id="spa-shell" class="container-fluid">
|
||||||
<i class="fa-solid fa-gauge-high"></i>
|
<div class="actionMessage" style="display:none;"></div>
|
||||||
Executive
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
|
||||||
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
|
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
|
||||||
</a>
|
|
||||||
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
|
||||||
<i class="fas fa-sign-out"></i>
|
|
||||||
Login
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
|
|
||||||
<i class="fas fa-sign-out"></i>
|
|
||||||
Log Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
|
|
||||||
<span id="update-banner-text"></span>
|
|
||||||
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
function showUpdateBanner(){
|
|
||||||
let $nav = $('nav.fixed-top');
|
|
||||||
let $banner = $('#update-banner');
|
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
|
||||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
|
||||||
}
|
|
||||||
function dismissUpdateBanner(){
|
|
||||||
$('#update-banner').hide();
|
|
||||||
$('#spa-shell').css('margin-top', '');
|
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(async function(){
|
|
||||||
|
|
||||||
// Set the correct link to active in the top nav bar
|
|
||||||
$('.top-nav a').each(function(index){
|
|
||||||
let $this = $(this);
|
|
||||||
$this.removeClass('active');
|
|
||||||
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
|
||||||
$this.addClass('active')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set the correct login/logout button, and reveal the current user's
|
|
||||||
// name (linking to their profile) once we know who they are.
|
|
||||||
var me = await app.auth.isLoggedIn();
|
|
||||||
if(me){
|
|
||||||
$('#cl-logout-button').show();
|
|
||||||
if(me.uid){
|
|
||||||
$('#cl-username-text').text(me.uid);
|
|
||||||
$('#cl-username').css('display', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if(await app.auth.memberOf('app_sso_admin', me) && !sessionStorage.getItem('update-banner-dismissed')){
|
|
||||||
app.api.get('update-check', function(error, info){
|
|
||||||
if(error || !info || !info.updateAvailable) return;
|
|
||||||
$('#update-banner-text').html(
|
|
||||||
'A newer version of SSO Manager is available: <b>v' + info.latestVersion + '</b> ' +
|
|
||||||
'(running v' + info.currentVersion + ') — ' +
|
|
||||||
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
|
||||||
);
|
|
||||||
showUpdateBanner();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
$('#cl-login-button').show();
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Container -->
|
|
||||||
<div id="spa-shell" class="container-fluid">
|
|
||||||
<div class="actionMessage" style="display:none;"></div>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user