thatcher 1.0.7 → 1.0.34

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.
Files changed (174) hide show
  1. package/README.md +4 -16
  2. package/package.json +12 -18
  3. package/src/adapters/google-auth.js +4 -43
  4. package/src/adapters/google-drive.js +12 -67
  5. package/src/app/api/audit/dashboard/route.js +14 -11
  6. package/src/app/api/audit/logs/route.js +2 -2
  7. package/src/app/api/audit/permissions/[id]/route.js +6 -3
  8. package/src/app/api/audit/permissions/route.js +10 -7
  9. package/src/app/api/audit/permissions/stats/route.js +8 -5
  10. package/src/app/api/audit/route.js +4 -4
  11. package/src/app/api/audit/stats/route.js +1 -1
  12. package/src/app/api/auth/google/callback/route.js +5 -2
  13. package/src/app/api/auth/google/route.js +4 -1
  14. package/src/app/api/auth/login/route.js +5 -2
  15. package/src/app/api/auth/mwr-bridge/route.js +11 -21
  16. package/src/app/api/auth/password-reset/route.js +15 -14
  17. package/src/app/api/cron/trigger/route.js +4 -1
  18. package/src/app/api/debug/[[...path]]/route.js +6 -5
  19. package/src/app/api/debug/sqlite/route.js +7 -12
  20. package/src/app/api/debug/sync/route.js +5 -5
  21. package/src/app/api/domains/route.js +4 -1
  22. package/src/app/api/email/allocate/batch/route.js +19 -29
  23. package/src/app/api/email/allocate/route.js +16 -17
  24. package/src/app/api/email/receive/route.js +31 -42
  25. package/src/app/api/email/send/route.js +28 -16
  26. package/src/app/api/email/unallocated/route.js +10 -13
  27. package/src/app/api/files/[id]/route.js +4 -1
  28. package/src/app/api/formula/[[...path]]/route.js +0 -4
  29. package/src/app/api/health/route.js +10 -9
  30. package/src/app/api/metrics/route.js +1 -1
  31. package/src/cli.js +4 -4
  32. package/src/config/config-loader.js +1 -21
  33. package/src/config/constants.js +0 -35
  34. package/src/config/env.js +0 -30
  35. package/src/config/spec-helpers.js +6 -95
  36. package/src/engine.js +23 -0
  37. package/src/engine.server.js +8 -78
  38. package/src/index.js +42 -67
  39. package/src/lib/alert-manager.js +7 -3
  40. package/src/lib/api-helpers.js +4 -3
  41. package/src/lib/api.js +10 -10
  42. package/src/lib/audit-logger-enhanced.js +77 -83
  43. package/src/lib/auth-middleware.js +1 -32
  44. package/src/lib/auth-route-helpers.js +5 -0
  45. package/src/lib/busybase-adapter.js +0 -5
  46. package/src/lib/busybase-audit-reads.js +142 -0
  47. package/src/lib/busybase-audit.js +41 -0
  48. package/src/lib/busybase-lucia-adapter.js +72 -0
  49. package/src/lib/busybase-store.js +338 -0
  50. package/src/lib/config-field-helpers.js +0 -17
  51. package/src/lib/config-generator-engine.js +19 -36
  52. package/src/lib/config-helpers.js +0 -21
  53. package/src/lib/crud-action-helpers.js +2 -2
  54. package/src/lib/crud-factory.js +1 -16
  55. package/src/lib/crud-handlers.js +17 -52
  56. package/src/lib/csrf-protection.js +5 -2
  57. package/src/lib/date-utils.js +3 -127
  58. package/src/lib/debug-registry.js +0 -5
  59. package/src/lib/domain-loader.js +4 -1
  60. package/src/lib/email-sender.js +25 -23
  61. package/src/lib/error-handler.js +5 -41
  62. package/src/lib/error-recovery.js +10 -7
  63. package/src/lib/error-resilience.js +7 -5
  64. package/src/lib/events-engine.js +9 -6
  65. package/src/lib/export-sink.js +0 -5
  66. package/src/lib/field-iterator.js +7 -76
  67. package/src/lib/field-registry.js +0 -22
  68. package/src/lib/generic-crud-handler.js +0 -16
  69. package/src/lib/hook-engine.js +5 -53
  70. package/src/lib/hot-reload/checkpoint.js +5 -2
  71. package/src/lib/hot-reload/promise-container.js +7 -3
  72. package/src/lib/hot-reload/route-wrapper.js +4 -1
  73. package/src/lib/hot-reload/supervisor.js +6 -3
  74. package/src/lib/hyperformula-service.js +0 -5
  75. package/src/lib/id-helpers.js +10 -0
  76. package/src/lib/index.js +4 -7
  77. package/src/lib/log-aggregator.js +0 -1
  78. package/src/lib/logger.js +0 -15
  79. package/src/lib/metrics-collector.js +2 -46
  80. package/src/lib/next-compat.js +15 -1
  81. package/src/lib/observability-bootstrap.js +0 -5
  82. package/src/lib/perf-monitor.js +4 -1
  83. package/src/lib/perf-profiler.js +0 -5
  84. package/src/lib/query-string-adapter.js +5 -47
  85. package/src/lib/realtime-server.js +4 -26
  86. package/src/lib/request-tracing.js +0 -5
  87. package/src/lib/resource-monitor.js +4 -1
  88. package/src/lib/response-formatter.js +0 -38
  89. package/src/lib/route-helpers.js +3 -1
  90. package/src/lib/safe-json.js +5 -1
  91. package/src/lib/server-bootstrap.js +10 -9
  92. package/src/lib/stage-pipeline.js +2 -2
  93. package/src/lib/state-transport-client.js +4 -1
  94. package/src/lib/static-server.js +2 -2
  95. package/src/lib/status-helpers.js +2 -51
  96. package/src/lib/tracing.js +0 -5
  97. package/src/lib/utils.js +6 -67
  98. package/src/lib/validate.js +5 -88
  99. package/src/lib/validators.js +0 -38
  100. package/src/lib/with-audit-logging.js +1 -1
  101. package/src/lib/workflow-engine.js +10 -77
  102. package/src/lib/xstate-workflow-engine.js +3 -9
  103. package/src/plugins/index.js +5 -3
  104. package/src/server/server.js +26 -27
  105. package/src/services/collaborator-role.service.js +21 -77
  106. package/src/services/email-sender.js +7 -34
  107. package/src/services/notification-engine.js +12 -33
  108. package/src/services/permission.service.js +10 -73
  109. package/src/ui/advanced-search-renderer.js +3 -2
  110. package/src/ui/advanced-widgets.js +1 -1
  111. package/src/ui/auth-pages.js +10 -9
  112. package/src/ui/auth-styles.js +1 -1
  113. package/src/ui/client.js +1 -1
  114. package/src/ui/component-engine.js +1 -1
  115. package/src/ui/dashboard-renderer.js +5 -5
  116. package/src/ui/dialog-engine.js +6 -9
  117. package/src/ui/dialog-factory.js +2 -2
  118. package/src/ui/engagement-cards.js +7 -7
  119. package/src/ui/engagement-dialogs.js +2 -3
  120. package/src/ui/engagement-grid-renderer.js +20 -19
  121. package/src/ui/entity-renderer.js +56 -42
  122. package/src/ui/file-dialogs.js +25 -9
  123. package/src/ui/flexup-report-renderer.js +21 -18
  124. package/src/ui/format-helpers.js +27 -28
  125. package/src/ui/highlight-threading-renderer.js +9 -9
  126. package/src/ui/job-management-renderer.js +18 -16
  127. package/src/ui/layout.js +51 -25
  128. package/src/ui/ml-console-renderer.js +15 -16
  129. package/src/ui/monitoring-dashboard-client.js +9 -8
  130. package/src/ui/notifications-renderer.js +11 -10
  131. package/src/ui/page-handler-admin.js +32 -23
  132. package/src/ui/page-handler-helpers.js +27 -23
  133. package/src/ui/page-handler-reviews.js +71 -35
  134. package/src/ui/page-handler-rfi.js +28 -14
  135. package/src/ui/page-handler.js +65 -48
  136. package/src/ui/picker-dialogs.js +26 -19
  137. package/src/ui/render-helpers.js +84 -46
  138. package/src/ui/review-comparison-renderer.js +8 -7
  139. package/src/ui/review-detail-panels.js +8 -9
  140. package/src/ui/review-detail-renderer.js +3 -3
  141. package/src/ui/review-detail-script.js +13 -12
  142. package/src/ui/review-mwr-renderer.js +6 -5
  143. package/src/ui/review-renderer.js +7 -6
  144. package/src/ui/review-widgets.js +3 -3
  145. package/src/ui/rfi-detail-renderer.js +37 -20
  146. package/src/ui/rfi-report-renderer.js +14 -9
  147. package/src/ui/settings-renderer-advanced.js +21 -18
  148. package/src/ui/settings-renderer-advanced2.js +20 -19
  149. package/src/ui/settings-renderer-teams.js +4 -4
  150. package/src/ui/settings-renderer.js +4 -4
  151. package/src/ui/spacing-system.js +5 -2
  152. package/src/ui/standalone-login.js +2 -2
  153. package/src/ui/styles.css +2 -1
  154. package/src/ui/styles2.css +75 -5
  155. package/src/ui/test-page.js +4 -4
  156. package/src/ui/widgets.js +20 -15
  157. package/src/lib/audit-logger.js +0 -193
  158. package/src/lib/database-core.js +0 -255
  159. package/src/lib/database-migrations.js +0 -350
  160. package/src/lib/db-backup.js +0 -97
  161. package/src/lib/error-boundary.js +0 -134
  162. package/src/lib/errors.js +0 -69
  163. package/src/lib/health-monitor.js +0 -134
  164. package/src/lib/index-optimizer.js +0 -96
  165. package/src/lib/query-engine-write.js +0 -221
  166. package/src/lib/query-engine.js +0 -446
  167. package/src/ui/collaboration-dialogs.js +0 -31
  168. package/src/ui/global-tags.js +0 -144
  169. package/src/ui/idle-logout.js +0 -156
  170. package/src/ui/letter-dialogs.js +0 -37
  171. package/src/ui/password-reset-page.js +0 -146
  172. package/src/ui/perf-renderer.js +0 -71
  173. package/src/ui/rfi-renderer.js +0 -194
  174. package/src/ui/validation-ui.js +0 -147
