thatcher 1.0.69 → 1.0.71
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 +1 -1
- package/src/config/spec-helpers.js +1 -1
- package/src/lib/config-generator-engine.js +38 -1
- package/src/lib/crud-handlers.js +1 -0
- package/src/lib/field-registry.js +3 -1
- package/src/lib/validation/entity-validators.js +50 -1
- package/src/server/server.js +40 -3
- package/src/ui/dashboard-renderer.js +11 -3
- package/src/ui/entity-renderer.js +38 -1
- package/src/ui/page-handler-admin.js +8 -2
- package/src/ui/page-handler-helpers.js +24 -4
- package/src/ui/page-handler.js +20 -2
package/package.json
CHANGED
|
@@ -96,7 +96,7 @@ export function getInitialState(spec) {
|
|
|
96
96
|
state[key] = false;
|
|
97
97
|
} else if (field.type === 'int' || field.type === 'decimal' || field.type === 'currency') {
|
|
98
98
|
state[key] = 0;
|
|
99
|
-
} else if (field.type === 'json' || field.type === 'multiselect') {
|
|
99
|
+
} else if (field.type === 'json' || field.type === 'multiselect' || field.type === 'multiref') {
|
|
100
100
|
state[key] = [];
|
|
101
101
|
} else if (field.type === 'file' || field.type === 'attachment') {
|
|
102
102
|
state[key] = null;
|
|
@@ -160,10 +160,47 @@ function withCrmDefaults(masterConfig) {
|
|
|
160
160
|
return changed ? { ...masterConfig, entities, workflows } : masterConfig;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
const PROJECT_ENTITY_DEFAULT = {
|
|
164
|
+
label: 'Project',
|
|
165
|
+
label_plural: 'Projects',
|
|
166
|
+
system_entity: true,
|
|
167
|
+
fields: {
|
|
168
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
169
|
+
description: { type: 'textarea', label: 'Description' },
|
|
170
|
+
owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
|
|
171
|
+
status: { type: 'enum', options: ['planning', 'active', 'on_hold', 'completed', 'cancelled'], default: 'planning', label: 'Status' },
|
|
172
|
+
start_date: { type: 'date', label: 'Start Date' },
|
|
173
|
+
due_date: { type: 'date', label: 'Due Date' },
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const TASK_ENTITY_DEFAULT = {
|
|
178
|
+
label: 'Task',
|
|
179
|
+
label_plural: 'Tasks',
|
|
180
|
+
system_entity: true,
|
|
181
|
+
fields: {
|
|
182
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
183
|
+
project_id: { type: 'ref', ref: 'project', label: 'Project' },
|
|
184
|
+
assignee_id: { type: 'ref', ref: 'user', label: 'Assignee' },
|
|
185
|
+
status: { type: 'enum', options: ['todo', 'in_progress', 'blocked', 'done'], default: 'todo', label: 'Status' },
|
|
186
|
+
priority: { type: 'enum', options: ['low', 'medium', 'high', 'urgent'], default: 'medium', label: 'Priority' },
|
|
187
|
+
due_date: { type: 'date', label: 'Due Date' },
|
|
188
|
+
depends_on: { type: 'multiref', ref: 'task', label: 'Depends On' },
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
function withProjectDefaults(masterConfig) {
|
|
193
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
194
|
+
let changed = false;
|
|
195
|
+
if (!entities.project) { entities.project = PROJECT_ENTITY_DEFAULT; changed = true; }
|
|
196
|
+
if (!entities.task) { entities.task = TASK_ENTITY_DEFAULT; changed = true; }
|
|
197
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
198
|
+
}
|
|
199
|
+
|
|
163
200
|
export class ConfigGeneratorEngine {
|
|
164
201
|
constructor(masterConfig) {
|
|
165
202
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
166
|
-
this.masterConfig = deepFreeze(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))));
|
|
203
|
+
this.masterConfig = deepFreeze(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))));
|
|
167
204
|
this.specCache = new LRUCache(100);
|
|
168
205
|
this.debugMode = false;
|
|
169
206
|
this._plugins = new Map();
|
package/src/lib/crud-handlers.js
CHANGED
|
@@ -11,6 +11,7 @@ export function coerceFieldValue(value, type) {
|
|
|
11
11
|
return value === true || value === 'true' || value === 1;
|
|
12
12
|
case 'json':
|
|
13
13
|
case 'multiselect':
|
|
14
|
+
case 'multiref':
|
|
14
15
|
case 'file':
|
|
15
16
|
case 'attachment':
|
|
16
17
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
@@ -36,12 +37,13 @@ export function deserializeField(value, type) {
|
|
|
36
37
|
switch (type) {
|
|
37
38
|
case 'json':
|
|
38
39
|
case 'multiselect':
|
|
40
|
+
case 'multiref':
|
|
39
41
|
case 'file':
|
|
40
42
|
case 'attachment':
|
|
41
43
|
try {
|
|
42
44
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
43
45
|
} catch {
|
|
44
|
-
return type === 'multiselect' ? [] : {};
|
|
46
|
+
return (type === 'multiselect' || type === 'multiref') ? [] : {};
|
|
45
47
|
}
|
|
46
48
|
case 'bool':
|
|
47
49
|
return Boolean(value);
|
|
@@ -76,6 +76,27 @@ export async function validateField(fieldDef, value, options = {}) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Multi-ref: array-of-ids referencing another entity (e.g. task.depends_on
|
|
80
|
+
// referencing other task ids) -- every id must resolve to a real record,
|
|
81
|
+
// the same existence guarantee a single 'ref' field already gets.
|
|
82
|
+
if (fieldDef.type === 'multiref' && fieldDef.ref) {
|
|
83
|
+
const arr = Array.isArray(value) ? value : (typeof value === 'string' ? JSON.parse(value) : []);
|
|
84
|
+
try {
|
|
85
|
+
const { getBy } = await import('@/lib/busybase/store');
|
|
86
|
+
const refTable = fieldDef.ref === 'user' ? 'users' : fieldDef.ref;
|
|
87
|
+
for (const refId of arr) {
|
|
88
|
+
if (!(await getBy(refTable, 'id', refId))) {
|
|
89
|
+
return {
|
|
90
|
+
valid: false,
|
|
91
|
+
error: `${fieldDef.ref.charAt(0).toUpperCase() + fieldDef.ref.slice(1)} with id '${refId}' not found`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Reference table might not exist yet
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
return { valid: true };
|
|
80
101
|
}
|
|
81
102
|
|
|
@@ -94,7 +115,7 @@ function validateType(fieldDef, value, fieldName) {
|
|
|
94
115
|
if (typeof value !== 'boolean') return `Field '${fieldName}' must be a boolean`;
|
|
95
116
|
} else if (type === 'timestamp' || type === 'date') {
|
|
96
117
|
if (isNaN(Number(value))) return `Field '${fieldName}' must be a valid timestamp`;
|
|
97
|
-
} else if (type === 'multiselect') {
|
|
118
|
+
} else if (type === 'multiselect' || type === 'multiref') {
|
|
98
119
|
const arr = Array.isArray(value) ? value : (typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : null);
|
|
99
120
|
if (!Array.isArray(arr)) return `Field '${fieldName}' must be an array`;
|
|
100
121
|
} else if (type === 'file' || type === 'attachment') {
|
|
@@ -176,6 +197,31 @@ async function checkUnique(fieldDef, value, { fieldName, entityName, existingRec
|
|
|
176
197
|
return null;
|
|
177
198
|
}
|
|
178
199
|
|
|
200
|
+
// Task completion cannot be a client-side/UI-only rule -- a raw API PATCH
|
|
201
|
+
// setting status=done must be rejected server-side the same as any other
|
|
202
|
+
// write, or dependency ordering is purely cosmetic. depends_on is checked
|
|
203
|
+
// against the CURRENT stored state of each referenced task (never trusting
|
|
204
|
+
// a value the caller might also be trying to change in the same payload).
|
|
205
|
+
async function checkTaskDependencies(entityName, changes, existingRecord) {
|
|
206
|
+
if (entityName !== 'task') return null;
|
|
207
|
+
if (changes.status !== 'done') return null;
|
|
208
|
+
|
|
209
|
+
const dependsOn = changes.depends_on !== undefined ? changes.depends_on : existingRecord?.depends_on;
|
|
210
|
+
const ids = Array.isArray(dependsOn) ? dependsOn : (typeof dependsOn === 'string' && dependsOn ? (() => { try { return JSON.parse(dependsOn); } catch { return []; } })() : []);
|
|
211
|
+
if (!ids.length) return null;
|
|
212
|
+
|
|
213
|
+
const { get } = await import('@/lib/busybase/store');
|
|
214
|
+
const incomplete = [];
|
|
215
|
+
for (const depId of ids) {
|
|
216
|
+
const dep = await get('task', depId);
|
|
217
|
+
if (!dep || dep.status !== 'done') incomplete.push(depId);
|
|
218
|
+
}
|
|
219
|
+
if (incomplete.length) {
|
|
220
|
+
return `Cannot mark done: depends on incomplete task(s) ${incomplete.join(', ')}`;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
179
225
|
export async function validateUpdate(entityName, changes, existingRecord) {
|
|
180
226
|
const spec = getSpec(entityName);
|
|
181
227
|
const errors = {};
|
|
@@ -202,6 +248,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
|
|
|
202
248
|
if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
|
|
203
249
|
}
|
|
204
250
|
|
|
251
|
+
const depErr = await checkTaskDependencies(entityName, changes, existingRecord);
|
|
252
|
+
if (depErr) errors.status = depErr;
|
|
253
|
+
|
|
205
254
|
return errors;
|
|
206
255
|
}
|
|
207
256
|
|
package/src/server/server.js
CHANGED
|
@@ -405,39 +405,76 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
405
405
|
}
|
|
406
406
|
break;
|
|
407
407
|
|
|
408
|
-
case 'POST':
|
|
408
|
+
case 'POST': {
|
|
409
409
|
// enforceEditPermissions is the authoritative field-level write check --
|
|
410
410
|
// it throws if the payload touches a field the caller's role isn't
|
|
411
411
|
// editable_by, so a hand-crafted request bypassing the rendered form
|
|
412
412
|
// cannot smuggle a restricted field in.
|
|
413
413
|
permissionService.enforceEditPermissions(user, spec, body);
|
|
414
414
|
result = await thatcher.create(entity, body, user);
|
|
415
|
+
const { logAction } = await import('../lib/busybase/audit.js');
|
|
416
|
+
logAction(entity, result.id, 'create', user.id, null, result);
|
|
415
417
|
result = permissionService.filterFields(user, spec, result);
|
|
416
418
|
status = 201;
|
|
417
419
|
break;
|
|
420
|
+
}
|
|
418
421
|
|
|
419
422
|
case 'PUT':
|
|
420
|
-
case 'PATCH':
|
|
423
|
+
case 'PATCH': {
|
|
421
424
|
if (!id) {
|
|
422
425
|
res.writeHead(400);
|
|
423
426
|
res.end(JSON.stringify({ error: 'ID required' }));
|
|
424
427
|
return;
|
|
425
428
|
}
|
|
429
|
+
// Same row/org-access gap the GET path had: an update by id must be
|
|
430
|
+
// scoped the same way a read is, or a user could edit a record
|
|
431
|
+
// outside their org purely because PUT was never checked.
|
|
432
|
+
const existingForUpdate = await thatcher.get(entity, id, { user });
|
|
433
|
+
if (!existingForUpdate) {
|
|
434
|
+
res.writeHead(404);
|
|
435
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
426
438
|
permissionService.enforceEditPermissions(user, spec, body);
|
|
439
|
+
{
|
|
440
|
+
// validateUpdate is the authoritative server-side check for both
|
|
441
|
+
// generic field-type constraints AND entity-specific business
|
|
442
|
+
// rules (e.g. task dependency completion) -- this raw-CRUD path
|
|
443
|
+
// never called it at all, so those rules were UI-only until now.
|
|
444
|
+
const { validateUpdate, hasErrors } = await import('../lib/validation/entity-validators.js');
|
|
445
|
+
const updateErrors = await validateUpdate(entity, body, existingForUpdate);
|
|
446
|
+
if (hasErrors(updateErrors)) {
|
|
447
|
+
res.writeHead(400);
|
|
448
|
+
res.end(JSON.stringify({ error: 'Validation failed', errors: updateErrors }));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
427
452
|
result = await thatcher.update(entity, id, body, user);
|
|
453
|
+
const { logAction: logUpdate } = await import('../lib/busybase/audit.js');
|
|
454
|
+
logUpdate(entity, id, 'update', user.id, existingForUpdate, result);
|
|
428
455
|
result = permissionService.filterFields(user, spec, result);
|
|
429
456
|
break;
|
|
457
|
+
}
|
|
430
458
|
|
|
431
|
-
case 'DELETE':
|
|
459
|
+
case 'DELETE': {
|
|
432
460
|
if (!id) {
|
|
433
461
|
res.writeHead(400);
|
|
434
462
|
res.end(JSON.stringify({ error: 'ID required' }));
|
|
435
463
|
return;
|
|
436
464
|
}
|
|
465
|
+
const existingForDelete = await thatcher.get(entity, id, { user });
|
|
466
|
+
if (!existingForDelete) {
|
|
467
|
+
res.writeHead(404);
|
|
468
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
437
471
|
await thatcher.delete(entity, id);
|
|
472
|
+
const { logAction: logDelete } = await import('../lib/busybase/audit.js');
|
|
473
|
+
logDelete(entity, id, 'delete', user.id, existingForDelete, null);
|
|
438
474
|
res.writeHead(204);
|
|
439
475
|
res.end();
|
|
440
476
|
return;
|
|
477
|
+
}
|
|
441
478
|
|
|
442
479
|
default:
|
|
443
480
|
res.writeHead(405);
|
|
@@ -100,12 +100,20 @@ export function renderAuditDashboard(user, auditData = {}) {
|
|
|
100
100
|
|
|
101
101
|
const statsHtml = `<div class="stats-row">${[
|
|
102
102
|
{ label: 'Total Actions (30d)', value: summary.total_actions || 0 },
|
|
103
|
-
{ label: '
|
|
104
|
-
{ label: '
|
|
105
|
-
{ label: '
|
|
103
|
+
{ label: 'Creates', value: summary.creates || 0 },
|
|
104
|
+
{ label: 'Updates', value: summary.updates || 0 },
|
|
105
|
+
{ label: 'Deletes', value: summary.deletes || 0 },
|
|
106
106
|
].map(s => `<div class="stat-card"><div class="stat-card-value">${s.value}</div><div class="stat-card-label">${s.label}</div></div>`).join('')}</div>`;
|
|
107
107
|
|
|
108
108
|
const content = `<div class="page-header"><h1 class="page-title">Audit Dashboard</h1><a href="/permission_audit" class="btn-ghost-clean">View All Records</a></div>
|
|
109
|
+
<form method="GET" style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
|
110
|
+
<input type="text" name="entity_type" placeholder="Entity type" class="form-input" style="width:160px">
|
|
111
|
+
<input type="text" name="user_id" placeholder="User ID" class="form-input" style="width:160px">
|
|
112
|
+
<input type="date" name="from_date" class="form-input">
|
|
113
|
+
<input type="date" name="to_date" class="form-input">
|
|
114
|
+
<button type="submit" class="btn-primary-clean">Filter</button>
|
|
115
|
+
<a href="/admin/audit" class="btn-ghost-clean">Clear</a>
|
|
116
|
+
</form>
|
|
109
117
|
${statsHtml}
|
|
110
118
|
<div class="table-wrap">
|
|
111
119
|
<div class="table-toolbar">
|
|
@@ -77,6 +77,10 @@ function formatFieldValue(k, v, entityName, f) {
|
|
|
77
77
|
const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
|
|
78
78
|
return arr.length ? arr.map(x => `<span class="pill pill-neutral" style="margin-right:4px">${esc(x)}</span>`).join('') : '-'
|
|
79
79
|
}
|
|
80
|
+
if (f?.type === 'multiref' && f.ref) {
|
|
81
|
+
const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
|
|
82
|
+
return arr.length ? arr.map(x => `<a href="/${esc(f.ref)}/${esc(x)}" class="pill pill-neutral" style="margin-right:4px;text-decoration:none">${esc(x)}</a>`).join('') : '-'
|
|
83
|
+
}
|
|
80
84
|
if (f?.type === 'file' || f?.type === 'attachment') {
|
|
81
85
|
const meta = typeof v === 'object' && v ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return null } })() : null)
|
|
82
86
|
return meta?.url ? `<a href="${esc(meta.url)}" class="text-primary hover:underline" target="_blank" rel="noopener">${esc(meta.filename || 'Download')}</a>` : '-'
|
|
@@ -84,7 +88,34 @@ function formatFieldValue(k, v, entityName, f) {
|
|
|
84
88
|
return fmtVal(v, k)
|
|
85
89
|
}
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
function formatHistoryDiff(before, after) {
|
|
92
|
+
const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})])
|
|
93
|
+
const changed = [...keys].filter(k => JSON.stringify(before?.[k]) !== JSON.stringify(after?.[k]) && !['updated_at', '_version'].includes(k))
|
|
94
|
+
if (!changed.length) return ''
|
|
95
|
+
return changed.map(k => {
|
|
96
|
+
const from = before?.[k] !== undefined ? esc(String(before[k])) : '<em>none</em>'
|
|
97
|
+
const to = after?.[k] !== undefined ? esc(String(after[k])) : '<em>none</em>'
|
|
98
|
+
return `<div class="history-field-change"><strong>${esc(k)}</strong>: ${from} → ${to}</div>`
|
|
99
|
+
}).join('')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderHistorySection(history) {
|
|
103
|
+
if (!history || !history.length) return ''
|
|
104
|
+
const rows = history.map(h => {
|
|
105
|
+
const when = esc(new Date((h.createdAt || 0) * 1000).toLocaleString('en-ZA'))
|
|
106
|
+
const diff = h.action === 'update' ? formatHistoryDiff(h.beforeState, h.afterState) : ''
|
|
107
|
+
return `<div class="history-entry">
|
|
108
|
+
<div class="history-entry-header"><span class="pill pill-info">${esc(h.action || '-')}</span><span class="history-entry-time">${when}</span><span class="history-entry-user">${esc(h.userId || '-')}</span></div>
|
|
109
|
+
${diff ? `<div class="history-entry-diff">${diff}</div>` : ''}
|
|
110
|
+
</div>`
|
|
111
|
+
}).join('')
|
|
112
|
+
return `<div class="card-clean" style="margin-top:1.5rem"><div class="card-clean-body">
|
|
113
|
+
<h3 style="margin-bottom:12px">History</h3>
|
|
114
|
+
<div class="history-list">${rows}</div>
|
|
115
|
+
</div></div>`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
88
119
|
const label = spec?.label || entityName
|
|
89
120
|
// A field the caller's role isn't visible_to must never reach rendered HTML,
|
|
90
121
|
// not just be CSS-hidden -- filterFields drops it from `fields` entirely so
|
|
@@ -135,6 +166,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
135
166
|
<div class="card-clean">
|
|
136
167
|
<div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
|
|
137
168
|
</div>
|
|
169
|
+
${renderHistorySection(history)}
|
|
138
170
|
`
|
|
139
171
|
|
|
140
172
|
// Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
|
|
@@ -185,6 +217,11 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
185
217
|
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(ov)}" class="checkbox checkbox-primary" ${selected.has(ov)?'checked':''}/><span>${esc(ol)}</span></label>` }).join('')
|
|
186
218
|
return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
|
|
187
219
|
}
|
|
220
|
+
if (f.type === 'multiref' && refOptions[k]) {
|
|
221
|
+
const selected = new Set(Array.isArray(val) ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return [] } })() : []))
|
|
222
|
+
const opts = refOptions[k].filter(o => o.value !== item?.id).map(o => `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(o.value)}" class="checkbox checkbox-primary" ${selected.has(o.value)?'checked':''}/><span>${esc(o.label)}</span></label>`).join('') || '<p style="font-size:0.8rem;color:var(--color-text-muted)">No options available</p>'
|
|
223
|
+
return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
|
|
224
|
+
}
|
|
188
225
|
if (f.type === 'currency') {
|
|
189
226
|
const symbol = esc(f.currency_symbol || '$')
|
|
190
227
|
const decimalVal = typeof val === 'number' ? (val / 100).toFixed(2) : val
|
|
@@ -21,10 +21,16 @@ async function lazyRenderer(name) {
|
|
|
21
21
|
return import(`file://${__dirname_adm}${name}?t=${t}`);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export async function handleAdminPage(normalized, segments, user) {
|
|
24
|
+
export async function handleAdminPage(normalized, segments, user, req) {
|
|
25
25
|
if (normalized === '/admin/audit') {
|
|
26
26
|
if (!isPartner(user) && !isManager(user)) return renderAccessDenied(user, 'admin', 'view');
|
|
27
|
-
const
|
|
27
|
+
const params = req ? new URL(req.url, `http://${req.headers.host}`).searchParams : new URLSearchParams();
|
|
28
|
+
const filters = {};
|
|
29
|
+
if (params.get('entity_type')) filters.entityType = params.get('entity_type');
|
|
30
|
+
if (params.get('user_id')) filters.userId = params.get('user_id');
|
|
31
|
+
if (params.get('from_date')) filters.fromDate = Math.floor(new Date(params.get('from_date')).getTime() / 1000);
|
|
32
|
+
if (params.get('to_date')) filters.toDate = Math.floor(new Date(params.get('to_date')).getTime() / 1000) + 86400;
|
|
33
|
+
const auditData = await getAuditData(filters);
|
|
28
34
|
return renderAuditDashboard(user, auditData);
|
|
29
35
|
}
|
|
30
36
|
if (!isPartner(user)) return renderAccessDenied(user, 'admin', 'view');
|
|
@@ -19,7 +19,7 @@ export function resolveEnumOptions(spec) {
|
|
|
19
19
|
|
|
20
20
|
export async function getRefOptions(spec) {
|
|
21
21
|
const refOptions = {};
|
|
22
|
-
for (const [fieldKey, field] of Object.entries(spec.fields || {}).filter(([, f]) => f.type === 'ref' && f.ref)) {
|
|
22
|
+
for (const [fieldKey, field] of Object.entries(spec.fields || {}).filter(([, f]) => (f.type === 'ref' || f.type === 'multiref') && f.ref)) {
|
|
23
23
|
try { refOptions[fieldKey] = (await list(field.ref, {})).map(r => ({ value: r.id, label: r.name || r.title || r.label || r.email || r.id })); }
|
|
24
24
|
catch { refOptions[fieldKey] = []; }
|
|
25
25
|
}
|
|
@@ -76,12 +76,32 @@ export async function getSystemConfig() {
|
|
|
76
76
|
} catch { return {}; }
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export async function getAuditData() {
|
|
79
|
+
export async function getAuditData(filters = {}) {
|
|
80
80
|
try {
|
|
81
|
-
|
|
81
|
+
// audit_logs is the real record-change trail logAction() writes (create/
|
|
82
|
+
// update/delete/transition on every entity); permission_audit is a
|
|
83
|
+
// separate, narrower RBAC grant/revoke/role_change log. This dashboard's
|
|
84
|
+
// own column headers (Entity Type, Entity ID) describe the former, so it
|
|
85
|
+
// reads audit_logs -- the same table entity-renderer.js's per-record
|
|
86
|
+
// History section now reads, giving one consistent audit surface.
|
|
87
|
+
const where = {};
|
|
88
|
+
if (filters.entityType) where.entity_type = filters.entityType;
|
|
89
|
+
if (filters.userId) where.user_id = filters.userId;
|
|
90
|
+
let audits = await list('audit_logs', where);
|
|
91
|
+
if (filters.fromDate) audits = audits.filter(a => (a.timestamp || a.created_at) >= filters.fromDate);
|
|
92
|
+
if (filters.toDate) audits = audits.filter(a => (a.timestamp || a.created_at) <= filters.toDate);
|
|
93
|
+
audits.sort((a, b) => (b.timestamp || b.created_at || 0) - (a.timestamp || a.created_at || 0));
|
|
82
94
|
const cutoff = Math.floor(Date.now() / 1000) - 30 * 86400;
|
|
83
95
|
const recent = audits.filter(a => (a.timestamp || a.created_at) > cutoff);
|
|
84
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
summary: {
|
|
98
|
+
total_actions: recent.length,
|
|
99
|
+
creates: recent.filter(a => a.action === 'create').length,
|
|
100
|
+
updates: recent.filter(a => a.action === 'update').length,
|
|
101
|
+
deletes: recent.filter(a => a.action === 'delete').length,
|
|
102
|
+
},
|
|
103
|
+
recentActivity: audits.slice(0, 200),
|
|
104
|
+
};
|
|
85
105
|
} catch { return { summary: {}, recentActivity: [] }; }
|
|
86
106
|
}
|
|
87
107
|
|
package/src/ui/page-handler.js
CHANGED
|
@@ -115,8 +115,26 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
115
115
|
if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
|
|
116
116
|
if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
|
|
117
117
|
const [resolvedItem] = resolveRefFields([item], spec);
|
|
118
|
+
// get(...,{user}) above already enforced row/org access for this exact
|
|
119
|
+
// record (a denied/absent record returns null before this point), so
|
|
120
|
+
// fetching its audit trail here is scoped by construction -- there is no
|
|
121
|
+
// separate access check to duplicate for the history view.
|
|
122
|
+
let history = [];
|
|
123
|
+
try {
|
|
124
|
+
const { getEntityAuditTrail } = await import('@/lib/busybase/audit-reads.js');
|
|
125
|
+
const { permissionService } = await import('@/services/permission.service.js');
|
|
126
|
+
const rawHistory = await getEntityAuditTrail(entityName, id);
|
|
127
|
+
// Field-level RBAC must hold for audit diffs too -- a viewer who can't see
|
|
128
|
+
// a restricted field on the live record must not see it in a before/after
|
|
129
|
+
// snapshot either, or field-level RBAC would be trivially bypassed via history.
|
|
130
|
+
history = rawHistory.map(h => ({
|
|
131
|
+
...h,
|
|
132
|
+
beforeState: h.beforeState ? permissionService.filterFields(user, spec, h.beforeState) : null,
|
|
133
|
+
afterState: h.afterState ? permissionService.filterFields(user, spec, h.afterState) : null,
|
|
134
|
+
}));
|
|
135
|
+
} catch {}
|
|
118
136
|
const { renderEntityDetail: lazyEntityDetail } = await lazyRenderer('entity-renderer.js');
|
|
119
|
-
return lazyEntityDetail(entityName, resolvedItem, spec, user);
|
|
137
|
+
return lazyEntityDetail(entityName, resolvedItem, spec, user, history);
|
|
120
138
|
}
|
|
121
139
|
async function handleClientSubRoute(user, clientId, subRoute) {
|
|
122
140
|
if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
|
|
@@ -162,7 +180,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
162
180
|
if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
|
|
163
181
|
if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100,user})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
|
|
164
182
|
if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
|
|
165
|
-
if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user);
|
|
183
|
+
if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user, req);
|
|
166
184
|
if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
|
|
167
185
|
if (isClerk(user) && segments.length >= 1 && ['user', 'team'].includes(segments[0])) return renderAccessDenied(user, segments[0], 'list');
|
|
168
186
|
if (normalized === '/mwr' || normalized === '/mwr/home') {
|