Compare commits

...

10 Commits

Author SHA1 Message Date
wmantly 5665504bc1 Merge pull request #106 from theta42/fix/sshpublickey-oauth-parent
Fix sshPublicKey ObjectClassViolationError and blank OAuth parent dropdown
2026-07-26 23:09:57 -04:00
wmantly 2ac1c30112 Fix sshPublicKey ObjectClassViolationError and blank OAuth parent dropdown
- User.update/addSSHkey now ensure the ldapPublicKey objectClass is present
  before writing sshPublicKey, so accounts predating that objectClass
  (e.g. the bootstrap admin) no longer 500 on PUT /api/user/:uid.
- populateHostDropdown in directory.ejs was missing an `oauth` branch,
  leaving the parent-Service picker blank when adding an OAuth Integration.
- Bump to 1.5.1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 22:38:48 -04:00
wmantly 04c18eaf30 Merge pull request #105 from theta42/docs/screenshots-refresh
docs: refresh screenshots for the unified UI
2026-07-26 16:31:46 -04:00
wmantly 6835074b8b docs: refresh screenshots for the unified UI, add directory.png
Screenshots were still showing the pre-unification nav (Dashboard/Sites/
Integrations); replace with the current Users/Groups/Directory/Executive
shell and add a directory.png for the new consolidated inventory page.
Fix a couple of stale "Integrations page" / "Sites" references in the
concept docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:26:11 -04:00
wmantly 59ae30897b Merge pull request #104 from theta42/feature/ui-unification
Release 1.5.0: unified front-end UI shell
2026-07-26 00:30:05 -04:00
wmantly 94a7e07410 Release 1.5.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly a5de279bb4 logInRedirect: keep the query string on the legacy /login/<path> form
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 <noreply@anthropic.com>
2026-07-25 23:55:07 -04:00
wmantly d8b6f6e7a3 app.api.delete: accept the (url, data, callback) form formAJAX uses
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (proxy's host/DNS rows) landed its callback in the
data slot and never ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly 208762f0d1 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-<cn>
  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=<path>, 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.

sso-manager-node specifics:
- val.js adopts the shared superset (adds the target/hostname rules and
  the password policy, and fixes the let-shadowed `message` that stopped
  custom rule messages from reaching validateMessage).
- GET /api/user/me now also reports isAdmin (membership in app_sso_admin).
- public/js/app.js: $.isFunction -> typeof (removed in jQuery 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly b076498219 Merge pull request #103 from theta42/release/v1.4.0
Release 1.4.0
2026-07-25 16:41:49 -04:00
23 changed files with 685 additions and 282 deletions
+32
View File
@@ -4,6 +4,38 @@ 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.1] - 2026-07-27
### Fixed
- **`PUT /api/user/:uid` 500'd with `ObjectClassViolationError` (LDAP `0x41`) when setting `sshPublicKey`** on any account created before the `ldapPublicKey` auxiliary objectClass was added to new-user creation (e.g. the bootstrap `admin` account). `User.update`'s `sshPublicKey` handling and `User.addSSHkey` (`nodejs/models/user_ldap.js`) now add the `ldapPublicKey` objectClass first (ignoring `TypeOrValueExistsError` if already present), the same pattern already used for `dateOfBirth`/`theta42Person`.
- **OAuth Integration parent dropdown was blank.** `populateHostDropdown` in `nodejs/views/directory.ejs` only built options for `kind === 'host'` and `kind === 'service'` — there was no branch for `kind === 'oauth'`, so choosing "OAuth Integration" in the Directory's add-resource modal left the parent-Service picker empty except the placeholder. Added the missing branch.
## [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
+1 -1
View File
@@ -46,7 +46,7 @@ on, just like anyone else's.
A **group** is just a named list of accounts, used to control access. This A **group** is just a named list of accounts, used to control access. This
app has a handful of built-in groups that grant admin powers (e.g. only app has a handful of built-in groups that grant admin powers (e.g. only
people in the `app_sso_admin` group can see the Users/Groups/Integrations people in the `app_sso_admin` group can see the Users/Groups/Directory/Executive
pages at all), but you can also make your own groups for any app you pages at all), but you can also make your own groups for any app you
connect — say, a group listing everyone who should be allowed into your connect — say, a group listing everyone who should be allowed into your
photo server. Once a group exists, add or remove members from the photo server. Once a group exists, add or remove members from the
+1 -1
View File
@@ -28,7 +28,7 @@ what matters practically is the handful of concepts below.
## What's a "client"? ## What's a "client"?
Every app you connect is registered here as a **client** — a single entry Every app you connect is registered here as a **client** — a single entry
on the Integrations page representing that one app. Registering a client in the Directory representing that one app. Registering a client
gives you a **Client ID** and **Client Secret**: think of these like a gives you a **Client ID** and **Client Secret**: think of these like a
username and password, but for the *app itself* rather than for a person. username and password, but for the *app itself* rather than for a person.
You paste them into the other app's own "Single Sign-On" or "OIDC" setup You paste them into the other app's own "Single Sign-On" or "OIDC" setup
+2
View File
@@ -58,6 +58,8 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it. The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
## Slug conventions ## Slug conventions
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention: Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 221 KiB

+3 -2
View File
@@ -22,10 +22,11 @@ one command).
## Screenshots ## Screenshots
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a> <a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Executive dashboard" width="49%"></a>
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a> <a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a> <a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" width="49%"></a> <a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
*(click any screenshot to view full size)* *(click any screenshot to view full size)*
+2
View File
@@ -64,6 +64,8 @@ Clients are managed directly from the **Directory** tab in the web UI. They are
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group. > All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
## Scopes ## Scopes
| Scope | Claims / access | | Scope | Claims / access |
+5
View File
@@ -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.
+26
View File
@@ -473,6 +473,19 @@ User.update = async function(data){
} }
if(data.sshPublicKey){ if(data.sshPublicKey){
// Ensure the auxiliary objectClass is present before setting the attribute
// -- accounts created before ldapPublicKey was added to addPosixAccount's
// objectclass list (e.g. the bootstrap admin) won't have it yet.
try {
await client.modify(this.dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
}),
]);
} catch(e) {
if(e.name !== 'TypeOrValueExistsError') throw e;
}
await client.modify(this.dn, [ await client.modify(this.dn, [
new Change({ new Change({
operation: 'replace', operation: 'replace',
@@ -784,6 +797,19 @@ User.addSSHkey = async function(data) {
let result; let result;
try { try {
await withClient(async (client) => { await withClient(async (client) => {
// Ensure the auxiliary objectClass is present before setting the attribute
// -- accounts created before ldapPublicKey was added to addPosixAccount's
// objectclass list (e.g. the bootstrap admin) won't have it yet.
try {
await client.modify(user.dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
}),
]);
} catch(e) {
if (e.name !== 'TypeOrValueExistsError') throw e;
}
await client.modify(user.dn, [ await client.modify(user.dn, [
new Change({ new Change({
operation: 'add', operation: 'add',
+6 -6
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.4.0", "version": "1.5.1",
"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",
+1 -1
View File
@@ -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 = '';
} }
+291 -98
View File
@@ -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
View File
@@ -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
View File
@@ -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);
} }
+46
View File
@@ -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
View File
@@ -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>
&copy; <%- buildYear %> theta42 &middot; &copy; <%- buildYear %> theta42 &middot;
<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>
+2
View File
@@ -572,6 +572,8 @@
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')')); $target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) { } else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')')); $target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'oauth' && r.kind === 'service') {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} }
}); });
if (selectedId) $target.val(selectedId); if (selectedId) $target.val(selectedId);
+143 -132
View File
@@ -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>