@@ -11,14 +11,14 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
11
11
  const statusBadge = (s) => { const color = s === 'answered' || s === 'accepted' ? '#22c55e' : s === 'overdue' ? '#ef4444' : '#f59e0b'; return `<span style="padding:2px 8px;border-radius:12px;font-size:0.7rem;font-weight:700;background:${color}20;color:${color}">${s || 'pending'}</span>`; };
12
12
  const qRows = questions.map((q, i) => {
13
13
  const resps = respMap[q.id] || [];
14
- const respHtml = resps.length ? resps.map(r => `<div style="margin-top:6px;padding:6px 10px;background:#f8fafc;border-left:3px solid #3b82f6;font-size:0.75rem">${esc(r.response||'')}<div style="margin-top:4px;color:#94a3b8;font-size:0.65rem">${fmtDate(r.created_at)}</div></div>`).join('') : '<div style="color:#94a3b8;font-size:0.75rem;margin-top:4px">No response yet</div>';
14
+ const respHtml = resps.length ? resps.map(r => `<div style="margin-top:6px;padding:6px 10px;background:#f8fafc;border-left:3px solid #3b82f6;font-size:0.75rem">${esc(r.response||'')}<div style="margin-top:4px;color:#475569;font-size:0.65rem">${fmtDate(r.created_at)}</div></div>`).join('') : '<div style="color:#475569;font-size:0.75rem;margin-top:4px">No response yet</div>';
15
15
  return `<div style="margin-bottom:16px;padding:12px;border:1px solid #e2e8f0;border-radius:6px;break-inside:avoid">
16
16
  <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px">
17
17
  <div style="font-size:0.85rem;font-weight:600">${i+1}. ${esc(q.question||q.text||'Question')}</div>
18
18
  ${statusBadge(q.status)}
19
19
  </div>
20
- ${q.due_date ? `<div style="color:#64748b;font-size:0.7rem;margin-bottom:6px">Due: ${fmtDate(q.due_date)}</div>` : ''}
21
- ${q.assigned_to ? `<div style="color:#64748b;font-size:0.7rem;margin-bottom:6px">Assigned: ${esc(q.assigned_to_name||q.assigned_to)}</div>` : ''}
20
+ ${q.due_date ? `<div style="color:#475569;font-size:0.7rem;margin-bottom:6px">Due: ${fmtDate(q.due_date)}</div>` : ''}
21
+ ${q.assigned_to ? `<div style="color:#475569;font-size:0.7rem;margin-bottom:6px">Assigned: ${esc(q.assigned_to_name||q.assigned_to)}</div>` : ''}
22
22
  ${respHtml}
23
23
  </div>`;
24
24
  }).join('');
@@ -27,20 +27,25 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
27
27
  return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/>
28
28
  <title>RFI Report — ${esc(rfi.name || rfi.title || 'RFI')}</title>
29
29
  <style>
