thatcher 1.0.59 → 1.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.59",
3
+ "version": "1.0.60",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -64,6 +64,16 @@ export class ConfigGeneratorEngine {
64
64
  return this;
65
65
  }
66
66
 
67
+ updatePermissionTemplate(templateName, roleActionsMap) {
68
+ if (!templateName || typeof templateName !== 'string') {
69
+ throw new Error('[ConfigGeneratorEngine] updatePermissionTemplate: templateName required');
70
+ }
71
+ const nextTemplates = { ...(this.masterConfig.permission_templates || {}), [templateName]: roleActionsMap };
72
+ this.masterConfig = deepFreeze({ ...this.masterConfig, permission_templates: nextTemplates });
73
+ this.specCache.clear();
74
+ return this;
75
+ }
76
+
67
77
  registerPlugin(entityName, plugin = {}) {
68
78
  if (!entityName || typeof entityName !== 'string') {
69
79
  throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
@@ -118,6 +118,10 @@ export function createServer(options) {
118
118
  return await handleUpdateWorkflow(req, res, id, thatcher, configEngine);
119
119
  }
120
120
 
121
+ if (req.method === 'POST' && entity === 'permission-template' && id && action === 'update') {
122
+ return await handleUpdatePermissionTemplate(req, res, id, thatcher, configEngine);
123
+ }
124
+
121
125
  // Check if user has custom route for this
122
126
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
123
127
  const routeExists = await fileExists(userRoutePath);
@@ -345,6 +349,80 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
345
349
  }
346
350
  }
347
351
 
