From aaa538c7f951c9646e30af9b84859535048b42b6 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 16 Jul 2026 13:44:46 -0400 Subject: [PATCH] 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. --- nodejs/app.js | 1 + nodejs/models/tos.js | 34 +++++++++++++++++++ nodejs/public/js/app.js | 16 +++++++++ nodejs/routes/index.js | 22 ++++++++---- nodejs/routes/tos.js | 55 ++++++++++++++++++++++++++++++ nodejs/views/dashboard.ejs | 68 ++++++++++++++++++++++++++++++++++++++ nodejs/views/tos.ejs | 1 + tos.md | 14 +++----- 8 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 nodejs/models/tos.js create mode 100644 nodejs/routes/tos.js diff --git a/nodejs/app.js b/nodejs/app.js index dbce83e..10c3044 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -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')); diff --git a/nodejs/models/tos.js b/nodejs/models/tos.js new file mode 100644 index 0000000..56e34fc --- /dev/null +++ b/nodejs/models/tos.js @@ -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}; diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index 89459e3..43d2b5d 100755 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -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){ diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 34822d1..3bd5393 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -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' ? `` : '', @@ -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) { diff --git a/nodejs/routes/tos.js b/nodejs/routes/tos.js new file mode 100644 index 0000000..b64093f --- /dev/null +++ b/nodejs/routes/tos.js @@ -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; diff --git a/nodejs/views/dashboard.ejs b/nodejs/views/dashboard.ejs index f12d2f4..194fa02 100644 --- a/nodejs/views/dashboard.ejs +++ b/nodejs/views/dashboard.ejs @@ -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(); }); @@ -370,5 +405,38 @@ +
+
+
Terms of Service
+
+
+ +
+
+
+
+ Editor + +
+
+
+ + +
+
+ + +
+ + +
+
+
+
+ <%- include('impersonate_modal') %> <%- include('bottom') %> diff --git a/nodejs/views/tos.ejs b/nodejs/views/tos.ejs index 8fd04de..59dc5c4 100644 --- a/nodejs/views/tos.ejs +++ b/nodejs/views/tos.ejs @@ -6,6 +6,7 @@ Terms of Service
+

Last updated: <%= tosUpdatedOnFmt %>

<%- tosHtml %>
diff --git a/tos.md b/tos.md index 471532d..8fd64f9 100644 --- a/tos.md +++ b/tos.md @@ -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.