Compare commits

...

12 Commits

Author SHA1 Message Date
wmantly 4c4fc34dcf Merge pull request #67 from theta42/bump-1.1.3
Bump version to 1.1.3
2026-07-16 16:04:36 -04:00
wmantly 3e67c23008 Bump version to 1.1.3; update CHANGELOG 2026-07-16 16:04:08 -04:00
wmantly b657c4034b Merge pull request #66 from theta42/add-changelog
Add CHANGELOG.md, serve it in-app at /docs/changelog
2026-07-16 16:01:11 -04:00
wmantly f323a45fef Add CHANGELOG.md, serve it in-app at /docs/changelog (closes theta42/theta-env#43)
GitHub Releases already carried real changelog notes per tag, but
those require internet access to view -- exactly what the /docs
route exists to avoid. CHANGELOG.md is a committed, Keep-a-Changelog
style file (backfilled from the v1.1.0/v1.1.1/v1.1.2 release notes),
linked from README and served at /docs/changelog alongside the rest
of the project's docs.
2026-07-16 16:00:59 -04:00
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
20 changed files with 400 additions and 26 deletions
+8 -1
View File
@@ -9,9 +9,16 @@
.claude
*.md
# 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
!tos.md
!CHANGELOG.md
!DEPLOYMENT.md
!API.md
!directory_spec.md
!docs/**/*.md
# Tests
nodejs/tests/
+41
View File
@@ -0,0 +1,41 @@
# Changelog
All notable changes to this project are documented here. Format loosely
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`.
## [Unreleased]
## [1.1.3] - 2026-07-16
### Added
- `CHANGELOG.md` (this file), backfilled from the release notes for every tag so far and served in-app at `/docs/changelog`. Closes [theta-env#43](https://github.com/theta42/theta-env/issues/43).
## [1.1.2] - 2026-07-16
### Fixed
- Removed a dead IE<9-only `html5shim` script tag pointing at a domain that no longer resolves.
### Added
- **In-app documentation**: `GET /docs` and `GET /docs/:slug` render this project's own README, DEPLOYMENT, API.md, `docs/{ldap,oauth,configuration}.md`, and `directory_spec.md` server-side — readable from the running app with no dependency on GitHub Pages, which requires internet access to view. Public, no auth, rate-limited.
## [1.1.1] - 2026-07-16
### Added
- **Terms of Service is now editable at runtime by admins.** `tos.md` used to be baked into the repo and read once at startup, requiring a code change and deploy to update. It's now a Redis-backed singleton, 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. Admins can optionally require all users to re-accept the terms after a substantive edit. Closes [#39](https://github.com/theta42/sso-manager-node/issues/39). ([#62](https://github.com/theta42/sso-manager-node/pull/62))
## [1.1.0] - 2026-07-16
First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app update-check banner polls against going forward.
### Added
- Standalone backup script (`ops/backup.sh`) — snapshots LDAP (`slapcat`), Redis, and `./config`, with retention.
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
- Merged OAuth Apps + LDAP Info into a single Integrations page.
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...HEAD
[1.1.3]: https://github.com/theta42/sso-manager-node/compare/v1.1.2...v1.1.3
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
+9
View File
@@ -95,6 +95,15 @@ COPY nodejs/public ./public
# level above the nodejs/ app dir). Without this the app crashes on startup.
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 CHANGELOG.md /CHANGELOG.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).
COPY --from=gitinfo /commit.txt ./.build_commit
+3
View File
@@ -161,6 +161,9 @@ required groups, LDAPS/TLS, direct-bind service accounts) live in:
- [docs/](docs/) (GitHub Pages) — the same content broken into
[deployment](docs/deployment.md), [configuration](docs/configuration.md),
[OAuth/OIDC](docs/oauth.md), and [LDAP](docs/ldap.md).
- [CHANGELOG.md](CHANGELOG.md) — what changed in each release.
- All of the above is also readable from the running app itself at `/docs`
no internet access required.
If you are pointing the app at your own existing LDAP server, see
*LDAP requirements* in [DEPLOYMENT.md](DEPLOYMENT.md) — the directory needs the
+6
View File
@@ -68,6 +68,11 @@ app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}
// Routes for front end content.
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.
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/notification', middleware.auth, require('./routes/notification'));
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.
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
+8
View File
@@ -40,3 +40,11 @@ exports.invite = rateLimit({
limit: 20,
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",
"version": "1.1.0",
"version": "1.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.1.0",
"version": "1.1.3",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.1.0",
"version": "1.1.3",
"private": true,
"author": [
{
+16
View File
@@ -287,6 +287,22 @@ app.oauthClient = (function(app){
return { list, add, remove, update, rotateSecret };
})(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){
function list(callback){
return app.api.get('api-token/', function(error, data){
+72
View File
@@ -0,0 +1,72 @@
'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')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.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';
const fs = require('fs');
const path = require('path');
var express = require('express');
var router = express.Router();
const moment = require('moment');
const {marked} = require('marked');
const {InviteToken, PasswordResetToken} = require('./../models/token');
const {Tos} = require('../models/tos');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const tosHtml = marked(fs.readFileSync(path.join(__dirname, '../../tos.md'), 'utf8'));
const values ={
title: conf.environment !== 'production' ? `dev` : '',
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' });
});
router.get('/tos', function(req, res) {
res.render('tos', {...values, tosHtml});
router.get('/tos', async function(req, res, next) {
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
@@ -61,8 +64,13 @@ router.get('/invites', function(req, res) {
res.render('invites', {...values});
});
router.get('/onboarding', function(req, res) {
res.render('onboarding', {...values, tosHtml});
router.get('/onboarding', async function(req, res, next) {
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) {
+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>
</span>
<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">
<i class="fa-brands fa-github"></i> GitHub
</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() {
loadDashboard();
loadHistory();
toggleFilterInputs();
loadTos();
});
</script>
@@ -370,5 +405,38 @@
</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('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/lib/js/app-base.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>
<body>
+1
View File
@@ -6,6 +6,7 @@
<i class="fa-solid fa-file-contract"></i> Terms of Service
</div>
<div class="card-body">
<p class="text-muted small">Last updated: <%= tosUpdatedOnFmt %></p>
<%- tosHtml %>
</div>
</div>
+5 -9
View File
@@ -1,14 +1,10 @@
# Terms of Service
*Last updated: June 2026*
> **This is a template.** SSO Manager ships this file as a starting point for
> operators to adapt to their own deployment, organization name, and
> jurisdiction. Replace the placeholder text below (or the whole document)
> 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.
> **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
> "Terms of Service" card (no code change or redeploy needed) to adapt it to
> your own organization and jurisdiction before relying on it — this file
> itself is only read once, to seed that first version.
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.