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.
This commit is contained in:
2026-07-16 13:44:46 -04:00
parent 8a13dee8ae
commit aaa538c7f9
8 changed files with 195 additions and 16 deletions
+1
View File
@@ -80,6 +80,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'));
+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};
+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){
+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;
+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') %>
+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.