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.
- package/README.md +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +5 -41
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +1 -1
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
package/src/ui/styles2.css
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
--ds-border: #3a3d42;
|
|
12
12
|
--ds-accent: #247420;
|
|
13
13
|
--ds-accent-light: #3A9A34;
|
|
14
|
+
--ds-on-accent: #fff;
|
|
14
15
|
--ds-mint: rgb(182, 222, 193);
|
|
15
16
|
--ds-pill-radius: 999px;
|
|
16
17
|
--ds-font-body: 'Nunito', Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
@@ -23,7 +24,7 @@ body[data-ds-theme="247420"], body { background: var(--ds-bg); color: var(--ds-f
|
|
|
23
24
|
h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3px; }
|
|
24
25
|
.pill, .badge, .info-label, .meta, code, kbd, .mono { font-family: var(--ds-font-mono); }
|
|
25
26
|
.btn, button.btn-primary, button.btn-secondary, .btn-ghost, .btn-outline { border-radius: var(--ds-pill-radius); padding: 8px 18px; font-family: var(--ds-font-body); font-weight: 600; }
|
|
26
|
-
.btn-primary { background: var(--ds-accent); color: var(--ds-
|
|
27
|
+
.btn-primary { background: var(--ds-accent); color: var(--ds-on-accent); border: 1px solid var(--ds-accent-light); }
|
|
27
28
|
.btn-primary:hover { background: var(--ds-accent-light); }
|
|
28
29
|
|
|
29
30
|
/* Layered surfaces */
|
|
@@ -35,7 +36,7 @@ h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3p
|
|
|
35
36
|
#ds-online-status.offline { display: inline-block; background: #4a1d1d; border-color: #6b2828; color: #ffd6d6; }
|
|
36
37
|
|
|
37
38
|
/* FlexUp chat dock */
|
|
38
|
-
#flexup-fab { position: fixed; bottom: 20px; right: 20px; z-index: 9998; width: 52px; height: 52px; border-radius: 999px; background: var(--ds-accent); color:
|
|
39
|
+
#flexup-fab { position: fixed; bottom: 20px; right: 20px; z-index: 9998; width: 52px; height: 52px; border-radius: 999px; background: var(--ds-accent); color: var(--ds-on-accent); border: 1px solid var(--ds-accent-light); cursor: pointer; font-family: var(--ds-font-display); font-size: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
|
|
39
40
|
#flexup-dock { position: fixed; bottom: 84px; right: 20px; z-index: 9998; width: 360px; max-height: 480px; background: var(--ds-surface); border: 1px solid var(--ds-border); border-radius: 12px; display: none; flex-direction: column; box-shadow: 0 12px 32px rgba(0,0,0,0.5); }
|
|
40
41
|
#flexup-dock.open { display: flex; }
|
|
41
42
|
#flexup-dock .fx-header { padding: 12px 14px; border-bottom: 1px solid var(--ds-border); font-family: var(--ds-font-display); font-size: 14px; }
|
|
@@ -45,7 +46,7 @@ h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3p
|
|
|
45
46
|
#flexup-dock .fx-msg.bot { background: var(--ds-bg); border: 1px solid var(--ds-border); }
|
|
46
47
|
#flexup-dock .fx-input-row { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--ds-border); }
|
|
47
48
|
#flexup-dock input.fx-input { flex: 1; background: var(--ds-bg); color: var(--ds-fg); border: 1px solid var(--ds-border); border-radius: 8px; padding: 8px 10px; font-family: var(--ds-font-body); }
|
|
48
|
-
#flexup-dock button.fx-send { background: var(--ds-accent); color: var(--ds-
|
|
49
|
+
#flexup-dock button.fx-send { background: var(--ds-accent); color: var(--ds-on-accent); border: 0; border-radius: 8px; padding: 6px 14px; font-family: var(--ds-font-body); font-weight: 600; cursor: pointer; }
|
|
49
50
|
|
|
50
51
|
/* Priority star */
|
|
51
52
|
.review-priority-star { background: transparent; border: 0; cursor: pointer; color: var(--ds-muted); padding: 2px 6px; font-size: 16px; }
|
|
@@ -78,7 +79,7 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|
|
78
79
|
--color-bg: #f1f5f9;
|
|
79
80
|
--color-border: #e2e8f0;
|
|
80
81
|
--color-text: #0f172a;
|
|
81
|
-
--color-text-muted: #
|
|
82
|
+
--color-text-muted: #5a6b7f;
|
|
82
83
|
--color-text-light: #94a3b8;
|
|
83
84
|
--color-success: #16a34a;
|
|
84
85
|
--color-warning: #d97706;
|
|
@@ -169,13 +170,48 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|
|
169
170
|
|
|
170
171
|
/* Stage pills */
|
|
171
172
|
.stage-pill { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 100px; font-size: 12px; font-weight: 600; letter-spacing: 0.2px; }
|
|
172
|
-
.stage-info_gathering { background: #
|
|
173
|
+
.stage-info_gathering { background: #dbeafe; color: #1e40af; }
|
|
173
174
|
.stage-commencement { background: #fef3c7; color: #92400e; }
|
|
174
175
|
.stage-team_execution { background: #dbeafe; color: #1e40af; }
|
|
175
176
|
.stage-partner_review { background: #ede9fe; color: #5b21b6; }
|
|
176
177
|
.stage-finalization { background: #dcfce7; color: #15803d; }
|
|
177
178
|
.stage-closeout { background: #f1f5f9; color: #475569; }
|
|
178
179
|
|
|
180
|
+
/* Badge variants for table cells and detail views (inherit stage/status pill colors via classes) */
|
|
181
|
+
.badge-stage { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 100px; font-size: 12px; font-weight: 600; }
|
|
182
|
+
.badge-stage.stage-info_gathering { background: #dbeafe; color: #1e40af; }
|
|
183
|
+
.badge-stage.stage-commencement { background: #fef3c7; color: #92400e; }
|
|
184
|
+
.badge-stage.stage-team_execution { background: #dbeafe; color: #1e40af; }
|
|
185
|
+
.badge-stage.stage-partner_review { background: #ede9fe; color: #5b21b6; }
|
|
186
|
+
.badge-stage.stage-finalization { background: #dcfce7; color: #15803d; }
|
|
187
|
+
.badge-stage.stage-closeout { background: #f1f5f9; color: #475569; }
|
|
188
|
+
|
|
189
|
+
/* Status label badges (alias to pill classes for consistency) */
|
|
190
|
+
.status-label { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 100px; font-size: 12px; font-weight: 500; }
|
|
191
|
+
|
|
192
|
+
/* Dark-mode overrides for status pills + stage pills: keep them as dark-tinted
|
|
193
|
+
islands (not light-on-dark) with legible light text. */
|
|
194
|
+
@media (prefers-color-scheme: dark) {
|
|
195
|
+
.pill-success { background: #14321f; color: #4ade80; }
|
|
196
|
+
.pill-danger { background: #3a1717; color: #f87171; }
|
|
197
|
+
.pill-warning { background: #3a2c12; color: #fbbf24; }
|
|
198
|
+
.pill-info { background: #16263f; color: #60a5fa; }
|
|
199
|
+
.pill-neutral { background: #25303d; color: #cbd5e1; }
|
|
200
|
+
.pill-primary { background: #122b3a; color: #38bdf8; }
|
|
201
|
+
.stage-info_gathering { background: #1e3a8a; color: #60a5fa; }
|
|
202
|
+
.stage-commencement { background: #3a2c12; color: #fcd34d; }
|
|
203
|
+
.stage-team_execution { background: #16263f; color: #93c5fd; }
|
|
204
|
+
.stage-partner_review { background: #261b3f; color: #c4b5fd; }
|
|
205
|
+
.stage-finalization { background: #14321f; color: #86efac; }
|
|
206
|
+
.stage-closeout { background: #25303d; color: #cbd5e1; }
|
|
207
|
+
.badge-stage.stage-info_gathering { background: #1e3a8a; color: #60a5fa; }
|
|
208
|
+
.badge-stage.stage-commencement { background: #3a2c12; color: #fcd34d; }
|
|
209
|
+
.badge-stage.stage-team_execution { background: #16263f; color: #93c5fd; }
|
|
210
|
+
.badge-stage.stage-partner_review { background: #261b3f; color: #c4b5fd; }
|
|
211
|
+
.badge-stage.stage-finalization { background: #14321f; color: #86efac; }
|
|
212
|
+
.badge-stage.stage-closeout { background: #25303d; color: #cbd5e1; }
|
|
213
|
+
}
|
|
214
|
+
|
|
179
215
|
/* Buttons */
|
|
180
216
|
.btn-primary-clean { display: inline-flex; align-items: center; gap: 6px; padding: 9px 18px; background: var(--color-primary); color: white; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; text-decoration: none; transition: all 0.15s ease; min-height: 40px; }
|
|
181
217
|
.btn-primary-clean:hover { background: var(--color-primary-hover); color: white; transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
|
@@ -194,6 +230,11 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|
|
194
230
|
.btn-danger-clean:hover { background: #7f1d1d; }
|
|
195
231
|
}
|
|
196
232
|
.btn-danger-clean:active { opacity: 0.8; }
|
|
233
|
+
/* gmConfirm danger:true emits .btn-error, but the runtime-injected `.ds-247420 .btn { background:var(--bg-2) }`
|
|
234
|
+
(specificity 0,2,0) out-weighed a plain .btn-error and rendered it gray (witnessed rgb(37,37,44)).
|
|
235
|
+
!important guarantees destructive-confirm buttons read as danger regardless of injected theme order. */
|
|
236
|
+
.btn-error, button.btn-error, .ds-247420 .btn-error { background: var(--color-danger, #dc2626) !important; color: #fff !important; border-color: var(--color-danger, #dc2626) !important; }
|
|
237
|
+
.btn-error:hover, button.btn-error:hover, .ds-247420 .btn-error:hover { background: #b91c1c !important; border-color: #b91c1c !important; }
|
|
197
238
|
.btn-danger-clean:focus-visible { outline: 2px solid var(--color-danger); outline-offset: 2px; }
|
|
198
239
|
.btn-edit-clean { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: transparent; color: var(--color-info); border: 1px solid #bfdbfe; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; text-decoration: none; transition: all 0.15s ease; min-height: 36px; }
|
|
199
240
|
.btn-edit-clean:hover { background: #eff6ff; }
|
|
@@ -768,6 +809,11 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
768
809
|
.main-nav-brand-link { display: flex; align-items: center; gap: 0.5rem; text-decoration: none; flex-shrink: 0; }
|
|
769
810
|
.main-nav-brand-text { color: #f8f9fa; font-size: 0.9rem; font-weight: 700; letter-spacing: 0; margin-left: 0.25rem; }
|
|
770
811
|
.main-nav-actions { display: flex; align-items: center; gap: 0.75rem; margin-left: 1rem; padding-right: 1rem; flex-shrink: 0; }
|
|
812
|
+
/* Shared nav icon-button (theme toggle, notification bell, hamburger): one rule
|
|
813
|
+
instead of duplicated inline style strings on each element. */
|
|
814
|
+
.nav-icon-btn { background: none; border: none; color: var(--color-text-muted, #ced4da); cursor: pointer; padding: 6px; border-radius: 4px; display: flex; align-items: center; min-width: 32px; min-height: 32px; text-decoration: none; transition: color 0.15s, background 0.15s; }
|
|
815
|
+
.nav-icon-btn:hover { color: var(--color-text, #fff); background: rgba(255,255,255,0.08); }
|
|
816
|
+
.nav-icon-btn.nav-notif-bell { position: relative; }
|
|
771
817
|
.nav-links-desktop { display: flex; align-items: center; gap: 1.25rem; flex-shrink: 0; padding: 0 0.75rem; }
|
|
772
818
|
@media (max-width: 768px) { .nav-links-desktop { display: none; } }
|
|
773
819
|
.nav-link { color: #adb5bd; font-size: 0.875rem; font-weight: 500; letter-spacing: 0; text-decoration: none; transition: color 0.15s; }
|
|
@@ -1180,6 +1226,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
1180
1226
|
.opacity-25 { opacity: 0.25; }
|
|
1181
1227
|
.opacity-50 { opacity: 0.5; }
|
|
1182
1228
|
.opacity-75 { opacity: 0.75; }
|
|
1229
|
+
/* read notification row: distinguish via muted bg instead of low-contrast opacity */
|
|
1230
|
+
tr.is-read { background: rgb(var(--base-200, 240 240 244) / 0.4); }
|
|
1231
|
+
tr.is-read td { color: rgb(var(--base-content, 30 30 35) / 0.7); }
|
|
1183
1232
|
.opacity-100 { opacity: 1; }
|
|
1184
1233
|
|
|
1185
1234
|
/* Object fit */
|
|
@@ -1357,6 +1406,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
1357
1406
|
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 600; }
|
|
1358
1407
|
.tab-count { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 100px; font-size: 11px; font-weight: 600; padding: 1px 7px; color: var(--color-text-muted); }
|
|
1359
1408
|
.tab-btn.active .tab-count { background: var(--color-primary); color: #fff; border-color: transparent; }
|
|
1409
|
+
/* Dark-mode --color-primary (#64b5f6) is light; white text on it is 2.2:1 (fails AA).
|
|
1410
|
+
Use dark text on the active-tab count badge in dark theme (witnessed on /review). */
|
|
1411
|
+
[data-theme="dark"] .tab-btn.active .tab-count { color: #0b1220; }
|
|
1360
1412
|
|
|
1361
1413
|
/* Form improvements */
|
|
1362
1414
|
.form-field { margin-bottom: 1rem; }
|
|
@@ -1473,6 +1525,24 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
1473
1525
|
--panel-select: rgba(255,255,255,0.10);
|
|
1474
1526
|
--panel-accent: #B8FF00;
|
|
1475
1527
|
--panel-accent-fg: #0B0B09;
|
|
1528
|
+
/* Manual dark toggle must drive the --color-* system too: the @media (prefers-color-scheme: dark)
|
|
1529
|
+
block below only fires on OS preference, so without these the toggle left --color-text* dark
|
|
1530
|
+
on a dark --color-bg (witnessed 1.4:1 date cells / breadcrumb-sep on /review). */
|
|
1531
|
+
--color-primary: #64b5f6;
|
|
1532
|
+
--color-primary-hover: #42a5f5;
|
|
1533
|
+
--color-surface: #1a1a1a;
|
|
1534
|
+
--color-bg: #0f172a;
|
|
1535
|
+
--color-border: #334155;
|
|
1536
|
+
--color-text: #f1f5f9;
|
|
1537
|
+
--color-text-muted: #cbd5e1;
|
|
1538
|
+
--color-text-light: #94a3b8;
|
|
1539
|
+
--color-success: #4ade80;
|
|
1540
|
+
--color-warning: #fbbf24;
|
|
1541
|
+
--color-danger: #f87171;
|
|
1542
|
+
--color-info: #60a5fa;
|
|
1543
|
+
--shadow-sm: 0 1px 3px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.3);
|
|
1544
|
+
--shadow-md: 0 4px 6px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
|
|
1545
|
+
--shadow-lg: 0 10px 15px rgba(0,0,0,0.6), 0 4px 6px rgba(0,0,0,0.4);
|
|
1476
1546
|
}
|
|
1477
1547
|
|
|
1478
1548
|
@media (prefers-color-scheme: dark) {
|
package/src/ui/test-page.js
CHANGED
|
@@ -4,7 +4,7 @@ export function generateTestPage() {
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
<title>System Test |
|
|
7
|
+
<title>System Test | Thatcher</title>
|
|
8
8
|
<style>
|
|
9
9
|
body { font-family: monospace; padding: 2rem; background: #04141f; color: #ced4da; }
|
|
10
10
|
h1 { color: #3b82f6; }
|
|
@@ -13,9 +13,9 @@ export function generateTestPage() {
|
|
|
13
13
|
</style>
|
|
14
14
|
</head>
|
|
15
15
|
<body>
|
|
16
|
-
<h1>
|
|
17
|
-
<p class="check"
|
|
18
|
-
<p class="check"
|
|
16
|
+
<h1>Thatcher System Test</h1>
|
|
17
|
+
<p class="check">[x] Server is running</p>
|
|
18
|
+
<p class="check">[x] Page handler is operational</p>
|
|
19
19
|
<p class="info">Timestamp: ${new Date().toISOString()}</p>
|
|
20
20
|
<p class="info">Environment: ${process.env.NODE_ENV || 'development'}</p>
|
|
21
21
|
</body>
|
package/src/ui/widgets.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { h } from '@/ui/webjsx.js'
|
|
2
|
-
import { STAGE_COLORS, AVATAR_COLORS, AVATAR_SIZES, nameHash, getInitials } from '@/ui/render-helpers.js'
|
|
2
|
+
import { STAGE_COLORS, AVATAR_COLORS, AVATAR_SIZES, nameHash, getInitials, esc } from '@/ui/render-helpers.js'
|
|
3
3
|
|
|
4
4
|
export function linearProgress(value = 0, max = 100, label = '', variant = 'medium') {
|
|
5
5
|
const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
|
|
@@ -19,7 +19,7 @@ export function circularProgress(value = 0, max = 100, label = '') {
|
|
|
19
19
|
const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
|
|
20
20
|
const color = pct < 30 ? '#ef4444' : pct < 70 ? '#f59e0b' : '#22c55e'
|
|
21
21
|
const r = 40, circ = 2 * Math.PI * r, offset = circ - (pct / 100) * circ
|
|
22
|
-
const ariaLabel = label || 'Progress'
|
|
22
|
+
const ariaLabel = esc(label || 'Progress')
|
|
23
23
|
return `<div class="circular-progress" style="width:100px;height:100px" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="${ariaLabel}">
|
|
24
24
|
<svg width="100" height="100" viewBox="0 0 100 100" aria-hidden="true">
|
|
25
25
|
<circle cx="50" cy="50" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="8"/>
|
|
@@ -28,7 +28,7 @@ export function circularProgress(value = 0, max = 100, label = '') {
|
|
|
28
28
|
</svg>
|
|
29
29
|
<div class="circular-progress-text" aria-hidden="true">
|
|
30
30
|
<span class="circular-progress-pct">${pct}%</span>
|
|
31
|
-
${label ? `<span class="circular-progress-label">${label}</span>` : ''}
|
|
31
|
+
${label ? `<span class="circular-progress-label">${esc(label)}</span>` : ''}
|
|
32
32
|
</div>
|
|
33
33
|
</div>`
|
|
34
34
|
}
|
|
@@ -46,10 +46,12 @@ export function engagementProgress(stage, stages = null) {
|
|
|
46
46
|
|
|
47
47
|
export function emptyState(icon = '', title = '', message = '', actionHref = '', actionLabel = '') {
|
|
48
48
|
const btn = actionHref && actionLabel ? h('a', { href: actionHref, className: 'btn btn-primary btn-sm' }, actionLabel) : ''
|
|
49
|
+
// icon is a trusted inline-SVG/markup fragment from the caller; title/message are
|
|
50
|
+
// text and must be escaped (webjsx h() does NOT escape string children).
|
|
49
51
|
return h('div', { className: 'empty-state' },
|
|
50
|
-
h('div', { className: 'empty-state-icon'
|
|
51
|
-
h('div', { className: 'empty-state-title' }, title),
|
|
52
|
-
h('div', { className: 'empty-state-msg' }, message),
|
|
52
|
+
h('div', { className: 'empty-state-icon', innerHTML: icon }),
|
|
53
|
+
h('div', { className: 'empty-state-title' }, esc(title)),
|
|
54
|
+
h('div', { className: 'empty-state-msg' }, esc(message)),
|
|
53
55
|
btn
|
|
54
56
|
)
|
|
55
57
|
}
|
|
@@ -69,7 +71,8 @@ export function userAvatar(user, size = 'md', showStatus = false) {
|
|
|
69
71
|
const color = AVATAR_COLORS[nameHash(name) % AVATAR_COLORS.length]
|
|
70
72
|
const fontSize = Math.round(px * 0.4)
|
|
71
73
|
const statusDot = showStatus ? `<span class="avatar-status avatar-status-${user?.status === 'active' || user?.online ? 'online' : 'offline'}" style="width:${Math.round(px * 0.3)}px;height:${Math.round(px * 0.3)}px"></span>` : ''
|
|
72
|
-
|
|
74
|
+
const safeName = esc(name)
|
|
75
|
+
return `<span class="user-avatar user-avatar-${size}" style="width:${px}px;height:${px}px;background:${color};font-size:${fontSize}px" title="${safeName}" aria-label="${safeName}" role="img"><span aria-hidden="true">${esc(initials)}</span>${statusDot}</span>`
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
export function teamAvatarGroup(users = [], maxShow = 3) {
|
|
@@ -82,12 +85,12 @@ export function teamAvatarGroup(users = [], maxShow = 3) {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
export function infoBubble(text, position = 'top') {
|
|
85
|
-
return `<span class="info-bubble info-bubble-${position}" data-tooltip="${text
|
|
88
|
+
return `<span class="info-bubble info-bubble-${position}" data-tooltip="${esc(text)}"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/><text x="8" y="12" text-anchor="middle" fill="currentColor" font-size="10" font-weight="600">i</text></svg></span>`
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
export function sortableList(items = [], containerId = 'sortable') {
|
|
89
92
|
const lis = items.map((item, i) =>
|
|
90
|
-
`<li class="sortable-item" draggable="true" data-index="${i}"><span class="sortable-handle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></span><span>${typeof item === 'string' ? item : item.label || item.name || ''}</span></li>`
|
|
93
|
+
`<li class="sortable-item" draggable="true" data-index="${i}"><span class="sortable-handle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></span><span>${esc(typeof item === 'string' ? item : item.label || item.name || '')}</span></li>`
|
|
91
94
|
).join('')
|
|
92
95
|
const script = `(function(){const c=document.getElementById('${containerId}');let dragged=null;c.addEventListener('dragstart',e=>{dragged=e.target.closest('.sortable-item');e.dataTransfer.effectAllowed='move'});c.addEventListener('dragover',e=>{e.preventDefault();const t=e.target.closest('.sortable-item');if(t&&t!==dragged)t.classList.add('drag-over')});c.addEventListener('dragleave',e=>{const t=e.target.closest('.sortable-item');if(t)t.classList.remove('drag-over')});c.addEventListener('drop',e=>{e.preventDefault();const t=e.target.closest('.sortable-item');if(t&&t!==dragged){t.classList.remove('drag-over');c.insertBefore(dragged,t.nextSibling);const order=[...c.querySelectorAll('.sortable-item')].map(el=>+el.dataset.index);c.dispatchEvent(new CustomEvent('sortable-reorder',{detail:{order}}))}});})();`
|
|
93
96
|
return `<ul id="${containerId}" class="sortable-list">${lis}</ul><script>${script}</script>`
|
|
@@ -99,7 +102,7 @@ export function responseChoiceBox(name, options = [], selected = null, type = 'r
|
|
|
99
102
|
const val = typeof opt === 'string' ? opt : opt.value || opt
|
|
100
103
|
const lbl = typeof opt === 'string' ? opt : opt.label || opt.value || opt
|
|
101
104
|
const checked = sel.includes(val) ? 'checked' : ''
|
|
102
|
-
return `<label class="choice-option"><input type="${type}" name="${name}" value="${val}" ${checked}/><span class="choice-label">${lbl}</span></label>`
|
|
105
|
+
return `<label class="choice-option"><input type="${esc(type)}" name="${esc(name)}" value="${esc(val)}" ${checked}/><span class="choice-label">${esc(lbl)}</span></label>`
|
|
103
106
|
}).join('')
|
|
104
107
|
return `<div class="choice-group">${items}</div>`
|
|
105
108
|
}
|
|
@@ -112,8 +115,9 @@ export function responseAttachment(file = {}) {
|
|
|
112
115
|
const isImg = ['png','jpg','jpeg','gif','webp','svg'].includes(ext)
|
|
113
116
|
const isPdf = ext === 'pdf'
|
|
114
117
|
const icon = isImg ? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>` : isPdf ? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>` : `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`
|
|
115
|
-
const
|
|
116
|
-
|
|
118
|
+
const safeHref = esc(href), safeName = esc(name), safeSize = esc(size)
|
|
119
|
+
const preview = isImg ? `<img src="${safeHref}" alt="${safeName}" class="attachment-preview"/>` : isPdf ? `<iframe src="${safeHref}" class="attachment-preview" style="width:100%;height:200px;border:none"></iframe>` : ''
|
|
120
|
+
return `<div class="attachment-card"><span class="attachment-icon">${icon}</span><div class="attachment-info"><div class="attachment-name">${safeName}</div>${size ? `<div class="attachment-size">${safeSize}</div>` : ''}${preview}</div><a href="${safeHref}" download class="btn btn-ghost btn-xs">Download</a></div>`
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
export function accordion(items) {
|
|
@@ -121,8 +125,9 @@ export function accordion(items) {
|
|
|
121
125
|
return h('div', { className: 'accordion' },
|
|
122
126
|
items.map(item =>
|
|
123
127
|
h('details', { className: 'accordion-item' },
|
|
124
|
-
h('summary', { className: 'accordion-summary' }, item.title),
|
|
125
|
-
|
|
128
|
+
h('summary', { className: 'accordion-summary' }, esc(item.title)),
|
|
129
|
+
// content is a caller-provided HTML fragment by contract.
|
|
130
|
+
h('div', { className: 'accordion-content', innerHTML: item.content })
|
|
126
131
|
)
|
|
127
132
|
).join('')
|
|
128
133
|
)
|
|
@@ -130,7 +135,7 @@ export function accordion(items) {
|
|
|
130
135
|
|
|
131
136
|
export function divider(label) {
|
|
132
137
|
if (!label) return '<hr class="divider"/>'
|
|
133
|
-
return `<div class="divider-labeled"><hr class="divider-line"/><span class="divider-text">${label}</span><hr class="divider-line"/></div>`
|
|
138
|
+
return `<div class="divider-labeled"><hr class="divider-line"/><span class="divider-text">${esc(label)}</span><hr class="divider-line"/></div>`
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
export function responsiveClass(breakpoint) {
|
package/src/lib/audit-logger.js
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import { getDatabase, genId, now } from '@/lib/database-core';
|
|
2
|
-
import { Mutex } from '@/lib/hot-reload/mutex';
|
|
3
|
-
|
|
4
|
-
const db = getDatabase();
|
|
5
|
-
const auditMutex = new Mutex('audit-logger');
|
|
6
|
-
|
|
7
|
-
export const ACTIVITY_TYPES = {
|
|
8
|
-
RFI_QUESTION_VIEW: 'rfi_question_view',
|
|
9
|
-
RFI_QUESTION_RESPOND: 'rfi_question_respond',
|
|
10
|
-
RFI_QUESTION_COMMENT: 'rfi_question_comment',
|
|
11
|
-
RFI_QUESTION_CREATE: 'rfi_question_create',
|
|
12
|
-
RFI_QUESTION_UPDATE: 'rfi_question_update',
|
|
13
|
-
RFI_QUESTION_DELETE: 'rfi_question_delete',
|
|
14
|
-
RFI_QUESTION_ASSIGN: 'rfi_question_assign',
|
|
15
|
-
RFI_QUESTION_DEADLINE: 'rfi_question_deadline',
|
|
16
|
-
HIGHLIGHT_CREATE: 'highlight_create',
|
|
17
|
-
HIGHLIGHT_RESOLVE: 'highlight_resolve',
|
|
18
|
-
HIGHLIGHT_REOPEN: 'highlight_reopen',
|
|
19
|
-
HIGHLIGHT_DELETE: 'highlight_delete',
|
|
20
|
-
REVIEW_CREATE: 'review_create',
|
|
21
|
-
REVIEW_UPDATE: 'review_update',
|
|
22
|
-
REVIEW_ARCHIVE: 'review_archive',
|
|
23
|
-
COLLABORATOR_ADD: 'collaborator_add',
|
|
24
|
-
COLLABORATOR_REMOVE: 'collaborator_remove',
|
|
25
|
-
PERMISSION_CHANGE: 'permission_change',
|
|
26
|
-
ROLE_CHANGE: 'role_change',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const parseJson = (val) => val ? JSON.parse(val) : null;
|
|
30
|
-
|
|
31
|
-
const parseRow = (row) => row ? {
|
|
32
|
-
...row,
|
|
33
|
-
old_permissions: parseJson(row.old_permissions),
|
|
34
|
-
new_permissions: parseJson(row.new_permissions),
|
|
35
|
-
metadata: parseJson(row.metadata),
|
|
36
|
-
before_state: parseJson(row.before_state),
|
|
37
|
-
after_state: parseJson(row.after_state),
|
|
38
|
-
} : null;
|
|
39
|
-
|
|
40
|
-
export const logAction = (entityType, entityId, action, userId, beforeState, afterState) => {
|
|
41
|
-
const id = genId();
|
|
42
|
-
const timestamp = now();
|
|
43
|
-
db.prepare(`
|
|
44
|
-
INSERT INTO audit_logs (id, entity_type, entity_id, action, user_id, before_state, after_state, created_at)
|
|
45
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
46
|
-
`).run(id, entityType, entityId, action, userId || null,
|
|
47
|
-
beforeState ? JSON.stringify(beforeState) : null,
|
|
48
|
-
afterState ? JSON.stringify(afterState) : null,
|
|
49
|
-
timestamp
|
|
50
|
-
);
|
|
51
|
-
return { id, timestamp };
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export const getAuditHistory = (filters = {}, page = 1, pageSize = 50) => {
|
|
55
|
-
const wc = [], params = [];
|
|
56
|
-
if (filters.entityType) { wc.push('entity_type = ?'); params.push(filters.entityType); }
|
|
57
|
-
if (filters.entityId) { wc.push('entity_id = ?'); params.push(filters.entityId); }
|
|
58
|
-
if (filters.userId) { wc.push('user_id = ?'); params.push(filters.userId); }
|
|
59
|
-
if (filters.action) { wc.push('action = ?'); params.push(filters.action); }
|
|
60
|
-
if (filters.fromDate) { wc.push('created_at >= ?'); params.push(filters.fromDate); }
|
|
61
|
-
if (filters.toDate) { wc.push('created_at <= ?'); params.push(filters.toDate); }
|
|
62
|
-
const where = wc.length ? 'WHERE ' + wc.join(' AND ') : '';
|
|
63
|
-
const { count: total } = db.prepare(`SELECT COUNT(*) as count FROM audit_logs ${where}`).get(...params);
|
|
64
|
-
const offset = (page - 1) * pageSize;
|
|
65
|
-
const items = db.prepare(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
66
|
-
return {
|
|
67
|
-
items: items.map(i => ({
|
|
68
|
-
id: i.id, entityType: i.entity_type, entityId: i.entity_id, action: i.action,
|
|
69
|
-
userId: i.user_id, beforeState: parseJson(i.before_state), afterState: parseJson(i.after_state), createdAt: i.created_at,
|
|
70
|
-
})),
|
|
71
|
-
pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
|
|
72
|
-
};
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export const getEntityAuditTrail = (entityType, entityId) => {
|
|
76
|
-
return db.prepare(`SELECT * FROM audit_logs WHERE entity_type = ? AND entity_id = ? ORDER BY created_at DESC`).all(entityType, entityId)
|
|
77
|
-
.map(i => ({ id: i.id, action: i.action, userId: i.user_id, beforeState: parseJson(i.before_state), afterState: parseJson(i.after_state), createdAt: i.created_at }));
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const statsSql = (col) => `SELECT ${col}, COUNT(*) as count FROM audit_logs WHERE created_at >= ? AND created_at <= ? GROUP BY ${col} ORDER BY count DESC`;
|
|
81
|
-
export const getActionStats = (fromDate, toDate) => db.prepare(statsSql('action')).all(fromDate, toDate);
|
|
82
|
-
export const getUserStats = (fromDate, toDate) => db.prepare(statsSql('user_id')).all(fromDate, toDate);
|
|
83
|
-
|
|
84
|
-
export const logPermissionChange = ({
|
|
85
|
-
userId, entityType, entityId, action, oldPermissions = null, newPermissions = null,
|
|
86
|
-
reason = null, reasonCode = 'other', affectedUserId = null, ipAddress = null, sessionId = null, metadata = null,
|
|
87
|
-
}) => {
|
|
88
|
-
if (!userId || !entityType || !entityId || !action) throw new Error('Missing required audit fields');
|
|
89
|
-
const auditId = genId();
|
|
90
|
-
const timestamp = now();
|
|
91
|
-
db.prepare(`
|
|
92
|
-
INSERT INTO permission_audit (id, user_id, entity_type, entity_id, action, old_permissions, new_permissions,
|
|
93
|
-
reason, reason_code, timestamp, ip_address, session_id, affected_user_id, metadata, created_at, updated_at, created_by, updated_by)
|
|
94
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
95
|
-
`).run(auditId, userId, entityType, entityId, action,
|
|
96
|
-
oldPermissions ? JSON.stringify(oldPermissions) : null, newPermissions ? JSON.stringify(newPermissions) : null,
|
|
97
|
-
reason, reasonCode, timestamp, ipAddress, sessionId, affectedUserId,
|
|
98
|
-
metadata ? JSON.stringify(metadata) : null, timestamp, timestamp, userId, userId
|
|
99
|
-
);
|
|
100
|
-
return auditId;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
export const auditPermissionChange = async ({ user, entityType, entityId, action, oldPermissions = null,
|
|
104
|
-
newPermissions = null, affectedUserId = null, reason = null, reasonCode = 'admin_action', metadata = null }) => {
|
|
105
|
-
return auditMutex.runExclusive(() => {
|
|
106
|
-
try {
|
|
107
|
-
logPermissionChange({ userId: user.id, entityType, entityId, action, oldPermissions, newPermissions,
|
|
108
|
-
reason: reason || `Permission ${action}`, reasonCode, affectedUserId, metadata });
|
|
109
|
-
} catch (error) {
|
|
110
|
-
console.error('[Audit] Failed to log permission change:', error);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
export const auditRoleChange = async ({ user, targetUserId, oldRole, newRole, reason = null, metadata = null }) => {
|
|
116
|
-
await auditPermissionChange({ user, entityType: 'user', entityId: targetUserId, action: 'role_change',
|
|
117
|
-
oldPermissions: { role: oldRole }, newPermissions: { role: newRole },
|
|
118
|
-
affectedUserId: targetUserId, reason: reason || `Role changed from ${oldRole} to ${newRole}`,
|
|
119
|
-
reasonCode: 'role_change', metadata: { ...metadata, old_role: oldRole, new_role: newRole } });
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
export const auditCollaboratorAdded = async ({ user, reviewId, collaboratorId, collaboratorUserId, permissions = null, reason = null, metadata = null }) => {
|
|
123
|
-
await auditPermissionChange({ user, entityType: 'review', entityId: reviewId, action: 'grant',
|
|
124
|
-
newPermissions: permissions || { access: 'collaborator' }, affectedUserId: collaboratorUserId,
|
|
125
|
-
reason: reason || 'Collaborator added to review', reasonCode: 'collaborator_added',
|
|
126
|
-
metadata: { ...metadata, collaborator_id: collaboratorId, review_id: reviewId } });
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
export const auditCollaboratorRemoved = async ({ user, reviewId, collaboratorId, collaboratorUserId, permissions = null, reason = null, metadata = null }) => {
|
|
130
|
-
await auditPermissionChange({ user, entityType: 'review', entityId: reviewId, action: 'revoke',
|
|
131
|
-
oldPermissions: permissions || { access: 'collaborator' }, affectedUserId: collaboratorUserId,
|
|
132
|
-
reason: reason || 'Collaborator removed from review', reasonCode: 'collaborator_removed',
|
|
133
|
-
metadata: { ...metadata, collaborator_id: collaboratorId, review_id: reviewId } });
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
export const auditLifecycleTransition = async ({ user, entityType, entityId, fromStage, toStage, metadata = null }) => {
|
|
137
|
-
await auditPermissionChange({ user, entityType, entityId, action: 'modify',
|
|
138
|
-
oldPermissions: { stage: fromStage }, newPermissions: { stage: toStage },
|
|
139
|
-
reason: `Lifecycle transition: ${fromStage} -> ${toStage}`, reasonCode: 'lifecycle_transition',
|
|
140
|
-
metadata: { ...metadata, from_stage: fromStage, to_stage: toStage } });
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
export const auditPermissionModify = async ({ user, entityType, entityId, oldPermissions, newPermissions,
|
|
144
|
-
affectedUserId = null, reason = null, reasonCode = 'admin_action', metadata = null }) => {
|
|
145
|
-
await auditPermissionChange({ user, entityType, entityId, action: 'modify',
|
|
146
|
-
oldPermissions, newPermissions, affectedUserId, reason: reason || 'Permissions modified', reasonCode, metadata });
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
export const getPermissionAuditTrail = ({ entityType, entityId, userId, affectedUserId, limit = 100, offset = 0 }) => {
|
|
150
|
-
let sql = 'SELECT * FROM permission_audit WHERE 1=1';
|
|
151
|
-
const params = [];
|
|
152
|
-
if (entityType) { sql += ' AND entity_type = ?'; params.push(entityType); }
|
|
153
|
-
if (entityId) { sql += ' AND entity_id = ?'; params.push(entityId); }
|
|
154
|
-
if (userId) { sql += ' AND user_id = ?'; params.push(userId); }
|
|
155
|
-
if (affectedUserId) { sql += ' AND affected_user_id = ?'; params.push(affectedUserId); }
|
|
156
|
-
sql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?';
|
|
157
|
-
return db.prepare(sql).all(...params, limit, offset).map(parseRow);
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
export const getPermissionAuditById = (auditId) => parseRow(db.prepare('SELECT * FROM permission_audit WHERE id = ?').get(auditId));
|
|
161
|
-
export const getPermissionAuditStats = () => db.prepare(`SELECT COUNT(*) as total_audits, COUNT(DISTINCT user_id) as unique_users, COUNT(DISTINCT entity_type) as entity_types, MIN(timestamp) as earliest_change, MAX(timestamp) as latest_change FROM permission_audit`).get();
|
|
162
|
-
export const getPermissionAuditBreakdown = (field) => db.prepare(`SELECT ${field}, COUNT(*) as count FROM permission_audit GROUP BY ${field} ORDER BY count DESC`).all();
|
|
163
|
-
|
|
164
|
-
export const searchPermissionAudit = (searchTerm, limit = 100) => {
|
|
165
|
-
const pattern = `%${searchTerm}%`;
|
|
166
|
-
return db.prepare(`SELECT * FROM permission_audit WHERE reason LIKE ? OR entity_type LIKE ? OR entity_id LIKE ? ORDER BY timestamp DESC LIMIT ?`)
|
|
167
|
-
.all(pattern, pattern, pattern, limit).map(parseRow);
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
export const getPermissionAuditByDateRange = (startDate, endDate, limit = 100) =>
|
|
171
|
-
db.prepare(`SELECT * FROM permission_audit WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ?`).all(startDate, endDate, limit).map(parseRow);
|
|
172
|
-
|
|
173
|
-
export const exportPermissionAuditCSV = async (filters = {}) => {
|
|
174
|
-
const trail = getPermissionAuditTrail({ ...filters, limit: 10000 });
|
|
175
|
-
const h = 'Timestamp,Changed By,Entity Type,Entity ID,Action,Reason Code,Reason,Affected User,IP Address';
|
|
176
|
-
const rows = trail.map(a => [new Date(a.timestamp * 1000).toISOString(), a.user_id, a.entity_type,
|
|
177
|
-
a.entity_id, a.action, a.reason_code, a.reason || '', a.affected_user_id || '', a.ip_address || '']
|
|
178
|
-
.map(c => `"${c}"`).join(','));
|
|
179
|
-
return [h, ...rows].join('\n');
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
export const logQuestionActivity = (questionId, action, userId, details = null) => {
|
|
183
|
-
return logAction('rfi_question', questionId, action, userId, null, details);
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
export const getPermissionDiff = (oldPerms, newPerms) => {
|
|
187
|
-
const diff = { added: [], removed: [], unchanged: [] };
|
|
188
|
-
const oldSet = new Set(Array.isArray(oldPerms) ? oldPerms : []);
|
|
189
|
-
const newSet = new Set(Array.isArray(newPerms) ? newPerms : []);
|
|
190
|
-
for (const p of newSet) { if (!oldSet.has(p)) diff.added.push(p); else diff.unchanged.push(p); }
|
|
191
|
-
for (const p of oldSet) { if (!newSet.has(p)) diff.removed.push(p); }
|
|
192
|
-
return diff;
|
|
193
|
-
};
|