Compare commits

..

8 Commits

Author SHA1 Message Date
wmantly ff10a23e78 Merge pull request #65 from theta42/bump-1.1.2
Bump version to 1.1.2
2026-07-16 15:38:27 -04:00
wmantly c2851ea537 Bump version to 1.1.2 2026-07-16 15:36:59 -04:00
wmantly 98d767a201 Merge pull request #64 from theta42/airgap-and-docs
Air-gap: remove dead CDN reference + in-app /docs
2026-07-16 15:34:12 -04:00
wmantly 955189d08a Air-gap: remove dead CDN reference + in-app /docs
- Removed a dead IE<9-only html5shim script tag pointing at a domain
  that no longer resolves.
- New GET /docs (index) and /docs/:slug routes render this project's
  own README, DEPLOYMENT, API.md, docs/*.md, and directory_spec.md
  server-side via marked -- so the documentation is readable from the
  running app with no route to GitHub Pages, where it otherwise only
  lives. Public, no auth, rate-limited (middleware/rate_limit.js) like
  the other public routes.
- .dockerignore/Dockerfile.openldap updated to copy DEPLOYMENT.md,
  API.md, directory_spec.md, and docs/ into the image, mirroring the
  existing tos.md -> /tos.md convention.
2026-07-16 15:33:46 -04:00
wmantly 65e43a5677 Merge pull request #63 from theta42/bump-1.1.1
Bump version to 1.1.1
2026-07-16 13:58:21 -04:00
wmantly f3885bb3df Bump version to 1.1.1 2026-07-16 13:57:07 -04:00
wmantly c3e086fc7b Merge pull request #62 from theta42/editable-tos
Make Terms of Service editable at runtime by admins
2026-07-16 13:45:18 -04:00
wmantly aaa538c7f9 Make Terms of Service editable at runtime by admins (closes #39)
tos.md was baked into the repo and read once at startup, so changing
the terms required a code change and deploy. It's now a Redis-backed
singleton (models/tos.js), editable from a new "Terms of Service" card
on the admin Dashboard, with the bundled tos.md used only as a
one-time seed for new deployments.

- routes/tos.js: GET (any authenticated user) / PUT (app_sso_admin
  only) via /api/tos. Saving can optionally reset every user's
  tos_accepted flag so they're asked to re-accept -- off by default,
  since a wording fix shouldn't re-prompt everyone.
- routes/index.js: /tos and /onboarding now render the live content
  instead of a module-level constant computed once at process start.
2026-07-16 13:44:46 -04:00
18 changed files with 353 additions and 26 deletions
+7 -1
View File
@@ -9,9 +9,15 @@
.claude .claude
*.md *.md
# README.md and tos.md are both read at runtime (tos.md is loaded by # README.md and tos.md are both read at runtime (tos.md is loaded by
# routes/index.js at boot), so they must stay in the build context. # routes/index.js at boot). DEPLOYMENT.md/API.md/directory_spec.md/docs/*.md
# are read at runtime too, by routes/docs.js -- all must stay in the build
# context.
!README.md !README.md
!tos.md !tos.md
!DEPLOYMENT.md
!API.md
!directory_spec.md
!docs/**/*.md
# Tests # Tests
nodejs/tests/ nodejs/tests/
+8
View File
@@ -95,6 +95,14 @@ COPY nodejs/public ./public
# level above the nodejs/ app dir). Without this the app crashes on startup. # level above the nodejs/ app dir). Without this the app crashes on startup.
COPY tos.md /tos.md COPY tos.md /tos.md
# Documentation, served in-app at /docs (routes/docs.js) so it's readable
# without internet access. Same flattened-path convention as tos.md above.
COPY README.md /README.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js). # Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit COPY --from=gitinfo /commit.txt ./.build_commit
+6
View File
@@ -68,6 +68,11 @@ app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}
// Routes for front end content. // Routes for front end content.
app.use('/', require('./routes/index')); app.use('/', require('./routes/index'));
// Local, in-app copy of the project's documentation (README, DEPLOYMENT,
// API.md, docs/*) -- public, no auth, so it's readable even by a locked-out
// admin or an air-gapped operator with no route to GitHub Pages.
app.use('/docs', require('./routes/docs'));
// API routes for authentication. // API routes for authentication.
app.use('/api/auth', require('./routes/auth')); app.use('/api/auth', require('./routes/auth'));
@@ -80,6 +85,7 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
app.use('/api/service-account', middleware.auth, require('./routes/service_account')); app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
app.use('/api/notification', middleware.auth, require('./routes/notification')); app.use('/api/notification', middleware.auth, require('./routes/notification'));
app.use('/api/update-check', middleware.auth, require('./routes/update_check')); app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
app.use('/api/tos', middleware.auth, require('./routes/tos'));
// Self-service API tokens (PATs) — owner-scoped, no admin group required. // Self-service API tokens (PATs) — owner-scoped, no admin group required.
app.use('/api/api-token', middleware.auth, require('./routes/api_token')); app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
+8
View File
@@ -40,3 +40,11 @@ exports.invite = rateLimit({
limit: 20, limit: 20,
handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }), handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }),
}); });
// Public, unauthenticated, reads from disk on every request -- generous
// since it's just docs, but still throttled per IP.
exports.docs = rateLimit({
windowMs: 60 * 1000,
limit: 120,
handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }),
});
+34
View File
@@ -0,0 +1,34 @@
'use strict';
const fs = require('fs');
const path = require('path');
const Table = require('.');
// Terms-of-Service text, editable by an admin at runtime (see routes/tos.js
// + the Dashboard's "Terms of Service" card) instead of being baked into the
// repo. A singleton row -- always keyed 'current' -- rather than a UUID like
// the other Redis models here, since there's only ever one live ToS.
class Tos extends Table {
static _key = 'name';
static _keyMap = {
name: {default: 'current', type: 'string'},
content: {isRequired: true, type: 'string'},
updated_by: {isRequired: true, type: 'string'},
updated_on: {default: () => Date.now()},
};
// Fetch the live row, seeding it from the bundled tos.md template the
// first time this is ever called on a deployment (so upgrading an
// existing install doesn't start with a blank ToS).
static async getCurrent() {
try {
return await this.get('current');
} catch (error) {
const content = fs.readFileSync(path.join(__dirname, '../../tos.md'), 'utf8');
return this.create({name: 'current', content, updated_by: 'system'});
}
}
}
Tos.register();
module.exports = {Tos};
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.0", "version": "1.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.0", "version": "1.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.0", "version": "1.1.2",
"private": true, "private": true,
"author": [ "author": [
{ {
+16
View File
@@ -287,6 +287,22 @@ app.oauthClient = (function(app){
return { list, add, remove, update, rotateSecret }; return { list, add, remove, update, rotateSecret };
})(app); })(app);
app.tos = (function(app){
function get(callback){
return app.api.get('tos/', function(error, data){
if(callback) callback(error, data);
});
}
function update(args, callback){
app.api.put('tos/', args, function(error, data){
callback(error, data);
});
}
return { get, update };
})(app);
app.apiToken = (function(app){ app.apiToken = (function(app){
function list(callback){ function list(callback){
return app.api.get('api-token/', function(error, data){ return app.api.get('api-token/', function(error, data){
+71
View File
@@ -0,0 +1,71 @@
'use strict';
const fs = require('fs');
const path = require('path');
const router = require('express').Router();
const {marked} = require('marked');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const rateLimit = require('../middleware/rate_limit');
const values = {
title: conf.environment !== 'production' ? `dev` : '',
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
name: conf.name,
...buildInfo,
};
// Full local copy of the project's documentation, rendered server-side --
// so an operator running air-gapped (no route to GitHub Pages, where this
// content otherwise only lives) can still read it from the running app.
// An explicit slug -> file allowlist, never a user-suppliable path, so
// there's no way to make this read outside the doc set below.
// docs/deployment.md is deliberately excluded -- it's just a stub pointing
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
// already covered by the "deployment" entry.
const DOCS = {
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
api: {title: 'API Reference', file: path.join(__dirname, '../../API.md')},
ldap: {title: 'LDAP', file: path.join(__dirname, '../../docs/ldap.md')},
oauth: {title: 'OAuth', file: path.join(__dirname, '../../docs/oauth.md')},
configuration: {title: 'Configuration', file: path.join(__dirname, '../../docs/configuration.md')},
'directory-spec': {title: 'Directory Spec (draft)', file: path.join(__dirname, '../../directory_spec.md')},
};
const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title}));
// README.md links its screenshots as repo-relative "docs/images/...", which
// only resolves correctly on GitHub. Serve that same folder here and rewrite
// the rendered markup to point at it absolutely, so the images work when
// read from /docs/overview too.
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
}
router.use(rateLimit.docs);
router.get('/', function(req, res) {
res.render('docs_index', {...values, docs: docList});
});
router.get('/:slug', function(req, res, next) {
const doc = DOCS[req.params.slug];
if (!doc) return next({status: 404, message: 'Doc not found'});
try {
const content = fs.readFileSync(doc.file, 'utf8');
res.render('docs_page', {
...values,
docs: docList,
currentSlug: req.params.slug,
docTitle: doc.title,
docHtml: fixImagePaths(marked(content)),
});
} catch (error) {
next(error);
}
});
module.exports = router;
+15 -7
View File
@@ -1,17 +1,15 @@
'use strict'; 'use strict';
const fs = require('fs');
const path = require('path'); const path = require('path');
var express = require('express'); var express = require('express');
var router = express.Router(); var router = express.Router();
const moment = require('moment'); const moment = require('moment');
const {marked} = require('marked'); const {marked} = require('marked');
const {InviteToken, PasswordResetToken} = require('./../models/token'); const {InviteToken, PasswordResetToken} = require('./../models/token');
const {Tos} = require('../models/tos');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info'); const buildInfo = require('../utils/build_info');
const tosHtml = marked(fs.readFileSync(path.join(__dirname, '../../tos.md'), 'utf8'));
const values ={ const values ={
title: conf.environment !== 'production' ? `dev` : '', title: conf.environment !== 'production' ? `dev` : '',
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '', titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
@@ -44,8 +42,13 @@ router.get('/health', function(req, res) {
res.json({ status: 'ok' }); res.json({ status: 'ok' });
}); });
router.get('/tos', function(req, res) { router.get('/tos', async function(req, res, next) {
res.render('tos', {...values, tosHtml}); try {
const tos = await Tos.getCurrent();
res.render('tos', {...values, tosHtml: marked(tos.content), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')});
} catch (error) {
next(error);
}
}); });
// Admin dashboard (stats + recent/inactive users) and Notifications // Admin dashboard (stats + recent/inactive users) and Notifications
@@ -61,8 +64,13 @@ router.get('/invites', function(req, res) {
res.render('invites', {...values}); res.render('invites', {...values});
}); });
router.get('/onboarding', function(req, res) { router.get('/onboarding', async function(req, res, next) {
res.render('onboarding', {...values, tosHtml}); try {
const tos = await Tos.getCurrent();
res.render('onboarding', {...values, tosHtml: marked(tos.content)});
} catch (error) {
next(error);
}
}); });
router.get('/', async function(req, res, next) { router.get('/', async function(req, res, next) {
+55
View File
@@ -0,0 +1,55 @@
'use strict';
const router = require('express').Router();
const {Tos} = require('../models/tos');
const {UserVerification} = require('../models/verification');
const permission = require('../utils/permission');
// Any authenticated user may read the current ToS (it's what they already
// see on /tos and during onboarding, and it isn't sensitive) -- only saving
// an edit is admin-gated.
router.get('/', async function(req, res, next) {
try {
const tos = await Tos.getCurrent();
return res.json(tos);
} catch (error) {
next(error);
}
});
router.put('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
const {content, resetAcceptance} = req.body;
if (!content || !content.trim()) {
return res.status(400).json({name: 'ValidationError', message: 'content is required'});
}
const tos = await Tos.getCurrent();
await tos.update({content, updated_by: req.user.uid, updated_on: Date.now()});
// Opt-in: a substantive change may need everyone to agree again, but a
// wording/typo fix shouldn't re-prompt every user, so this only runs
// when the admin explicitly asks for it.
let resetCount = 0;
if (resetAcceptance) {
const verifications = await UserVerification.listDetail();
for (const v of verifications) {
if (v.tos_accepted) {
// Leave tos_accepted_at as the last acceptance time (a
// historical fact) -- only the boolean flips, driving
// onboardingNeeds back to including 'tos'.
await v.update({tos_accepted: false});
resetCount++;
}
}
}
return res.json({results: tos, resetCount});
} catch (error) {
next(error);
}
});
module.exports = router;
+3
View File
@@ -10,6 +10,9 @@
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a> <a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" 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">
<i class="fa-solid fa-book"></i> Docs
</a>
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none"> <a href="https://github.com/theta42/sso-manager-node" 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>
+68
View File
@@ -148,10 +148,45 @@
} }
} }
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) { alert('Terms of Service text cannot be empty.'); return; }
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() { $(document).ready(function() {
loadDashboard(); loadDashboard();
loadHistory(); loadHistory();
toggleFilterInputs(); toggleFilterInputs();
loadTos();
}); });
</script> </script>
@@ -370,5 +405,38 @@
</div> </div>
<div class="row mt-4">
<div class="col-12">
<h5 class="mb-3"><i class="fa-solid fa-file-contract"></i> Terms of Service</h5>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-pencil"></i> Editor
<small class="text-muted float-end" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control shadow" id="tos-content" rows="16"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">
Require all users to re-accept these terms
</label>
</div>
<button class="btn btn-primary shadow" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %> <%- include('impersonate_modal') %>
<%- include('bottom') %> <%- include('bottom') %>
+24
View File
@@ -0,0 +1,24 @@
<%- include('top') %>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg mt-4 mb-4">
<div class="card-header shadow">
<i class="fa-solid fa-book"></i> Documentation
</div>
<div class="card-body">
<p class="text-muted">
A local copy of this project's documentation, readable from the
running app -- no internet access required.
</p>
<ul class="list-group">
<% docs.forEach(function(doc){ %>
<li class="list-group-item">
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
</li>
<% }) %>
</ul>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+29
View File
@@ -0,0 +1,29 @@
<%- include('top') %>
<div class="row">
<div class="col-md-3 d-none d-md-block">
<div class="card shadow-lg mt-4 mb-4">
<div class="card-header shadow">
<i class="fa-solid fa-book"></i> Documentation
</div>
<div class="list-group list-group-flush">
<% docs.forEach(function(doc){ %>
<a href="/docs/<%= doc.slug %>"
class="list-group-item list-group-item-action<%= doc.slug === currentSlug ? ' active' : '' %>">
<%= doc.title %>
</a>
<% }) %>
</div>
</div>
</div>
<div class="col-md-9">
<div class="card shadow-lg mt-4 mb-4">
<div class="card-header shadow">
<i class="fa-solid fa-file-lines"></i> <%= docTitle %>
</div>
<div class="card-body">
<%- docHtml %>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
-6
View File
@@ -24,12 +24,6 @@
<script type="text/javascript" src="/static-modules/moment/moment.js"></script> <script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script> <script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script> <script type="text/javascript" src="/static/js/app.js"></script>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
</head> </head>
<body> <body>
+1
View File
@@ -6,6 +6,7 @@
<i class="fa-solid fa-file-contract"></i> Terms of Service <i class="fa-solid fa-file-contract"></i> Terms of Service
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small">Last updated: <%= tosUpdatedOnFmt %></p>
<%- tosHtml %> <%- tosHtml %>
</div> </div>
</div> </div>
+5 -9
View File
@@ -1,14 +1,10 @@
# Terms of Service # Terms of Service
*Last updated: June 2026* > **This is a template.** SSO Manager ships this file as the initial seed for
> a new deployment's Terms of Service. Edit it from the admin Dashboard's
> **This is a template.** SSO Manager ships this file as a starting point for > "Terms of Service" card (no code change or redeploy needed) to adapt it to
> operators to adapt to their own deployment, organization name, and > your own organization and jurisdiction before relying on it — this file
> jurisdiction. Replace the placeholder text below (or the whole document) > itself is only read once, to seed that first version.
> with terms reviewed by your own admin/legal before relying on it. See
> [issue #39](https://github.com/theta42/sso-manager-node/issues/39) for the
> planned admin UI that will let operators edit this document without a code
> change.
Welcome. By creating an account and using any services on this system, you agree to the following terms. Please read them carefully — they're short and written in plain English. Welcome. By creating an account and using any services on this system, you agree to the following terms. Please read them carefully — they're short and written in plain English.