Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff10a23e78 | |||
| c2851ea537 | |||
| 98d767a201 | |||
| 955189d08a | |||
| 65e43a5677 | |||
| f3885bb3df | |||
| c3e086fc7b | |||
| aaa538c7f9 |
+7
-1
@@ -9,9 +9,15 @@
|
|||||||
.claude
|
.claude
|
||||||
*.md
|
*.md
|
||||||
# README.md and tos.md are both read at runtime (tos.md is loaded by
|
# README.md and tos.md are both read at runtime (tos.md is loaded by
|
||||||
# routes/index.js at boot), so they must stay in the build context.
|
# routes/index.js at boot). DEPLOYMENT.md/API.md/directory_spec.md/docs/*.md
|
||||||
|
# are read at runtime too, by routes/docs.js -- all must stay in the build
|
||||||
|
# context.
|
||||||
!README.md
|
!README.md
|
||||||
!tos.md
|
!tos.md
|
||||||
|
!DEPLOYMENT.md
|
||||||
|
!API.md
|
||||||
|
!directory_spec.md
|
||||||
|
!docs/**/*.md
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
nodejs/tests/
|
nodejs/tests/
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ COPY nodejs/public ./public
|
|||||||
# level above the nodejs/ app dir). Without this the app crashes on startup.
|
# level above the nodejs/ app dir). Without this the app crashes on startup.
|
||||||
COPY tos.md /tos.md
|
COPY tos.md /tos.md
|
||||||
|
|
||||||
|
# Documentation, served in-app at /docs (routes/docs.js) so it's readable
|
||||||
|
# without internet access. Same flattened-path convention as tos.md above.
|
||||||
|
COPY README.md /README.md
|
||||||
|
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||||
|
COPY API.md /API.md
|
||||||
|
COPY directory_spec.md /directory_spec.md
|
||||||
|
COPY docs /docs
|
||||||
|
|
||||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}
|
|||||||
// Routes for front end content.
|
// Routes for front end content.
|
||||||
app.use('/', require('./routes/index'));
|
app.use('/', require('./routes/index'));
|
||||||
|
|
||||||
|
// Local, in-app copy of the project's documentation (README, DEPLOYMENT,
|
||||||
|
// API.md, docs/*) -- public, no auth, so it's readable even by a locked-out
|
||||||
|
// admin or an air-gapped operator with no route to GitHub Pages.
|
||||||
|
app.use('/docs', require('./routes/docs'));
|
||||||
|
|
||||||
// API routes for authentication.
|
// API routes for authentication.
|
||||||
app.use('/api/auth', require('./routes/auth'));
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
|
|
||||||
@@ -80,6 +85,7 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
|||||||
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
|
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
|
||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
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.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|||||||
@@ -40,3 +40,11 @@ exports.invite = rateLimit({
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }),
|
handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public, unauthenticated, reads from disk on every request -- generous
|
||||||
|
// since it's just docs, but still throttled per IP.
|
||||||
|
exports.docs = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 120,
|
||||||
|
handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -287,6 +287,22 @@ app.oauthClient = (function(app){
|
|||||||
return { list, add, remove, update, rotateSecret };
|
return { list, add, remove, update, rotateSecret };
|
||||||
})(app);
|
})(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){
|
app.apiToken = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
return app.api.get('api-token/', function(error, data){
|
return app.api.get('api-token/', function(error, data){
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const router = require('express').Router();
|
||||||
|
const {marked} = require('marked');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const buildInfo = require('../utils/build_info');
|
||||||
|
const rateLimit = require('../middleware/rate_limit');
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
|
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
||||||
|
name: conf.name,
|
||||||
|
...buildInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full local copy of the project's documentation, rendered server-side --
|
||||||
|
// so an operator running air-gapped (no route to GitHub Pages, where this
|
||||||
|
// content otherwise only lives) can still read it from the running app.
|
||||||
|
// An explicit slug -> file allowlist, never a user-suppliable path, so
|
||||||
|
// there's no way to make this read outside the doc set below.
|
||||||
|
// docs/deployment.md is deliberately excluded -- it's just a stub pointing
|
||||||
|
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
|
||||||
|
// already covered by the "deployment" entry.
|
||||||
|
const DOCS = {
|
||||||
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
|
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
|
||||||
|
api: {title: 'API Reference', file: path.join(__dirname, '../../API.md')},
|
||||||
|
ldap: {title: 'LDAP', file: path.join(__dirname, '../../docs/ldap.md')},
|
||||||
|
oauth: {title: 'OAuth', file: path.join(__dirname, '../../docs/oauth.md')},
|
||||||
|
configuration: {title: 'Configuration', file: path.join(__dirname, '../../docs/configuration.md')},
|
||||||
|
'directory-spec': {title: 'Directory Spec (draft)', file: path.join(__dirname, '../../directory_spec.md')},
|
||||||
|
};
|
||||||
|
|
||||||
|
const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title}));
|
||||||
|
|
||||||
|
// README.md links its screenshots as repo-relative "docs/images/...", which
|
||||||
|
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
||||||
|
// the rendered markup to point at it absolutely, so the images work when
|
||||||
|
// read from /docs/overview too.
|
||||||
|
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
||||||
|
function fixImagePaths(html) {
|
||||||
|
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(rateLimit.docs);
|
||||||
|
|
||||||
|
router.get('/', function(req, res) {
|
||||||
|
res.render('docs_index', {...values, docs: docList});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:slug', function(req, res, next) {
|
||||||
|
const doc = DOCS[req.params.slug];
|
||||||
|
if (!doc) return next({status: 404, message: 'Doc not found'});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(doc.file, 'utf8');
|
||||||
|
res.render('docs_page', {
|
||||||
|
...values,
|
||||||
|
docs: docList,
|
||||||
|
currentSlug: req.params.slug,
|
||||||
|
docTitle: doc.title,
|
||||||
|
docHtml: fixImagePaths(marked(content)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+15
-7
@@ -1,17 +1,15 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
var express = require('express');
|
var express = require('express');
|
||||||
var router = express.Router();
|
var router = express.Router();
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const {marked} = require('marked');
|
const {marked} = require('marked');
|
||||||
const {InviteToken, PasswordResetToken} = require('./../models/token');
|
const {InviteToken, PasswordResetToken} = require('./../models/token');
|
||||||
|
const {Tos} = require('../models/tos');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
|
|
||||||
const tosHtml = marked(fs.readFileSync(path.join(__dirname, '../../tos.md'), 'utf8'));
|
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
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' });
|
res.json({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/tos', function(req, res) {
|
router.get('/tos', async function(req, res, next) {
|
||||||
res.render('tos', {...values, tosHtml});
|
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
|
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||||
@@ -61,8 +64,13 @@ router.get('/invites', function(req, res) {
|
|||||||
res.render('invites', {...values});
|
res.render('invites', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/onboarding', function(req, res) {
|
router.get('/onboarding', async function(req, res, next) {
|
||||||
res.render('onboarding', {...values, tosHtml});
|
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) {
|
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;
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="d-flex align-items-center gap-3">
|
<span class="d-flex align-items-center gap-3">
|
||||||
|
<a href="/docs" class="text-light text-decoration-none">
|
||||||
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
|
</a>
|
||||||
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none">
|
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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() {
|
$(document).ready(function() {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
toggleFilterInputs();
|
toggleFilterInputs();
|
||||||
|
loadTos();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -370,5 +405,38 @@
|
|||||||
|
|
||||||
</div>
|
</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('impersonate_modal') %>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-lg mt-4 mb-4">
|
||||||
|
<div class="card-header shadow">
|
||||||
|
<i class="fa-solid fa-book"></i> Documentation
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
A local copy of this project's documentation, readable from the
|
||||||
|
running app -- no internet access required.
|
||||||
|
</p>
|
||||||
|
<ul class="list-group">
|
||||||
|
<% docs.forEach(function(doc){ %>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 d-none d-md-block">
|
||||||
|
<div class="card shadow-lg mt-4 mb-4">
|
||||||
|
<div class="card-header shadow">
|
||||||
|
<i class="fa-solid fa-book"></i> Documentation
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<% docs.forEach(function(doc){ %>
|
||||||
|
<a href="/docs/<%= doc.slug %>"
|
||||||
|
class="list-group-item list-group-item-action<%= doc.slug === currentSlug ? ' active' : '' %>">
|
||||||
|
<%= doc.title %>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="card shadow-lg mt-4 mb-4">
|
||||||
|
<div class="card-header shadow">
|
||||||
|
<i class="fa-solid fa-file-lines"></i> <%= docTitle %>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<%- docHtml %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -24,12 +24,6 @@
|
|||||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
|
|
||||||
|
|
||||||
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
|
|
||||||
<!--[if lt IE 9]>
|
|
||||||
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<i class="fa-solid fa-file-contract"></i> Terms of Service
|
<i class="fa-solid fa-file-contract"></i> Terms of Service
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">Last updated: <%= tosUpdatedOnFmt %></p>
|
||||||
<%- tosHtml %>
|
<%- tosHtml %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
# Terms of Service
|
# Terms of Service
|
||||||
|
|
||||||
*Last updated: June 2026*
|
> **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
|
||||||
> **This is a template.** SSO Manager ships this file as a starting point for
|
> "Terms of Service" card (no code change or redeploy needed) to adapt it to
|
||||||
> operators to adapt to their own deployment, organization name, and
|
> your own organization and jurisdiction before relying on it — this file
|
||||||
> jurisdiction. Replace the placeholder text below (or the whole document)
|
> itself is only read once, to seed that first version.
|
||||||
> 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.
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user