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:
@@ -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'));
|
||||
|
||||
@@ -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};
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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') %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user