352
+ const PERMISSION_ACTIONS = new Set(['list', 'view', 'create', 'edit', 'delete', 'archive', 'export', 'manage_settings']);
353
+
354
+ async function handleUpdatePermissionTemplate(req, res, templateName, thatcher, configEngineArg) {
355
+ const user = await resolveRequestUser(req);
356
+ if (!user) {
357
+ res.writeHead(401, { 'Content-Type': 'application/json' });
358
+ res.end(JSON.stringify({ error: 'Authentication required' }));
359
+ return;
360
+ }
361
+ const { isPartner } = await import('../ui/permissions-ui.js');
362
+ if (!isPartner(user)) {
363
+ res.writeHead(403, { 'Content-Type': 'application/json' });
364
+ res.end(JSON.stringify({ error: 'Forbidden' }));
365
+ return;
366
+ }
367
+
368
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
369
+ if (!configEngine) {
370
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
371
+ configEngine = getConfigEngineSync();
372
+ }
373
+ const existingTemplate = configEngine.getConfig().permission_templates?.[templateName];
374
+ if (!existingTemplate) {
375
+ res.writeHead(404);
376
+ res.end(JSON.stringify({ error: `Permission template "${templateName}" not found` }));
377
+ return;
378
+ }
379
+ const validRoles = new Set(Object.keys(configEngine.getRoles()));
380
+
381
+ let body;
382
+ try {
383
+ body = await readBody(req);
384
+ } catch (e) {
385
+ res.writeHead(400);
386
+ res.end(JSON.stringify({ error: e.message }));
387
+ return;
388
+ }
389
+ const roles = body && typeof body.roles === 'object' && !Array.isArray(body.roles) ? body.roles : null;
390
+ if (!roles) {
391
+ res.writeHead(400);
392
+ res.end(JSON.stringify({ error: 'roles object required' }));
393
+ return;
394
+ }
395
+ for (const [roleName, actions] of Object.entries(roles)) {
396
+ if (!validRoles.has(roleName)) {
397
+ res.writeHead(400);
398
+ res.end(JSON.stringify({ error: `unknown role "${roleName}"` }));
399
+ return;
400
+ }
401
+ if (!Array.isArray(actions)) {
402
+ res.writeHead(400);
403
+ res.end(JSON.stringify({ error: `actions for role "${roleName}" must be an array` }));
404
+ return;
405
+ }
406
+ for (const a of actions) {
407
+ if (!PERMISSION_ACTIONS.has(a)) {
408
+ res.writeHead(400);
409
+ res.end(JSON.stringify({ error: `unknown action "${a}" for role "${roleName}"` }));
410
+ return;
411
+ }
412
+ }
413
+ }
414
+
415
+ try {
416
+ configEngine.updatePermissionTemplate(templateName, roles);
417
+ res.writeHead(200, { 'Content-Type': 'application/json' });
418
+ res.end(JSON.stringify({ ok: true, template: templateName, roleCount: Object.keys(roles).length }));
419
+ } catch (err) {
420
+ apiLog.error(err.message);
421
+ res.writeHead(500);
422
+ res.end(JSON.stringify({ error: err.message }));
423
+ }
424
+ }
425
+
348
426
  async function handleUpdateWorkflow(req, res, workflowName, thatcher, configEngineArg) {
349
427
  const user = await resolveRequestUser(req);
350
428
  if (!user) {
@@ -7,6 +7,7 @@ import { renderSettingsNotifications, renderSettingsIntegrations, renderSettings
7
7
  import { renderChecklistsManagement } from '@/ui/checklist-renderer.js';
8
8
  import { renderJobManagement } from '@/ui/job-management-renderer.js';
9
9
  import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-renderer.js';
10
+ import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/ui/rbac-renderer.js';
10
11
  import { isPartner, isManager } from '@/ui/permissions-ui.js';
11
12
  import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
12
13
  import { fileURLToPath } from 'url';
@@ -142,5 +143,25 @@ export async function handleAdminPage(normalized, segments, user) {
142
143
  if (!workflowDef) return null;
143
144
  return renderWorkflowEditor(user, segments[2], workflowDef);
144
145
  }
146
+ if (normalized === '/admin/roles') {
147
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
148
+ const engine = getConfigEngineSync();
149
+ return renderRolesList(user, engine.getRoles());
150
+ }
151
+ if (normalized === '/admin/permissions') {
152
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
153
+ const engine = getConfigEngineSync();
154
+ const config = engine.getConfig();
155
+ return renderTemplateList(user, Object.keys(config.permission_templates || {}));
156
+ }
157
+ if (segments.length === 3 && segments[1] === 'permissions') {
158
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
159
+ const engine = getConfigEngineSync();
160
+ const config = engine.getConfig();
161
+ const roleActionsMap = config.permission_templates?.[segments[2]];
162
+ if (!roleActionsMap) return null;
163
+ const roleNames = Object.keys(engine.getRoles());
164
+ return renderPermissionMatrix(user, segments[2], roleNames, roleActionsMap);
165
+ }
145
166
  return null;
146
167
  }
@@ -0,0 +1,63 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc } from '@/ui/render-helpers.js';
3
+
4
+ const ACTIONS = ['list', 'view', 'create', 'edit', 'delete', 'archive', 'export', 'manage_settings'];
5
+
6
+ export function renderRolesList(user, roles) {
7
+ const sorted = Object.entries(roles).sort((a, b) => (a[1].hierarchy ?? 999) - (b[1].hierarchy ?? 999));
8
+ const rows = sorted.map(([name, def]) =>
9
+ `<tr data-row><td>${esc(name)}</td><td>${esc(def.label || name)}</td><td>${esc(String(def.hierarchy ?? '-'))}</td><td>${esc(def.permissions_scope || '-')}</td></tr>`
10
+ ).join('') || '<tr><td colspan="4">No roles configured</td></tr>';
11
+ const content = `<div class="page-header"><h1 class="page-title">Roles</h1></div>
12
+ <div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th><th>Label</th><th>Hierarchy</th><th>Scope</th></tr></thead><tbody>${rows}</tbody></table></div>
13
+ <div style="margin-top:16px"><a href="/admin/permissions" class="btn-ghost-clean">Edit Permission Templates</a></div>`;
14
+ return page(user, 'Roles | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Roles' }], content);
15
+ }
16
+
17
+ export function renderTemplateList(user, templateNames) {
18
+ const rows = templateNames.map(name =>
19
+ `<tr data-row data-navigate="/admin/permissions/${esc(name)}" style="cursor:pointer"><td>${esc(name)}</td></tr>`
20
+ ).join('') || '<tr><td>No permission templates configured</td></tr>';
21
+ const content = `<div class="page-header"><h1 class="page-title">Permission Templates</h1></div>
22
+ <div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th></tr></thead><tbody>${rows}</tbody></table></div>`;
23
+ return page(user, 'Permission Templates | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/roles', label: 'Roles' }, { label: 'Permission Templates' }], content);
24
+ }
25
+
26
+ export function renderPermissionMatrix(user, templateName, roleNames, roleActionsMap) {
27
+ const header = ACTIONS.map(a => `<th style="text-align:center">${esc(a)}</th>`).join('');
28
+ const rows = roleNames.map(role => {
29
+ const actions = roleActionsMap[role] || [];
30
+ const cells = ACTIONS.map(a =>
31
+ `<td style="text-align:center"><input type="checkbox" data-role="${esc(role)}" data-action="${esc(a)}"${actions.includes(a) ? ' checked' : ''}></td>`
32
+ ).join('');
33
+ return `<tr><td>${esc(role)}</td>${cells}</tr>`;
34
+ }).join('');
35
+
36
+ const content = `<div class="page-header"><h1 class="page-title">Permissions: ${esc(templateName)}</h1></div>
37
+ <div class="table-wrap"><table class="data-table" id="perm-matrix"><thead><tr><th>Role</th>${header}</tr></thead><tbody>${rows}</tbody></table></div>
38
+ <div style="margin-top:16px">
39
+ <button type="button" class="btn-primary-clean" data-action="savePermissions" data-args='["${esc(templateName)}"]'>Save</button>
40
+ <span id="save-status" style="margin-left:12px;font-size:13px"></span>
41
+ </div>`;
42
+
43
+ const script = `(function(){
44
+ window.savePermissions=function(templateName){
45
+ var boxes=document.querySelectorAll('#perm-matrix input[type="checkbox"]');
46
+ var map={};
47
+ boxes.forEach(function(cb){
48
+ var role=cb.getAttribute('data-role');
49
+ var action=cb.getAttribute('data-action');
50
+ if(!map[role])map[role]=[];
51
+ if(cb.checked)map[role].push(action);
52
+ });
53
+ var status=document.getElementById('save-status');
54
+ status.textContent='Saving...';
55
+ fetch('/api/permission-template/'+templateName+'/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({roles:map})})
56
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
57
+ .then(function(res){if(res.ok){status.textContent='Saved';status.style.color='var(--color-success,#22c55e)';setTimeout(function(){location.reload()},500)}else{status.textContent='Error: '+(res.d.error||'save failed');status.style.color='var(--color-danger,#ef4444)'}})
58
+ .catch(function(err){status.textContent='Error: '+err.message;status.style.color='var(--color-danger,#ef4444)'});
59
+ };
60
+ })();`;
61
+
62
+ return page(user, `Permissions: ${templateName} | Thatcher`, [{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/roles', label: 'Roles' }, { href: '/admin/permissions', label: 'Permission Templates' }, { label: templateName }], content, [script]);
63
+ }