feat(ui): TDS migration stage 1 — EJS layout, CSP, vendored design system, shared runtime
- Vendor web_template base.css/base.js (v1.2 bbec859) under public/web_template with sync script - Add views/layout.ejs (fixed upstream EJS comment delimiters, mobile drawer, cmd palette, keys help, WS status, theme toggle) and lib/render.js two-step renderer - Add page routes for /, /workers, /workflows, /executions, /quick, /scheduler (stub views) - helmet with strict nonce CSP (script-src-attr 'none'), /csp-report, report-only toggle - lib/pageauth.js: shared users upsert + HTML 401/403 for page routes - PULSE_DEV_READONLY guard disabling background writers for local testing - public/assets/app.js shared runtime (Pulse namespace, delegated actions, WS singleton, themed confirm modal) and app.css - eslint globals for browser files; ignore vendored assets Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
// Navigation metadata shared by the layout and the page routes.
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/', key: 'dashboard', label: 'Dashboard' },
|
||||
{ href: '/workers', key: 'workers', label: 'Workers' },
|
||||
{ href: '/workflows', key: 'workflows', label: 'Workflows' },
|
||||
{ href: '/executions', key: 'executions', label: 'Executions' },
|
||||
{ href: '/quick', key: 'quick', label: 'Quick Command' },
|
||||
{ href: '/scheduler', key: 'scheduler', label: 'Scheduler' }
|
||||
];
|
||||
|
||||
// Page route table: { path, view, key, title }
|
||||
// `view` is the basename of views/pages/<view>.ejs and of /assets/pages/<view>.js
|
||||
const PAGES = [
|
||||
{ path: '/', view: 'dashboard', key: 'dashboard', title: 'Dashboard' },
|
||||
{ path: '/workers', view: 'workers', key: 'workers', title: 'Workers' },
|
||||
{ path: '/workflows', view: 'workflows', key: 'workflows', title: 'Workflows' },
|
||||
{ path: '/executions', view: 'executions', key: 'executions', title: 'Executions' },
|
||||
{ path: '/quick', view: 'quick', key: 'quick', title: 'Quick Command' },
|
||||
{ path: '/scheduler', view: 'scheduler', key: 'scheduler', title: 'Scheduler' }
|
||||
];
|
||||
|
||||
module.exports = { navLinks, PAGES };
|
||||
@@ -0,0 +1,70 @@
|
||||
// Authelia SSO helpers shared by the JSON API middleware (server.js authenticateSSO)
|
||||
// and the HTML page middleware (authenticatePage).
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { renderError } = require('./render');
|
||||
|
||||
const ALLOWED_GROUPS = ['admin', 'employee'];
|
||||
|
||||
// Upsert the SSO user into the `users` table. Extracted verbatim from authenticateSSO.
|
||||
async function upsertUser(pool, headers) {
|
||||
const userId = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO users (id, username, display_name, email, groups, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name=VALUES(display_name),
|
||||
email=VALUES(email),
|
||||
groups=VALUES(groups),
|
||||
last_login=NOW()`,
|
||||
[
|
||||
userId,
|
||||
headers['remote-user'],
|
||||
headers['remote-name'],
|
||||
headers['remote-email'],
|
||||
headers['remote-groups']
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Express middleware factory for HTML page routes: same auth rules as the API,
|
||||
// but failures render a themed HTML page instead of JSON.
|
||||
function makeAuthenticatePage(pool) {
|
||||
return async function authenticatePage(req, res, next) {
|
||||
const remoteUser = req.headers['remote-user'];
|
||||
const remoteName = req.headers['remote-name'];
|
||||
const remoteEmail = req.headers['remote-email'];
|
||||
const remoteGroups = req.headers['remote-groups'];
|
||||
|
||||
if (!remoteUser) {
|
||||
return renderError(req, res, 401, 'Not authenticated',
|
||||
'Not authenticated — access via Authelia SSO (auth.lotusguild.org).');
|
||||
}
|
||||
|
||||
const groups = remoteGroups ? remoteGroups.split(',').map(g => g.trim()) : [];
|
||||
const hasAccess = groups.some(g => ALLOWED_GROUPS.includes(g));
|
||||
|
||||
if (!hasAccess) {
|
||||
return renderError(req, res, 403, 'Access denied',
|
||||
'You must be in the admin or employee group to use this service.');
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertUser(pool, req.headers);
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
}
|
||||
|
||||
req.user = {
|
||||
username: remoteUser,
|
||||
name: remoteName || remoteUser,
|
||||
email: remoteEmail || '',
|
||||
groups: groups,
|
||||
isAdmin: groups.includes('admin')
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { upsertUser, makeAuthenticatePage, ALLOWED_GROUPS };
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
// Two-step EJS rendering: views/pages/<view>.ejs -> `body` -> views/layout.ejs.
|
||||
// No express-ejs-layouts; the vendored layout is a wrapper file that expects `body`.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const ejs = require('ejs');
|
||||
const { navLinks } = require('./nav');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const VIEWS_DIR = path.join(ROOT, 'views');
|
||||
const PAGES_DIR = path.join(VIEWS_DIR, 'pages');
|
||||
const LAYOUT = path.join(VIEWS_DIR, 'layout.ejs');
|
||||
const STUB = path.join(PAGES_DIR, '_stub.ejs');
|
||||
|
||||
const CACHE = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Cache-busting token: <package version>-<web_template short sha>, computed once.
|
||||
const assetVersion = (function computeAssetVersion() {
|
||||
let version = '0.0.0';
|
||||
try {
|
||||
version = require(path.join(ROOT, 'package.json')).version || '0.0.0';
|
||||
} catch (_) { /* keep default */ }
|
||||
let sha = '';
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(ROOT, 'public/web_template/VERSION'), 'utf8').trim();
|
||||
sha = (raw.split(/\s+/)[1] || '').trim();
|
||||
} catch (_) { /* VERSION not vendored yet */ }
|
||||
return sha ? `${version}-${sha}` : version;
|
||||
})();
|
||||
|
||||
const APP_NAME = process.env.APP_NAME || 'PULSE';
|
||||
const APP_SUBTITLE = process.env.APP_SUBTITLE || 'Worker Orchestration // LotusGuild';
|
||||
|
||||
function viewPath(view) {
|
||||
const file = path.join(PAGES_DIR, `${view}.ejs`);
|
||||
return fs.existsSync(file) ? file : STUB;
|
||||
}
|
||||
|
||||
// Render a page view inside the shared layout.
|
||||
async function renderPage(req, res, view, locals = {}) {
|
||||
const data = Object.assign({
|
||||
user: req.user || null,
|
||||
nonce: res.locals && res.locals.nonce,
|
||||
appName: APP_NAME,
|
||||
appSubtitle: APP_SUBTITLE,
|
||||
csrfToken: '',
|
||||
navLinks,
|
||||
pageTitle: '',
|
||||
activeNav: '',
|
||||
pageStyles: [],
|
||||
pageScripts: [],
|
||||
pageConfig: {},
|
||||
assetVersion
|
||||
}, locals);
|
||||
|
||||
const opts = { cache: CACHE, filename: viewPath(view) };
|
||||
const body = await ejs.renderFile(viewPath(view), data, opts);
|
||||
const html = await ejs.renderFile(LAYOUT, Object.assign({}, data, { body }), {
|
||||
cache: CACHE,
|
||||
filename: LAYOUT
|
||||
});
|
||||
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(html);
|
||||
return html;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Small self-contained themed page for 401/403/404/500.
|
||||
function renderError(req, res, status, title, message) {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>${esc(status)} — ${esc(APP_NAME)}</title>
|
||||
<link rel="stylesheet" href="/web_template/base.css?v=${esc(assetVersion)}">
|
||||
</head>
|
||||
<body>
|
||||
<main class="lt-main lt-container">
|
||||
<div class="lt-frame">
|
||||
<div class="lt-alert lt-alert-danger">
|
||||
<strong>${esc(status)} — ${esc(title)}</strong>
|
||||
<p>${esc(message)}</p>
|
||||
</div>
|
||||
<p><a class="lt-btn lt-btn-secondary lt-btn-sm" href="/">Return to dashboard</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
res.status(status).set('Content-Type', 'text/html; charset=utf-8').send(html);
|
||||
}
|
||||
|
||||
module.exports = { renderPage, renderError, assetVersion };
|
||||
Reference in New Issue
Block a user