30
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 24px; color: #1e293b; font-size: 14px; }
30
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 24px; color: #1e293b; font-size: 14px; background: #fff; }
31
31
  .header { border-bottom: 2px solid #3b82f6; padding-bottom: 16px; margin-bottom: 24px; }
32
- .meta { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0; font-size: 0.8rem; color: #64748b; }
32
+ .meta { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0; font-size: 0.8rem; color: #475569; }
33
33
  .meta-item { display: flex; flex-direction: column; }
34
34
  .meta-label { font-weight: 600; margin-bottom: 2px; }
35
35
  .progress-bar { height: 8px; background: #e2e8f0; border-radius: 4px; margin: 8px 0; }
36
36
  .progress-fill { height: 100%; border-radius: 4px; background: #3b82f6; }
37
37
  .no-print { padding: 12px 0; }
38
38
  @media print { .no-print { display: none; } body { padding: 12px; } }
39
+ @media (prefers-color-scheme: dark) {
40
+ body { color: #e2e8f0; background: #1e293b; }
41
+ .meta { color: #94a3b8; }
42
+ .progress-bar { background: #334155; }
43
+ }
39
44
  </style></head><body>
40
- <div class="no-print"><button onclick="window.print()" style="padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem">Print / Save PDF</button> <a href="/rfi/${esc(rfi.id)}" style="margin-left:12px;font-size:0.85rem;color:#3b82f6">← Back to RFI</a></div>
45
+ <div class="no-print"><button onclick="window.print()" aria-label="Print or save as PDF" style="padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem">Print / Save PDF</button> <a href="/rfi/${esc(rfi.id)}" title="Back to RFI" style="margin-left:12px;font-size:0.85rem;color:#3b82f6">&lt;- Back to RFI</a></div>
41
46
  <div class="header">
42
47
  <div style="font-size:1.4rem;font-weight:700">${esc(rfi.name||rfi.title||'RFI')}</div>
43
- <div style="color:#64748b;font-size:0.85rem;margin-top:4px">${esc(e.name||e.client_name||'')}</div>
48
+ <div style="color:#475569;font-size:0.85rem;margin-top:4px">${esc(e.name||e.client_name||'')}</div>
44
49
  <div class="meta">
45
50
  <div class="meta-item"><span class="meta-label">Status</span>${esc(rfi.status||'-')}</div>
46
51
  <div class="meta-item"><span class="meta-label">Created</span>${fmtDate(rfi.created_at)}</div>
@@ -50,7 +55,7 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
50
55
  </div>
51
56
  <div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div>
52
57
  </div>
53
- <div>${qRows || '<div style="color:#94a3b8;text-align:center;padding:24px">No questions found</div>'}</div>
54
- <div style="margin-top:24px;padding-top:12px;border-top:1px solid #e2e8f0;font-size:0.7rem;color:#94a3b8;text-align:center">Generated ${new Date().toLocaleString('en-ZA')} &bull; Moonlanding</div>
58
+ <div>${qRows || '<div style="color:#475569;text-align:center;padding:24px">No questions found</div>'}</div>
59
+ <div style="margin-top:24px;padding-top:12px;border-top:1px solid #e2e8f0;font-size:0.7rem;color:#475569;text-align:center">Generated ${new Date().toLocaleString('en-ZA')} - Thatcher</div>
55
60
  </body></html>`;
56
61
  }
@@ -23,7 +23,7 @@ export function renderSettingsChecklists(user, checklists = []) {
23
23
  const rows = checklists.map(c => `<tr ${trClick('/checklist/'+c.id)}>
24
24
  <td class="text-sm font-medium">${esc(c.name||'-')}</td>
25
25
  <td class="text-sm">${esc(c.type||'-')}</td>
26
- <td class="text-sm text-base-content/50">${esc(c.review_id||'-')}</td>
26
+ <td class="text-sm text-base-content/70">${esc(c.review_id||'-')}</td>
27
27
  <td>${editBtn('/checklist/'+c.id+'/edit')}</td>
28
28
  </tr>`).join('');
29
29
  return settingsPage(user, 'Checklists - Settings', bc('Checklists'), hdr('Checklists', '/checklist/new', '+ Add Checklist') + inlineTable(['Name', 'Type', 'Review', 'Actions'], rows, 'No checklists found'));
@@ -43,11 +43,12 @@ export function renderSettingsRecreation(user, _logs = [], _users = []) {
43
43
  <div class="card-clean"><div class="card-clean-body" style="padding:0">
44
44
  <div class="table-wrap"><table class="data-table">
45
45
  <thead><tr><th>Timestamp</th><th>Operation</th><th>Entity</th><th>Entity ID</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
46
- <tbody id="audit-tbody"><tr><td colspan="7" class="text-center py-8 text-base-content/40 text-sm">Loading...</td></tr></tbody>
46
+ <tbody id="audit-tbody"><tr><td colspan="7" class="text-center py-8 text-base-content/70 text-sm">Loading...</td></tr></tbody>
47
47
  </table></div>
48
48
  </div></div>`;
49
49
  const script = `${TOAST_SCRIPT}
50
- async function loadAuditLogs(params){const tbody=document.getElementById('audit-tbody');try{const q=params?'?'+new URLSearchParams(params):'';const r=await fetch('/api/audit/logs'+q);const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(l){const ts=l.timestamp;const date=ts?new Date(ts).toLocaleString():'-';const details=l.details?JSON.stringify(l.details).substring(0,60)+'...':'-';return'<tr><td class="text-xs text-base-content/50">'+date+'</td><td><span class="badge badge-flat-primary text-xs">'+(l.operation||'-')+'</span></td><td class="text-sm">'+(l.entity_type||'-')+'</td><td class="text-xs text-base-content/50">'+(l.entity_id||'-')+'</td><td class="text-sm">'+(l.user_id||'-')+'</td><td class="text-sm">'+(l.action||'-')+'</td><td class="text-xs text-base-content/40">'+details+'</td></tr>'}).join(''):'<tr><td colspan="7" class="text-center py-8 text-base-content/40 text-sm">No audit logs found</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="7" class="text-center py-4 text-error text-sm">Failed to load: '+e.message+'</td></tr>'}}
50
+ function _esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
51
+ async function loadAuditLogs(params){const tbody=document.getElementById('audit-tbody');try{const q=params?'?'+new URLSearchParams(params):'';const r=await fetch('/api/audit/logs'+q);const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(l){const ts=l.timestamp;const date=ts?window.fmtDateTime(ts):'-';const details=l.details?JSON.stringify(l.details).substring(0,60)+'...':'-';return'<tr><td class="text-xs text-base-content/70">'+_esc(date)+'</td><td><span class="badge badge-flat-primary text-xs">'+_esc(l.operation||'-')+'</span></td><td class="text-sm">'+_esc(l.entity_type||'-')+'</td><td class="text-xs text-base-content/70">'+_esc(l.entity_id||'-')+'</td><td class="text-sm">'+_esc(l.user_id||'-')+'</td><td class="text-sm">'+_esc(l.action||'-')+'</td><td class="text-xs text-base-content/70">'+_esc(details)+'</td></tr>'}).join(''):'<tr><td colspan="7" class="text-center py-8 text-base-content/70 text-sm">No audit logs found</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="7" class="text-center py-4 text-error text-sm">Failed to load: '+_esc(e.message)+'</td></tr>'}}
51
52
  function applyAuditFilters(){const level=document.getElementById('filter-level').value;const entity=document.getElementById('filter-entity').value;const start=document.getElementById('filter-start').value;const end=document.getElementById('filter-end').value;const p={};if(level)p.level=level;if(entity)p.entityType=entity;if(start)p.from=new Date(start).toISOString();if(end)p.to=new Date(end+'T23:59:59').toISOString();loadAuditLogs(p)}
52
53
  loadAuditLogs();`;
53
54
  return settingsPage(user, 'Audit Logs - Settings', bc('Audit Logs'), content, [script]);
@@ -67,7 +68,7 @@ export function renderSettingsIntegrations(user, integrations = {}) {
67
68
  return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body">
68
69
  <div class="flex items-center gap-4">
69
70
  <div class="text-2xl">${integ.icon}</div>
70
- <div class="flex-1"><div class="font-semibold">${integ.name}</div><div class="text-xs text-base-content/50">${integ.desc}</div></div>
71
+ <div class="flex-1"><div class="font-semibold">${integ.name}</div><div class="text-xs text-base-content/70">${integ.desc}</div></div>
71
72
  <div class="flex flex-col items-end gap-2">
72
73
  <span class="badge ${connected ? 'badge-success badge-flat-success' : 'badge-flat-secondary'} text-xs">${connected ? 'Connected' : 'Disconnected'}</span>
73
74
  <button data-action="toggleConfig" data-args='["${integ.id}"]' class="btn btn-ghost btn-xs">Configure</button>
@@ -75,7 +76,7 @@ export function renderSettingsIntegrations(user, integrations = {}) {
75
76
  </div>
76
77
  <div id="config-${integ.id}" class="hidden mt-4 pt-4 border-t border-base-200">
77
78
  <div class="flex gap-3 items-end flex-wrap">
78
- <div class="form-group flex-1 min-w-48"><label class="label"><span class="label-text font-semibold">API Key / Credentials</span></label><input type="password" id="key-${integ.id}" class="input input-solid max-w-full" placeholder="Enter credentials"/></div>
79
+ <div class="form-group flex-1 min-w-48"><label class="label"><span class="label-text font-semibold">API Key / Credentials</span></label><input type="password" id="key-${integ.id}" class="input input-solid max-w-full" placeholder="Paste the API key or credentials"/></div>
79
80
  <div class="flex gap-2"><button data-action="saveIntegration" data-args='["${integ.id}"]' class="btn btn-primary btn-sm">Save</button><button data-action="testIntegration" data-args='["${integ.id}"]' class="btn btn-ghost btn-sm">Test</button></div>
80
81
  </div>
81
82
  </div>
@@ -90,19 +91,19 @@ export function renderSettingsIntegrations(user, integrations = {}) {
90
91
  <button class="btn btn-ghost btn-sm" data-action="copyPrivateKey">Copy</button>
91
92
  <button class="btn btn-primary btn-sm" data-action="generatePrivateKey">Generate New Key</button>
92
93
  </div>
93
- <div class="text-xs text-base-content/50 mt-1">This key links Friday and My Review. Keep it confidential — anyone with access can import data.</div>
94
+ <div class="text-xs text-base-content/70 mt-1">This key links Friday and My Review. Keep it confidential — anyone with access can import data.</div>
94
95
  </div>
95
96
  <div class="mb-4">
96
97
  <label class="label"><span class="label-text font-semibold">My Review API Key (from MWR app)</span></label>
97
98
  <input type="text" id="mwr-api-key" class="input input-solid w-full" value="${esc(mwr_api_key)}" placeholder="Paste the key from My Review Integrations settings"/>
98
- <div class="text-xs text-base-content/50 mt-1">Enter the private key generated in the My Review settings to enable cross-app access.</div>
99
+ <div class="text-xs text-base-content/70 mt-1">Enter the private key generated in the My Review settings to enable cross-app access.</div>
99
100
  </div>
100
101
  <button class="btn btn-primary btn-sm" data-action="saveApiKeys">Save API Keys</button>
101
102
  </div></div>`;
102
103
  const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Integrations</h1>${apiKeysCard}${cards}`;
103
104
  const script = `${TOAST_SCRIPT}
104
105
  function toggleConfig(id){const el=document.getElementById('config-'+id);el.classList.toggle('hidden')}
105
- function saveIntegration(id){const key=document.getElementById('key-'+id).value;if(!key){showToast('Enter credentials','error');return}showToast('Integration saved','success');toggleConfig(id)}
106
+ function saveIntegration(id){const key=document.getElementById('key-'+id).value.trim();if(!key){showToast('Enter the API key or credentials before saving','error');return}showToast('Integration credentials saved','success');toggleConfig(id)}
106
107
  function testIntegration(id){showToast('Testing connection...','info');setTimeout(()=>showToast('Test complete','success'),1000)}
107
108
  function generatePrivateKey(){const arr=new Uint8Array(32);crypto.getRandomValues(arr);const key=Array.from(arr,b=>b.toString(16).padStart(2,'0')).join('');document.getElementById('mwr-private-key').value=key}
108
109
  function copyPrivateKey(){const v=document.getElementById('mwr-private-key').value;if(!v){showToast('No key to copy','error');return}navigator.clipboard.writeText(v).then(()=>showToast('Copied to clipboard','success')).catch(()=>showToast('Copy failed','error'))}
@@ -114,7 +115,7 @@ export function renderSettingsNotifications(user, config = {}) {
114
115
  const t = config.thresholds || {};
115
116
  const rfi = t.rfi || {};
116
117
  const notif = t.notification || {};
117
- const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/50">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
118
+ const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/70">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
118
119
  const toggles = [
119
120
  { id: 'rfi_reminders', label: 'RFI Reminders', desc: 'Send reminders for outstanding RFIs', checked: true },
120
121
  { id: 'deadline_alerts', label: 'Deadline Alerts', desc: 'Alert when deadlines are approaching', checked: true },
@@ -122,10 +123,10 @@ export function renderSettingsNotifications(user, config = {}) {
122
123
  { id: 'new_messages', label: 'New Messages', desc: 'Notify when new messages are received', checked: true },
123
124
  { id: 'weekly_reports', label: 'Weekly Reports', desc: 'Send weekly summary reports', checked: true },
124
125
  ];
125
- const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none" >
126
+ const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="notif-trigger-dialog-title">
126
127
  <div class="modal-overlay" data-dialog-close="notif-trigger-dialog"></div>
127
128
  <div class="modal-content rounded-box max-w-md p-6">
128
- <h3 class="text-lg font-semibold mb-4">Add Notification Trigger</h3>
129
+ <h3 id="notif-trigger-dialog-title" class="text-lg font-semibold mb-4">Add Notification Trigger</h3>
129
130
  <div class="form-group mb-3"><label class="form-label">Trigger Type</label><select id="ntd-type" class="form-input"><option value="before">Before</option><option value="after">After</option></select></div>
130
131
  <div class="form-group mb-3"><label class="form-label">Days</label><input id="ntd-days" type="number" min="0" max="365" value="7" class="form-input"/></div>
131
132
  <div class="form-group mb-3"><label class="form-label">Reference Date</label><select id="ntd-ref" class="form-input"><option value="commencement_date">Commencement Date</option><option value="deadline_date">Deadline Date</option></select></div>
@@ -150,13 +151,14 @@ export function renderSettingsNotifications(user, config = {}) {
150
151
  <div class="card-clean"><div class="card-clean-body" style="padding:0">
151
152
  <div class="table-wrap"><table class="data-table" id="triggers-table">
152
153
  <thead><tr><th>Type</th><th>Days</th><th>Reference</th><th>Recipient</th><th>Status</th><th></th></tr></thead>
153
- <tbody id="triggers-tbody"><tr><td colspan="6" class="text-center py-8 text-base-content/40 text-sm">Loading...</td></tr></tbody>
154
+ <tbody id="triggers-tbody"><tr><td colspan="6" class="text-center py-8 text-base-content/70 text-sm">Loading...</td></tr></tbody>
154
155
  </table></div>
155
156
  </div></div>
156
157
  ${triggerDialog}`;
157
158
  const script = `${TOAST_SCRIPT}
158
159
  document.getElementById('notif-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#notif-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});try{const res=await fetch('/api/admin/settings/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}});
159
- async function loadTriggers(){const tbody=document.getElementById('triggers-tbody');try{const r=await fetch('/api/friday/engagement/notifications');const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(t){return'<tr><td class="text-sm">'+t.trigger_type+'</td><td class="text-sm">'+t.trigger_days+' days</td><td class="text-sm">'+t.trigger_reference+'</td><td class="text-sm">'+t.recipient_type+'</td><td>'+(t.active?'<span class="badge badge-flat-success text-xs">Active</span>':'<span class="badge badge-flat-secondary text-xs">Inactive</span>')+'</td><td><button class="btn btn-ghost btn-xs" data-action="deleteNotifTrigger" data-args=\\'["\\'+t.id+\\'"]\\'>Delete</button></td></tr>'}).join(''):'<tr><td colspan="6" class="text-center py-8 text-base-content/40 text-sm">No triggers configured</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="6" class="text-center py-4 text-error text-sm">Failed to load</td></tr>'}}
160
+ function _esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
161
+ async function loadTriggers(){const tbody=document.getElementById('triggers-tbody');try{const r=await fetch('/api/friday/engagement/notifications');const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(t){return'<tr><td class="text-sm">'+_esc(t.trigger_type)+'</td><td class="text-sm">'+_esc(t.trigger_days)+' days</td><td class="text-sm">'+_esc(t.trigger_reference)+'</td><td class="text-sm">'+_esc(t.recipient_type)+'</td><td>'+(t.active?'<span class="badge badge-flat-success text-xs">Active</span>':'<span class="badge badge-flat-secondary text-xs">Inactive</span>')+'</td><td><button class="btn btn-ghost btn-xs" data-action="deleteNotifTrigger" data-args=\\'["\\'+encodeURIComponent(t.id)+\\'"]\\'>Delete</button></td></tr>'}).join(''):'<tr><td colspan="6" class="text-center py-8 text-base-content/70 text-sm">No triggers configured</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="6" class="text-center py-4 text-error text-sm">Failed to load</td></tr>'}}
160
162
  function openNotifTriggerDialog(){document.getElementById('notif-trigger-dialog').style.display='flex'}
161
163
  async function saveNotifTrigger(){const type=document.getElementById('ntd-type').value;const days=Number(document.getElementById('ntd-days').value);const ref=document.getElementById('ntd-ref').value;const recipient=document.getElementById('ntd-recipient').value;const active=document.getElementById('ntd-active').checked;if(!days&&days!==0){showToast('Enter number of days','error');return}try{const r=await fetch('/api/friday/engagement/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trigger_type:type,trigger_days:days,trigger_reference:ref,recipient_type:recipient,active})});if(r.ok){showToast('Trigger added','success');document.getElementById('notif-trigger-dialog').style.display='none';loadTriggers()}else{const d=await r.json();showToast(d.message||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
162
164
  async function deleteNotifTrigger(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this trigger?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/friday/engagement/notifications',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});if(r.ok){showToast('Deleted','success');loadTriggers()}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
@@ -167,7 +169,7 @@ loadTriggers();`;
167
169
  function renderTypeList(user, items, entityKey, title) {
168
170
  const rows = items.map(t => `<tr class="hover">
169
171
  <td class="font-medium text-sm">${esc(t.name || '-')}</td>
170
- <td class="text-xs text-base-content/50">${t.created_at ? new Date(t.created_at).toLocaleDateString() : '-'}</td>
172
+ <td class="text-xs text-base-content/70">${t.created_at ? new Date(t.created_at).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
171
173
  <td><div class="flex gap-2">
172
174
  <button type="button" class="btn btn-ghost btn-xs type-edit-btn" data-id="${esc(t.id)}" data-name="${esc(t.name||'')}">Edit</button>
173
175
  <button type="button" class="btn btn-error btn-xs type-del-btn" data-id="${esc(t.id)}">Delete</button>
@@ -175,13 +177,14 @@ function renderTypeList(user, items, entityKey, title) {
175
177
  </tr>`).join('');
176
178
  const script = `${TOAST_SCRIPT}
177
179
  var _eid='';
178
- function openAdd(){_eid='';document.getElementById('type-name').value='';document.getElementById('type-form').style.display='block'}
179
- function openEdit(id,name){_eid=id;document.getElementById('type-name').value=name;document.getElementById('type-form').style.display='block'}
180
- function cancelForm(){document.getElementById('type-form').style.display='none'}
180
+ var _typeFormTrigger=null;
181
+ function openAdd(){_eid='';_typeFormTrigger=document.activeElement;document.getElementById('type-name').value='';document.getElementById('type-form').style.display='block';var n=document.getElementById('type-name');if(n)n.focus()}
182
+ function openEdit(id,name){_eid=id;_typeFormTrigger=document.activeElement;document.getElementById('type-name').value=name;document.getElementById('type-form').style.display='block';var n=document.getElementById('type-name');if(n)n.focus()}
183
+ function cancelForm(){document.getElementById('type-form').style.display='none';if(_typeFormTrigger&&typeof _typeFormTrigger.focus==='function')_typeFormTrigger.focus();_typeFormTrigger=null}
181
184
  async function saveType(){const name=document.getElementById('type-name').value.trim();if(!name){showToast('Name required','error');return}const url=_eid?'/api/${entityKey}/'+_eid:'/api/${entityKey}';const method=_eid?'PUT':'POST';try{const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});if(r.ok){showToast(_eid?'Updated':'Created','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
182
185
  async function delType(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/${entityKey}/'+id,{method:'DELETE'});if(r.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
183
186
  document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.type-edit-btn').forEach(b=>b.addEventListener('click',function(){openEdit(this.dataset.id,this.dataset.name)}));document.querySelectorAll('.type-del-btn').forEach(b=>b.addEventListener('click',function(){delType(this.dataset.id)}));document.querySelectorAll('.type-add-btn').forEach(b=>b.addEventListener('click',openAdd))});`;
184
- const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex gap-2 items-end"><div class="form-group"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid" style="max-width:240px" placeholder="Name"/></div><button data-action="saveType" class="btn btn-primary btn-sm">Save</button><button data-action="cancelForm" class="btn btn-ghost btn-sm">Cancel</button></div></div></div>`;
187
+ const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex flex-wrap gap-2 items-end"><div class="form-group" style="flex:1;min-width:0"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid w-full" placeholder="Name"/></div><button data-action="saveType" class="btn btn-primary btn-sm">Save</button><button data-action="cancelForm" class="btn btn-ghost btn-sm">Cancel</button></div></div></div>`;
185
188
  const content = `${settingsBack()}<div class="flex justify-between items-center mb-4"><h1 class="text-2xl font-bold">${title}</h1><button class="btn btn-primary btn-sm type-add-btn">Add</button></div>${formHtml}<div class="card-clean"><div class="card-clean-body" style="padding:0">${inlineTable(['Name','Created','Actions'],rows,'No items found.')}</div></div>`;
186
189
  return settingsPage(user, `${title} - Settings`, bc(title), content, [script]);
187
190
  }
@@ -1,8 +1,8 @@
1
1
  import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
2
- import { esc } from '@/ui/render-helpers.js';
2
+ import { esc, icon } from '@/ui/render-helpers.js';
3
3
 
4
4
  const bc = (label) => [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label }];
5
- const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/50">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
5
+ const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/80">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
6
6
 
7
7
  export function renderSettingsReviewSettings(user, config = {}) {
8
8
  const review = config.review || {};
@@ -39,7 +39,7 @@ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
39
39
  { id: 'mobile_resize', label: 'Mobile Resize', desc: 'Enable mobile-friendly resizable highlights', checked: fr.mobile_resize !== false },
40
40
  { id: 'coordinate_snap', label: 'Coordinate Snap', desc: 'Snap highlight coordinates to text boundaries', checked: !!fr.coordinate_snap },
41
41
  ];
42
- const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button" onclick="removeFlag('${type}','${esc(f.replace(/'/g,"\\'"))}')" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>`).join('');
42
+ const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button" data-action="removeFlag" data-args='${esc(JSON.stringify([type, f]))}' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;line-height:1;color:inherit;display:inline-flex">${icon('close',16)}</button></span>`).join('');
43
43
  const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">File Review Settings</h1>
44
44
  <div id="fr-review-flags" data-flags='${JSON.stringify(reviewFlags)}'></div>
45
45
  <div id="fr-tender-flags" data-flags='${JSON.stringify(tenderFlags)}'></div>
@@ -55,18 +55,19 @@ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
55
55
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
56
56
  <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Review Flags</h2>
57
57
  <div id="review-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(reviewFlags, 'review')}</div>
58
- <div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" onclick="addFlag('review')" class="btn btn-primary btn-sm">Add</button></div>
58
+ <div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["review"]' class="btn btn-primary btn-sm">Add</button></div>
59
59
  </div></div>
60
60
  <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Tender Flags</h2>
61
61
  <div id="tender-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(tenderFlags, 'tender')}</div>
62
- <div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" onclick="addFlag('tender')" class="btn btn-primary btn-sm">Add</button></div>
62
+ <div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["tender"]' class="btn btn-primary btn-sm">Add</button></div>
63
63
  </div></div>
64
64
  </div>
65
65
  <button type="submit" class="btn btn-primary">Save File Review Settings</button></form>`;
66
66
  const script = `${TOAST_SCRIPT}
67
67
  var _rvFlags=${JSON.stringify(reviewFlags)};
68
68
  var _tdFlags=${JSON.stringify(tenderFlags)};
69
- function renderFlags(type){const list=type==='review'?_rvFlags:_tdFlags;const el=document.getElementById(type+'-flags-list');if(!el)return;el.innerHTML=list.map(f=>'<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">'+f+'<button type="button" onclick="removeFlag(\\''+type+'\\',\\''+f.replace(/'/g,"\\'")+'\\')" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>').join('')}
69
+ function _escFlag(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
70
+ function renderFlags(type){const list=type==='review'?_rvFlags:_tdFlags;const el=document.getElementById(type+'-flags-list');if(!el)return;el.innerHTML=list.map(f=>'<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">'+_escFlag(f)+'<button type="button" data-action="removeFlag" data-args=\\''+_escFlag(JSON.stringify([type,f]))+'\\' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>').join('')}
70
71
  window.addFlag=function(type){const inp=document.getElementById('new-'+type+'-flag');const val=(inp?.value||'').trim();if(!val)return;if(type==='review'){_rvFlags=[..._rvFlags,val]}else{_tdFlags=[..._tdFlags,val]};inp.value='';renderFlags(type)};
71
72
  window.removeFlag=function(type,flag){if(type==='review'){_rvFlags=_rvFlags.filter(f=>f!==flag)}else{_tdFlags=_tdFlags.filter(f=>f!==flag)};renderFlags(type)};
72
73
  document.getElementById('file-review-settings-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#file-review-settings-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});document.querySelectorAll('#file-review-settings-form input[type=number]').forEach(n=>{if(data[n.name])data[n.name]=Number(data[n.name])});data.review_flags=_rvFlags;data.tender_flags=_tdFlags;try{const res=await fetch('/api/admin/settings/file-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}})`;
@@ -79,31 +80,31 @@ export function renderSettingsMwrPermissions(user, permissions = []) {
79
80
  <td class="text-sm">${esc(p.entity_id||'-')}</td>
80
81
  <td class="text-sm">${esc(p.user_id||'-')}</td>
81
82
  <td class="text-sm">${esc(p.permission_type||'-')}</td>
82
- <td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString() : '-'}</td>
83
- <td><button class="btn btn-error btn-xs btn-outline" onclick="revokePermission('${esc(p.id)}')">Revoke</button></td>
83
+ <td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
84
+ <td><button type="button" class="btn btn-error btn-xs btn-outline" data-action="revokePermission" data-args='${esc(JSON.stringify([p.id]))}'>Revoke</button></td>
84
85
  </tr>`).join('');
85
86
  const content = `${settingsBack()}<div class="flex justify-between items-center mb-6">
86
87
  <h1 class="text-2xl font-bold">MWR Permissions</h1>
87
- <button class="btn btn-primary btn-sm" onclick="document.getElementById('grant-perm-dialog').style.display='flex'">+ Grant Permission</button>
88
+ <button class="btn btn-primary btn-sm" data-action="openDialog" data-args='["grant-perm-dialog"]'>+ Grant Permission</button>
88
89
  </div>
89
90
  ${inlineTable(['Entity Type','Entity ID','User ID','Permission','Granted','Actions'], rows, 'No permissions configured')}
90
- <div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
91
- <div class="dialog-panel" style="max-width:460px">
92
- <div class="dialog-header"><span class="dialog-title">Grant Permission</span><button class="dialog-close" onclick="document.getElementById('grant-perm-dialog').style.display='none'">&times;</button></div>
91
+ <div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="gp-dialog-title" data-dialog-close="grant-perm-dialog">
92
+ <div class="dialog-panel" style="max-width:min(460px,90vw)">
93
+ <div class="dialog-header"><span class="dialog-title" id="gp-dialog-title">Grant Permission</span><button class="dialog-close" data-dialog-close="grant-perm-dialog" aria-label="Close">&times;</button></div>
93
94
  <div class="dialog-body">
94
- <div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="review"/></div>
95
- <div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="entity id"/></div>
96
- <div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="user id"/></div>
95
+ <div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="e.g. review"/></div>
96
+ <div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="e.g. 1a2b3c"/></div>
97
+ <div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="e.g. user_42"/></div>
97
98
  <div class="modal-form-group"><label>Permission Type</label><select id="gp-perm-type" class="form-input"><option value="view">view</option><option value="edit">edit</option><option value="admin">admin</option></select></div>
98
99
  <div id="gp-result" style="display:none;margin-top:8px"></div>
99
100
  </div>
100
101
  <div class="dialog-footer">
101
- <button class="btn btn-ghost btn-sm" onclick="document.getElementById('grant-perm-dialog').style.display='none'">Cancel</button>
102
- <button class="btn btn-primary btn-sm" onclick="grantPermission()">Grant</button>
102
+ <button class="btn btn-ghost btn-sm" data-dialog-close="grant-perm-dialog">Cancel</button>
103
+ <button class="btn btn-primary btn-sm" data-action="grantPermission">Grant</button>
103
104
  </div>
104
105
  </div>
105
106
  </div>`;
106
- const script = `${TOAST_SCRIPT}async function grantPermission(){var body={entity_type:document.getElementById('gp-entity-type').value.trim(),entity_id:document.getElementById('gp-entity-id').value.trim(),user_id:document.getElementById('gp-user-id').value.trim(),permission_type:document.getElementById('gp-perm-type').value};var res=document.getElementById('gp-result');if(!body.entity_type||!body.entity_id||!body.user_id){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">All fields required.</div>';return}try{var r=await fetch('/api/mwr/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var d=await r.json();if(r.ok&&d.success){showToast('Permission granted','success');document.getElementById('grant-perm-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||'Failed')+'</div>'}}catch(e){showToast('Error: '+e.message,'error')}}async function revokePermission(id){if(!confirm('Revoke this permission?'))return;try{var r=await fetch('/api/permission/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
107
+ const script = `${TOAST_SCRIPT}window.grantPermission=async function(){var body={entity_type:document.getElementById('gp-entity-type').value.trim(),entity_id:document.getElementById('gp-entity-id').value.trim(),user_id:document.getElementById('gp-user-id').value.trim(),permission_type:document.getElementById('gp-perm-type').value};var res=document.getElementById('gp-result');if(!body.entity_type||!body.entity_id||!body.user_id){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">All fields required.</div>';return}try{var r=await fetch('/api/mwr/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var d=await r.json();if(r.ok&&d.success){showToast('Permission granted','success');(window.closeDialog?window.closeDialog('grant-perm-dialog'):document.getElementById('grant-perm-dialog').style.display='none');setTimeout(function(){location.reload()},500)}else{res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||'Failed')+'</div>'}}catch(e){showToast('Error: '+e.message,'error')}};window.revokePermission=async function(id){if(!(await window.gmConfirm({title:'Revoke permission',message:'Revoke this permission?',confirmLabel:'Revoke',danger:true})))return;try{var r=await fetch('/api/permission/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
107
108
  return settingsPage(user, 'MWR Permissions', bc('MWR Permissions'), content, [script]);
108
109
  }
109
110
 
@@ -153,6 +154,6 @@ export function renderSettingsTemplateManage(user, template = {}, sections = [])
153
154
  window.editTplSection=function(id,name,color){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value=id;document.getElementById('tpl-sec-name').value=name;document.getElementById('tpl-sec-color').value=color};
154
155
  window.cancelTplSection=function(){document.getElementById('tpl-section-form').classList.add('hidden')};
155
156
  window.saveTplSection=async function(){var id=document.getElementById('tpl-sec-id').value;var body={name:document.getElementById('tpl-sec-name').value,color:document.getElementById('tpl-sec-color').value,review_template_id:tplId};if(!body.name){showToast('Name required','error');return}var url=id?'/api/review_template_section/'+id:'/api/review_template_section';var method=id?'PUT':'POST';try{var res=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};
156
- window.deleteTplSection=async function(id){if(!confirm('Delete this section?'))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
157
+ window.deleteTplSection=async function(id){if(!(await window.gmConfirm({title:'Delete Section',message:'Delete this section? This cannot be undone.',confirmLabel:'Delete',danger:true})))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
157
158
  return settingsPage(user, `Manage Template - ${esc(template.name || 'Template')}`, tplBc, content, [script]);
158
159
  }
@@ -14,8 +14,8 @@ function parseMembers(t) {
14
14
  }
15
15
 
16
16
  export function renderSettingsTeams(user, teams = [], allUsers = []) {
17
- const editDialog = `<div id="team-dialog" class="modal" style="display:none" data-overlay-close="true" data-action="closeTeamDialog">
18
- <div class="modal-overlay" data-action="closeTeamDialog"></div>
17
+ const editDialog = `<div id="team-dialog" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="team-dialog-title">
18
+ <div class="modal-overlay" data-dialog-close="team-dialog"></div>
19
19
  <div class="modal-content rounded-box max-w-md p-6">
20
20
  <h3 id="team-dialog-title" class="text-lg font-semibold mb-4">Edit Team</h3>
21
21
  <input type="hidden" id="team-edit-id"/>
@@ -59,7 +59,7 @@ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAl
59
59
  const content = `${settingsBack()}${editDialog}
60
60
  <div class="flex justify-between items-center mb-4">
61
61
  <h1 class="text-2xl font-bold">Teams</h1>
62
- <button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
62
+ <button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
63
63
  </div>
64
64
  <div class="card-clean"><div class="card-clean-body" style="padding:0rem">${inlineTable(['Name', 'Members', 'Actions'], rows, 'No teams found')}</div></div>`;
65
65
  return settingsPage(user, 'Teams - Settings', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Teams' }], content, [script]);
@@ -101,7 +101,7 @@ export function renderSettingsRfiSections(user, sections = []) {
101
101
  const script = `${TOAST_SCRIPT}
102
102
  function toggleAddForm(){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value='';document.getElementById('section-name').value='';selectColor('#B0B0B0')}
103
103
  function cancelForm(){document.getElementById('section-form').style.display='none'}
104
- function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'#1a1a1a':'transparent'})}
104
+ function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'var(--color-text)':'transparent'})}
105
105
  function editSection(id,name,color){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value=id;document.getElementById('section-name').value=name;selectColor(color)}
106
106
  async function saveSection(){const name=document.getElementById('section-name').value.trim();const color=document.getElementById('section-color').value;const id=document.getElementById('edit-id').value;if(!name){showToast('Name is required','error');return}const url=id?'/api/rfi_section/'+id:'/api/rfi_section';const method=id?'PUT':'POST';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name,color})});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(()=>location.reload(),500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
107
107
  async function deleteSection(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this section?',danger:true,confirmLabel:'OK'})))return;try{const res=await fetch('/api/rfi_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}}
@@ -50,7 +50,7 @@ const SETTINGS_CARDS = [
50
50
  export function renderSettingsHome(user, config = {}, counts = {}) {
51
51
  const cards = SETTINGS_CARDS.map(c => {
52
52
  const cnt = c.countKey && counts[c.countKey] !== undefined ? counts[c.countKey] : null;
53
- const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString()} items</span>` : '';
53
+ const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString('en-ZA')} items</span>` : '';
54
54
  return `<a href="${c.href}" class="card-clean" style="text-decoration:none;border:1px solid var(--color-border);transition:box-shadow 0.15s">
55
55
  <div class="card-clean-body" style="padding:${SPACING.md}">
56
56
  <div style="display:flex;align-items:flex-start;gap:${SPACING.md}">
@@ -60,7 +60,7 @@ export function renderSettingsHome(user, config = {}, counts = {}) {
60
60
  <div style="font-size:0.75rem;color:var(--color-text-muted);margin-top:${SPACING.xs};line-height:1.4">${c.desc}</div>
61
61
  ${badge ? `<div style="margin-top:${SPACING.sm}">${badge}</div>` : ''}
62
62
  </div>
63
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
63
+ <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
64
64
  </div>
65
65
  </div>
66
66
  </a>`;
@@ -120,10 +120,10 @@ export function renderSettingsUsers(user, users = []) {
120
120
  <div class="flex justify-between items-center mb-2">
121
121
  <div>
122
122
  <h1 class="text-2xl font-bold">Users</h1>
123
- <p class="text-sm text-base-content/50 mt-0.5">${users.length} total &middot; ${active} active</p>
123
+ <p class="text-sm text-base-content/50 mt-0.5">${users.length} total - ${active} active</p>
124
124
  </div>
125
125
  <a href="/user/new" class="btn btn-primary btn-sm gap-1">
126
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
126
+ <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
127
127
  </a>
128
128
  </div>
129
129
  <div class="table-toolbar mb-3">
@@ -124,11 +124,14 @@ export function renderStatsRow(stats) {
124
124
  </div>`).join('')}</div>`;
125
125
  }
126
126
 
127
+ // title/subtitle are plain text (escaped here); actionsHtml is a caller-built
128
+ // HTML fragment by contract. esc() inline to keep this module dependency-free.
129
+ function phEsc(s) { return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
127
130
  export function renderPageHeader(title, subtitle, actionsHtml = '') {
128
131
  return `<div class="page-header">
129
132
  <div>
130
- <h1 class="page-title">${title}</h1>
131
- ${subtitle ? `<p class="page-subtitle">${subtitle}</p>` : ''}
133
+ <h1 class="page-title">${phEsc(title)}</h1>
134
+ ${subtitle ? `<p class="page-subtitle">${phEsc(subtitle)}</p>` : ''}
132
135
  </div>
133
136
  ${actionsHtml ? `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">${actionsHtml}</div>` : ''}
134
137
  </div>`;
@@ -4,7 +4,7 @@ export function renderStandaloneLogin(showGoogleAuth = false) {
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Sign In | Moonlanding</title>
7
+ <title>Sign In | Thatcher</title>
8
8
  <style>
9
9
  *{margin:0;padding:0;box-sizing:border-box}
10
10
  :root{--bg:#f1f5f9;--surface:#fff;--border:#e2e8f0;--text:#0f172a;--text-muted:#64748b;--primary:#04141f;--primary-hover:#0a2535;--accent:#2563eb;--danger:#dc2626;--danger-bg:#fee2e2;--danger-border:#fca5a5;--radius:10px;--shadow:0 4px 24px rgba(0,0,0,0.09)}
@@ -45,7 +45,7 @@ export function renderStandaloneLogin(showGoogleAuth = false) {
45
45
  <div class="wrap">
46
46
  <div class="logo">
47
47
  <div class="logo-mark"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.75" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
48
- <span class="logo-text">Moonlanding</span>
48
+ <span class="logo-text">Thatcher</span>
49
49
  <div class="logo-sub">Sign in to your account</div>
50
50
  </div>
51
51
  <div class="card">
package/src/ui/styles.css CHANGED
@@ -1184,9 +1184,10 @@ tr:last-child td { border-bottom: none; }
1184
1184
  .dialog-panel { background: white; border-radius: 0.75rem; padding: 1.5rem; max-width: 480px; width: 90%; max-height: 85vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2); }
1185
1185
  .dialog-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
1186
1186
  .dialog-title { font-size: 1.125rem; font-weight: 600; color: #111827; }
1187
- .dialog-close { background: none; border: none; font-size: 1.5rem; color: #9ca3af; cursor: pointer; line-height: 1; padding: 0; }
1187
+ .dialog-close { background: none; border: none; font-size: 1.5rem; color: #9ca3af; cursor: pointer; line-height: 1; padding: 0; min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; }
1188
1188
  .dialog-close:hover { color: #374151; }
1189
1189
  @media (prefers-color-scheme: dark) {
1190
+ .dialog-title { color: #f5f5f5; }
1190
1191
  .dialog-close { color: #9ca3af; }
1191
1192
  .dialog-close:hover { color: #f5f5f5; }
1192
1193
  }