thatcher 1.0.58 → 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
|
@@ -54,6 +54,26 @@ export class ConfigGeneratorEngine {
|
|
|
54
54
|
return this.masterConfig?.system?.multi_tenancy?.enabled === true;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
updateWorkflow(workflowName, updatedDef) {
|
|
58
|
+
if (!workflowName || typeof workflowName !== 'string') {
|
|
59
|
+
throw new Error('[ConfigGeneratorEngine] updateWorkflow: workflowName required');
|
|
60
|
+
}
|
|
61
|
+
const nextWorkflows = { ...(this.masterConfig.workflows || {}), [workflowName]: updatedDef };
|
|
62
|
+
this.masterConfig = deepFreeze({ ...this.masterConfig, workflows: nextWorkflows });
|
|
63
|
+
this.specCache.clear();
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
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
|
+
|
|
57
77
|
registerPlugin(entityName, plugin = {}) {
|
|
58
78
|
if (!entityName || typeof entityName !== 'string') {
|
|
59
79
|
throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
|
|
@@ -30,6 +30,11 @@ const log = createLogger('[WorkflowEngine]');
|
|
|
30
30
|
const LOCKOUT_SECONDS = 300; // 5 minutes default
|
|
31
31
|
const workflowCache = new Map();
|
|
32
32
|
|
|
33
|
+
export function clearWorkflowCache(workflowName) {
|
|
34
|
+
if (workflowName) workflowCache.delete(workflowName);
|
|
35
|
+
else workflowCache.clear();
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
function getWorkflowDef(workflowName) {
|
|
34
39
|
if (workflowCache.has(workflowName)) {
|
|
35
40
|
return workflowCache.get(workflowName);
|
package/src/server/server.js
CHANGED
|
@@ -114,6 +114,14 @@ export function createServer(options) {
|
|
|
114
114
|
return await handleListMemberships(req, res, thatcher, configEngine);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
if (req.method === 'POST' && entity === 'workflow' && id && action === 'update') {
|
|
118
|
+
return await handleUpdateWorkflow(req, res, id, thatcher, configEngine);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (req.method === 'POST' && entity === 'permission-template' && id && action === 'update') {
|
|
122
|
+
return await handleUpdatePermissionTemplate(req, res, id, thatcher, configEngine);
|
|
123
|
+
}
|
|
124
|
+
|
|
117
125
|
// Check if user has custom route for this
|
|
118
126
|
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
119
127
|
const routeExists = await fileExists(userRoutePath);
|
|
@@ -341,6 +349,159 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
341
349
|
}
|
|
342
350
|
}
|
|
343
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
|
+
|
|
426
|
+
async function handleUpdateWorkflow(req, res, workflowName, thatcher, configEngineArg) {
|
|
427
|
+
const user = await resolveRequestUser(req);
|
|
428
|
+
if (!user) {
|
|
429
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
430
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const { isPartner } = await import('../ui/permissions-ui.js');
|
|
434
|
+
if (!isPartner(user)) {
|
|
435
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
436
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
441
|
+
if (!configEngine) {
|
|
442
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
443
|
+
configEngine = getConfigEngineSync();
|
|
444
|
+
}
|
|
445
|
+
const existingDef = configEngine.getConfig().workflows?.[workflowName];
|
|
446
|
+
if (!existingDef) {
|
|
447
|
+
res.writeHead(404);
|
|
448
|
+
res.end(JSON.stringify({ error: `Workflow "${workflowName}" not found` }));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let body;
|
|
453
|
+
try {
|
|
454
|
+
body = await readBody(req);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
res.writeHead(400);
|
|
457
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const stages = Array.isArray(body?.stages) ? body.stages : null;
|
|
461
|
+
if (!stages || !stages.length) {
|
|
462
|
+
res.writeHead(400);
|
|
463
|
+
res.end(JSON.stringify({ error: 'stages array required and must be non-empty' }));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const seenNames = new Set();
|
|
467
|
+
for (const s of stages) {
|
|
468
|
+
if (!s || typeof s.name !== 'string' || !s.name.trim()) {
|
|
469
|
+
res.writeHead(400);
|
|
470
|
+
res.end(JSON.stringify({ error: 'every stage requires a non-empty name' }));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (seenNames.has(s.name)) {
|
|
474
|
+
res.writeHead(400);
|
|
475
|
+
res.end(JSON.stringify({ error: `duplicate stage name "${s.name}"` }));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
seenNames.add(s.name);
|
|
479
|
+
}
|
|
480
|
+
const validNames = new Set(stages.map(s => s.name));
|
|
481
|
+
for (const s of stages) {
|
|
482
|
+
for (const target of (s.forward || [])) {
|
|
483
|
+
if (!validNames.has(target)) {
|
|
484
|
+
res.writeHead(400);
|
|
485
|
+
res.end(JSON.stringify({ error: `stage "${s.name}" has a forward transition to unknown stage "${target}"` }));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
const updatedDef = { ...existingDef, stages };
|
|
493
|
+
configEngine.updateWorkflow(workflowName, updatedDef);
|
|
494
|
+
const { clearWorkflowCache } = await import('../lib/workflow-engine.js');
|
|
495
|
+
clearWorkflowCache(workflowName);
|
|
496
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
497
|
+
res.end(JSON.stringify({ ok: true, workflow: workflowName, stageCount: stages.length }));
|
|
498
|
+
} catch (err) {
|
|
499
|
+
apiLog.error(err.message);
|
|
500
|
+
res.writeHead(500);
|
|
501
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
344
505
|
async function handleListMemberships(req, res, thatcher, configEngineArg) {
|
|
345
506
|
const user = await resolveRequestUser(req);
|
|
346
507
|
if (!user) {
|
|
@@ -6,6 +6,8 @@ import { renderSettingsTemplates, renderSettingsChecklists, renderSettingsEntity
|
|
|
6
6
|
import { renderSettingsNotifications, renderSettingsIntegrations, renderSettingsRecreation, renderSettingsReviewSettings, renderSettingsFileReview, renderSettingsMwrPermissions } from '@/ui/settings/review.js';
|
|
7
7
|
import { renderChecklistsManagement } from '@/ui/checklist-renderer.js';
|
|
8
8
|
import { renderJobManagement } from '@/ui/job-management-renderer.js';
|
|
9
|
+
import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-renderer.js';
|
|
10
|
+
import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/ui/rbac-renderer.js';
|
|
9
11
|
import { isPartner, isManager } from '@/ui/permissions-ui.js';
|
|
10
12
|
import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
|
|
11
13
|
import { fileURLToPath } from 'url';
|
|
@@ -127,5 +129,39 @@ export async function handleAdminPage(normalized, segments, user) {
|
|
|
127
129
|
let logs = []; try { logs = (await list('job_log', {})).slice(0, 20); } catch {}
|
|
128
130
|
return renderJobManagement(user, jobs, logs);
|
|
129
131
|
}
|
|
132
|
+
if (normalized === '/admin/workflows') {
|
|
133
|
+
const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
|
|
134
|
+
const engine = getConfigEngineSync();
|
|
135
|
+
const config = engine.getConfig();
|
|
136
|
+
return renderWorkflowList(user, Object.keys(config.workflows || {}));
|
|
137
|
+
}
|
|
138
|
+
if (segments.length === 3 && segments[1] === 'workflows') {
|
|
139
|
+
const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
|
|
140
|
+
const engine = getConfigEngineSync();
|
|
141
|
+
const config = engine.getConfig();
|
|
142
|
+
const workflowDef = config.workflows?.[segments[2]];
|
|
143
|
+
if (!workflowDef) return null;
|
|
144
|
+
return renderWorkflowEditor(user, segments[2], workflowDef);
|
|
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
|
+
}
|
|
130
166
|
return null;
|
|
131
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
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
|
|
4
|
+
export function renderWorkflowList(user, workflowNames) {
|
|
5
|
+
const rows = workflowNames.map(name =>
|
|
6
|
+
`<tr data-row data-navigate="/admin/workflows/${esc(name)}" style="cursor:pointer"><td>${esc(name)}</td></tr>`
|
|
7
|
+
).join('') || '<tr><td>No workflows configured</td></tr>';
|
|
8
|
+
const content = `<div class="page-header"><h1 class="page-title">Workflows</h1></div>
|
|
9
|
+
<div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
10
|
+
return page(user, 'Workflows | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Workflows' }], content);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stageRow(stage, allStageNames) {
|
|
14
|
+
const forwardOpts = allStageNames.filter(n => n !== stage.name).map(n =>
|
|
15
|
+
`<label style="display:inline-flex;align-items:center;gap:4px;margin-right:10px"><input type="checkbox" name="forward_${esc(stage.name)}" value="${esc(n)}"${(stage.forward || []).includes(n) ? ' checked' : ''}>${esc(n)}</label>`
|
|
16
|
+
).join('');
|
|
17
|
+
return `<div class="card-clean" style="margin-bottom:12px" data-stage-card="${esc(stage.name)}">
|
|
18
|
+
<div class="card-clean-body">
|
|
19
|
+
<div style="display:flex;gap:12px;align-items:center;margin-bottom:8px">
|
|
20
|
+
<input type="text" name="name_${esc(stage.name)}" value="${esc(stage.name)}" placeholder="stage name" style="width:140px" readonly>
|
|
21
|
+
<input type="text" name="label_${esc(stage.name)}" value="${esc(stage.label || '')}" placeholder="Label" style="flex:1">
|
|
22
|
+
<input type="text" name="role_${esc(stage.name)}" value="${esc((stage.requires_role || []).join(','))}" placeholder="requires_role (comma-separated)" style="width:200px">
|
|
23
|
+
<button type="button" class="btn-ghost-clean" data-action="removeStage" data-args='["${esc(stage.name)}"]'>Remove</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div style="font-size:12px;color:var(--color-text-muted,#666)">Forward transitions:</div>
|
|
26
|
+
<div>${forwardOpts || '<span style="font-size:12px;color:var(--color-text-muted,#666)">(no other stages)</span>'}</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderWorkflowEditor(user, workflowName, workflowDef) {
|
|
32
|
+
const stages = workflowDef?.stages || [];
|
|
33
|
+
const stageNames = stages.map(s => s.name);
|
|
34
|
+
const stageCards = stages.map(s => stageRow(s, stageNames)).join('');
|
|
35
|
+
|
|
36
|
+
const content = `<div class="page-header"><h1 class="page-title">Workflow: ${esc(workflowName)}</h1></div>
|
|
37
|
+
<form id="workflow-form">
|
|
38
|
+
<div id="stage-list">${stageCards}</div>
|
|
39
|
+
<div style="margin:16px 0;display:flex;gap:8px">
|
|
40
|
+
<input type="text" id="new-stage-name" placeholder="new stage name" style="width:200px">
|
|
41
|
+
<button type="button" class="btn-ghost-clean" data-action="addStage">Add Stage</button>
|
|
42
|
+
</div>
|
|
43
|
+
<button type="button" class="btn-primary-clean" data-action="saveWorkflow" data-args='["${esc(workflowName)}"]'>Save Workflow</button>
|
|
44
|
+
<span id="save-status" style="margin-left:12px;font-size:13px"></span>
|
|
45
|
+
</form>`;
|
|
46
|
+
|
|
47
|
+
const script = `(function(){
|
|
48
|
+
function collectStages(){
|
|
49
|
+
var cards=document.querySelectorAll('[data-stage-card]');
|
|
50
|
+
var stages=[];
|
|
51
|
+
cards.forEach(function(card,idx){
|
|
52
|
+
var origName=card.getAttribute('data-stage-card');
|
|
53
|
+
var nameInput=card.querySelector('input[name^="name_"]');
|
|
54
|
+
var labelInput=card.querySelector('input[name^="label_"]');
|
|
55
|
+
var roleInput=card.querySelector('input[name^="role_"]');
|
|
56
|
+
var checked=Array.prototype.slice.call(card.querySelectorAll('input[type="checkbox"]:checked')).map(function(cb){return cb.value});
|
|
57
|
+
var roles=(roleInput.value||'').split(',').map(function(s){return s.trim()}).filter(Boolean);
|
|
58
|
+
stages.push({name:origName,label:labelInput.value||origName,order:idx,forward:checked,requires_role:roles});
|
|
59
|
+
});
|
|
60
|
+
return stages;
|
|
61
|
+
}
|
|
62
|
+
window.addStage=function(){
|
|
63
|
+
var input=document.getElementById('new-stage-name');
|
|
64
|
+
var name=(input.value||'').trim();
|
|
65
|
+
if(!name)return;
|
|
66
|
+
if(document.querySelector('[data-stage-card="'+name+'"]')){alert('Stage already exists');return}
|
|
67
|
+
var div=document.createElement('div');
|
|
68
|
+
div.className='card-clean';
|
|
69
|
+
div.style.marginBottom='12px';
|
|
70
|
+
div.setAttribute('data-stage-card',name);
|
|
71
|
+
var body=document.createElement('div');
|
|
72
|
+
body.className='card-clean-body';
|
|
73
|
+
var row=document.createElement('div');
|
|
74
|
+
row.style.cssText='display:flex;gap:12px;align-items:center;margin-bottom:8px';
|
|
75
|
+
var nameInput=document.createElement('input');
|
|
76
|
+
nameInput.type='text';nameInput.name='name_'+name;nameInput.value=name;nameInput.readOnly=true;nameInput.style.width='140px';
|
|
77
|
+
var labelInput=document.createElement('input');
|
|
78
|
+
labelInput.type='text';labelInput.name='label_'+name;labelInput.placeholder='Label';labelInput.style.flex='1';
|
|
79
|
+
var roleInput=document.createElement('input');
|
|
80
|
+
roleInput.type='text';roleInput.name='role_'+name;roleInput.placeholder='requires_role (comma-separated)';roleInput.style.width='200px';
|
|
81
|
+
var removeBtn=document.createElement('button');
|
|
82
|
+
removeBtn.type='button';removeBtn.className='btn-ghost-clean';removeBtn.textContent='Remove';
|
|
83
|
+
removeBtn.addEventListener('click',function(){window.removeStage(name)});
|
|
84
|
+
row.appendChild(nameInput);row.appendChild(labelInput);row.appendChild(roleInput);row.appendChild(removeBtn);
|
|
85
|
+
var hint=document.createElement('div');
|
|
86
|
+
hint.style.cssText='font-size:12px;color:var(--color-text-muted,#666)';
|
|
87
|
+
hint.textContent='Forward transitions: (save and reload to link)';
|
|
88
|
+
body.appendChild(row);body.appendChild(hint);
|
|
89
|
+
div.appendChild(body);
|
|
90
|
+
document.getElementById('stage-list').appendChild(div);
|
|
91
|
+
input.value='';
|
|
92
|
+
};
|
|
93
|
+
window.removeStage=function(name){
|
|
94
|
+
var card=document.querySelector('[data-stage-card="'+name+'"]');
|
|
95
|
+
if(card)card.remove();
|
|
96
|
+
};
|
|
97
|
+
window.saveWorkflow=function(workflowName){
|
|
98
|
+
var stages=collectStages();
|
|
99
|
+
var status=document.getElementById('save-status');
|
|
100
|
+
status.textContent='Saving...';
|
|
101
|
+
fetch('/api/workflow/'+workflowName+'/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({stages:stages})})
|
|
102
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
103
|
+
.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)'}})
|
|
104
|
+
.catch(function(err){status.textContent='Error: '+err.message;status.style.color='var(--color-danger,#ef4444)'});
|
|
105
|
+
};
|
|
106
|
+
})();`;
|
|
107
|
+
|
|
108
|
+
return page(user, `Workflow: ${workflowName} | Thatcher`, [{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/workflows', label: 'Workflows' }, { label: workflowName }], content, [script]);
|
|
109
|
+
}
|