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 @@ +
Last updated: <%= tosUpdatedOnFmt %>
<%- tosHtml %>