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
+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;