thatcher 1.0.4

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 (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/package.json +73 -0
  4. package/src/adapters/google-auth.js +148 -0
  5. package/src/adapters/google-drive.js +209 -0
  6. package/src/app/api/[entity]/[[...path]]/route.js +33 -0
  7. package/src/app/api/audit/dashboard/route.js +77 -0
  8. package/src/app/api/audit/logs/route.js +46 -0
  9. package/src/app/api/audit/permissions/[id]/route.js +37 -0
  10. package/src/app/api/audit/permissions/route.js +93 -0
  11. package/src/app/api/audit/permissions/stats/route.js +29 -0
  12. package/src/app/api/audit/route.js +79 -0
  13. package/src/app/api/audit/stats/route.js +18 -0
  14. package/src/app/api/auth/google/callback/route.js +94 -0
  15. package/src/app/api/auth/google/route.js +58 -0
  16. package/src/app/api/auth/login/route.js +121 -0
  17. package/src/app/api/auth/logout/route.js +52 -0
  18. package/src/app/api/auth/me/route.js +16 -0
  19. package/src/app/api/auth/mwr-bridge/route.js +77 -0
  20. package/src/app/api/auth/password-reset/route.js +65 -0
  21. package/src/app/api/cron/trigger/route.js +58 -0
  22. package/src/app/api/csrf-token/route.js +8 -0
  23. package/src/app/api/debug/config/route.js +22 -0
  24. package/src/app/api/debug/hooks/route.js +16 -0
  25. package/src/app/api/debug/plugins/route.js +20 -0
  26. package/src/app/api/debug/sqlite/route.js +21 -0
  27. package/src/app/api/debug/sync/route.js +29 -0
  28. package/src/app/api/debug/workflow/route.js +20 -0
  29. package/src/app/api/domains/[domain]/route.js +26 -0
  30. package/src/app/api/domains/route.js +21 -0
  31. package/src/app/api/email/allocate/batch/route.js +106 -0
  32. package/src/app/api/email/allocate/route.js +141 -0
  33. package/src/app/api/email/receive/route.js +158 -0
  34. package/src/app/api/email/route.js +3 -0
  35. package/src/app/api/email/send/route.js +77 -0
  36. package/src/app/api/email/unallocated/route.js +47 -0
  37. package/src/app/api/files/[id]/route.js +38 -0
  38. package/src/app/api/health/route.js +95 -0
  39. package/src/app/api/metrics/route.js +73 -0
  40. package/src/app/api/monitoring/dashboard/route.js +18 -0
  41. package/src/cli.js +243 -0
  42. package/src/config/config-loader.js +112 -0
  43. package/src/config/constants.js +127 -0
  44. package/src/config/env.js +164 -0
  45. package/src/config/spec-helpers.js +232 -0
  46. package/src/engine.server.js +212 -0
  47. package/src/index.js +368 -0
  48. package/src/lib/accessibility.js +162 -0
  49. package/src/lib/action-factory.js +34 -0
  50. package/src/lib/action-utils.js +21 -0
  51. package/src/lib/alert-manager.js +189 -0
  52. package/src/lib/api-error-wrapper.js +125 -0
  53. package/src/lib/api-helpers.js +53 -0
  54. package/src/lib/api.js +82 -0
  55. package/src/lib/audit-logger-enhanced.js +117 -0
  56. package/src/lib/audit-logger.js +193 -0
  57. package/src/lib/auth-middleware.js +102 -0
  58. package/src/lib/auth-route-helpers.js +83 -0
  59. package/src/lib/business-rules-engine.js +86 -0
  60. package/src/lib/compression.js +44 -0
  61. package/src/lib/config-field-helpers.js +91 -0
  62. package/src/lib/config-generator-engine.js +445 -0
  63. package/src/lib/config-helpers.js +120 -0
  64. package/src/lib/connection-guard.js +79 -0
  65. package/src/lib/crud-action-helpers.js +34 -0
  66. package/src/lib/crud-factory.js +83 -0
  67. package/src/lib/crud-handlers.js +244 -0
  68. package/src/lib/csrf-protection.js +63 -0
  69. package/src/lib/database-core.js +258 -0
  70. package/src/lib/database-migrations.js +96 -0
  71. package/src/lib/date-utils.js +159 -0
  72. package/src/lib/db-backup.js +97 -0
  73. package/src/lib/db-monitor.js +127 -0
  74. package/src/lib/domain-loader.js +82 -0
  75. package/src/lib/email-sender.js +100 -0
  76. package/src/lib/error-boundary.js +134 -0
  77. package/src/lib/error-handler.js +84 -0
  78. package/src/lib/error-recovery.js +190 -0
  79. package/src/lib/error-resilience.js +130 -0
  80. package/src/lib/errors.js +69 -0
  81. package/src/lib/events-engine.js +182 -0
  82. package/src/lib/field-iterator.js +50 -0
  83. package/src/lib/field-registry.js +68 -0
  84. package/src/lib/field-types.js +154 -0
  85. package/src/lib/generic-crud-handler.js +32 -0
  86. package/src/lib/health-monitor.js +134 -0
  87. package/src/lib/hook-engine.js +169 -0
  88. package/src/lib/hot-reload/cache-invalidator.js +115 -0
  89. package/src/lib/hot-reload/checkpoint.js +95 -0
  90. package/src/lib/hot-reload/debug-exposure.js +67 -0
  91. package/src/lib/hot-reload/directory-watcher.js +96 -0
  92. package/src/lib/hot-reload/index.js +50 -0
  93. package/src/lib/hot-reload/mutex.js +75 -0
  94. package/src/lib/hot-reload/promise-container.js +66 -0
  95. package/src/lib/hot-reload/route-wrapper.js +46 -0
  96. package/src/lib/hot-reload/safe-error.js +51 -0
  97. package/src/lib/hot-reload/supervisor.js +161 -0
  98. package/src/lib/hot-reload/timeout-wrapper.js +52 -0
  99. package/src/lib/http-methods-factory.js +25 -0
  100. package/src/lib/index-optimizer.js +96 -0
  101. package/src/lib/index.js +35 -0
  102. package/src/lib/list-data-transform.js +39 -0
  103. package/src/lib/log-aggregator.js +116 -0
  104. package/src/lib/logger.js +55 -0
  105. package/src/lib/metrics-collector.js +102 -0
  106. package/src/lib/minifier.js +19 -0
  107. package/src/lib/monitoring-init.js +67 -0
  108. package/src/lib/next-compat.js +80 -0
  109. package/src/lib/next-polyfills.js +135 -0
  110. package/src/lib/perf-monitor.js +91 -0
  111. package/src/lib/progress-components.js +181 -0
  112. package/src/lib/query-cache.js +126 -0
  113. package/src/lib/query-engine-write.js +221 -0
  114. package/src/lib/query-engine.js +399 -0
  115. package/src/lib/query-perf.js +117 -0
  116. package/src/lib/query-string-adapter.js +75 -0
  117. package/src/lib/realtime-server.js +67 -0
  118. package/src/lib/render-cache.js +61 -0
  119. package/src/lib/request-tracker.js +43 -0
  120. package/src/lib/resource-hints.js +29 -0
  121. package/src/lib/resource-monitor.js +117 -0
  122. package/src/lib/response-formatter.js +80 -0
  123. package/src/lib/route-helpers.js +33 -0
  124. package/src/lib/route-resolver.js +142 -0
  125. package/src/lib/safe-json.js +8 -0
  126. package/src/lib/server-bootstrap.js +71 -0
  127. package/src/lib/stage-pipeline.js +153 -0
  128. package/src/lib/state-protocol.js +171 -0
  129. package/src/lib/state-transport-client.js +169 -0
  130. package/src/lib/state-transport-reconnect.js +121 -0
  131. package/src/lib/state-transport-server.js +181 -0
  132. package/src/lib/static-server.js +97 -0
  133. package/src/lib/status-helpers.js +98 -0
  134. package/src/lib/universal-handler.js +7 -0
  135. package/src/lib/utils.js +93 -0
  136. package/src/lib/validate.js +197 -0
  137. package/src/lib/validation/business-validators.js +61 -0
  138. package/src/lib/validation/csrf.js +51 -0
  139. package/src/lib/validation/file-validators.js +34 -0
  140. package/src/lib/validation/format-validators.js +106 -0
  141. package/src/lib/validation/index.js +19 -0
  142. package/src/lib/validation/rate-limit.js +31 -0
  143. package/src/lib/validation/security-validators.js +78 -0
  144. package/src/lib/validation-middleware.js +133 -0
  145. package/src/lib/validators.js +105 -0
  146. package/src/lib/with-audit-logging.js +63 -0
  147. package/src/lib/with-error-handler.js +31 -0
  148. package/src/lib/workflow-engine.js +250 -0
  149. package/src/server/server.js +305 -0
  150. package/src/services/collaborator-role.service.js +205 -0
  151. package/src/services/email-sender.js +105 -0
  152. package/src/services/notification-engine.js +110 -0
  153. package/src/services/permission.service.js +181 -0
  154. package/src/ui/advanced-search-renderer.js +42 -0
  155. package/src/ui/advanced-widgets.js +47 -0
  156. package/src/ui/auth-pages.js +114 -0
  157. package/src/ui/auth-styles.js +53 -0
  158. package/src/ui/client.js +99 -0
  159. package/src/ui/collaboration-dialogs.js +31 -0
  160. package/src/ui/common-handlers.js +156 -0
  161. package/src/ui/component-engine.js +103 -0
  162. package/src/ui/dashboard-renderer.js +150 -0
  163. package/src/ui/dialog-engine.js +147 -0
  164. package/src/ui/dialog-factory.js +38 -0
  165. package/src/ui/engagement-cards.js +76 -0
  166. package/src/ui/engagement-dialogs.js +100 -0
  167. package/src/ui/engagement-grid-renderer.js +109 -0
  168. package/src/ui/entity-renderer.js +176 -0
  169. package/src/ui/event-delegation.js +108 -0
  170. package/src/ui/fetch-json.js +24 -0
  171. package/src/ui/file-dialogs.js +81 -0
  172. package/src/ui/flexup-report-renderer.js +173 -0
  173. package/src/ui/format-helpers.js +102 -0
  174. package/src/ui/global-tags.js +144 -0
  175. package/src/ui/highlight-threading-renderer.js +176 -0
  176. package/src/ui/idle-logout.js +156 -0
  177. package/src/ui/job-management-renderer.js +61 -0
  178. package/src/ui/layout.js +219 -0
  179. package/src/ui/letter-dialogs.js +37 -0
  180. package/src/ui/ml-console-renderer.js +108 -0
  181. package/src/ui/monitoring-dashboard-client.js +136 -0
  182. package/src/ui/monitoring-dashboard.js +134 -0
  183. package/src/ui/notifications-renderer.js +51 -0
  184. package/src/ui/page-handler-admin.js +121 -0
  185. package/src/ui/page-handler-helpers.js +111 -0
  186. package/src/ui/page-handler-reviews.js +165 -0
  187. package/src/ui/page-handler-rfi.js +42 -0
  188. package/src/ui/page-handler.js +210 -0
  189. package/src/ui/password-reset-page.js +146 -0
  190. package/src/ui/perf-helpers.js +96 -0
  191. package/src/ui/perf-renderer.js +71 -0
  192. package/src/ui/permissions-ui.js +163 -0
  193. package/src/ui/picker-dialogs.js +100 -0
  194. package/src/ui/render-helpers.js +124 -0
  195. package/src/ui/renderer.js +35 -0
  196. package/src/ui/review-comparison-renderer.js +58 -0
  197. package/src/ui/review-detail-panels.js +71 -0
  198. package/src/ui/review-detail-renderer.js +170 -0
  199. package/src/ui/review-detail-script.js +95 -0
  200. package/src/ui/review-mwr-renderer.js +113 -0
  201. package/src/ui/review-renderer.js +202 -0
  202. package/src/ui/review-widgets.js +88 -0
  203. package/src/ui/review-zone-nav.js +12 -0
  204. package/src/ui/rfi-detail-renderer.js +191 -0
  205. package/src/ui/rfi-renderer.js +194 -0
  206. package/src/ui/rfi-report-renderer.js +56 -0
  207. package/src/ui/rippleui.css +1 -0
  208. package/src/ui/settings-renderer-advanced.js +195 -0
  209. package/src/ui/settings-renderer-advanced2.js +158 -0
  210. package/src/ui/settings-renderer-teams.js +112 -0
  211. package/src/ui/settings-renderer.js +166 -0
  212. package/src/ui/spacing-system.js +155 -0
  213. package/src/ui/standalone-login.js +109 -0
  214. package/src/ui/styles.css +2530 -0
  215. package/src/ui/styles2.css +1602 -0
  216. package/src/ui/test-page.js +23 -0
  217. package/src/ui/validation-rules.js +73 -0
  218. package/src/ui/validation-ui.js +147 -0
  219. package/src/ui/virtual-scroll.js +107 -0
  220. package/src/ui/webjsx.js +61 -0
  221. package/src/ui/widgets.js +152 -0
@@ -0,0 +1,165 @@
1
+ import { list, get } from '@/lib/query-engine.js';
2
+ import { getSpec } from '@/config/spec-helpers.js';
3
+ import { getDatabase } from '@/lib/database-core.js';
4
+ import { renderEntityList, renderAccessDenied } from '@/ui/renderer.js';
5
+ import { renderSectionReport, renderReviewListTabbed } from '@/ui/review-renderer.js';
6
+ import { renderChecklistDetails, renderChecklistsHome } from '@/ui/checklist-renderer.js';
7
+ import { renderPdfViewer, renderPdfEditorPlaceholder } from '@/ui/pdf-viewer-renderer.js';
8
+ import { renderReviewAnalytics } from '@/ui/review-analytics-renderer.js';
9
+ import { renderHighlightThreading } from '@/ui/highlight-threading-renderer.js';
10
+ import { renderSectionResolution } from '@/ui/section-resolution-renderer.js';
11
+ import { renderReviewComparison, renderComparisonPicker } from '@/ui/review-comparison-renderer.js';
12
+ import { renderTenderDashboard } from '@/ui/tender-dashboard-renderer.js';
13
+ import { renderBatchOperations } from '@/ui/batch-review-renderer.js';
14
+ import { canList, canView, canEdit } from '@/ui/permissions-ui.js';
15
+ import { resolveRefFields } from '@/ui/page-handler-helpers.js';
16
+ import { fileURLToPath } from 'url';
17
+ const __dirname_rv = fileURLToPath(new URL('.', import.meta.url));
18
+
19
+ async function lazyRenderer(name) {
20
+ const t = globalThis.__reloadTs__ || Date.now();
21
+ return import(`file://${__dirname_rv}${name}?t=${t}`);
22
+ }
23
+
24
+ export function handleFilteredReviewList(user, filter) {
25
+ if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
26
+ const spec = getSpec('review'); if (!spec) return null;
27
+ let items = list('review', {});
28
+ if (filter === 'active') items = items.filter(r => r.status === 'active' || r.status === 'open');
29
+ else if (filter === 'priority') { const pids = user.priority_reviews || []; items = items.filter(r => pids.includes(r.id)); }
30
+ else if (filter === 'history') items = items.filter(r => r.status === 'closed' || r.status === 'completed');
31
+ else if (filter === 'archive') items = items.filter(r => r.status === 'archived');
32
+ return renderEntityList('review', resolveRefFields(items, spec), spec, user);
33
+ }
34
+
35
+ export async function handleReviewRoutes(normalized, segments, user, req) {
36
+ if (normalized === '/reviews/active' || normalized === '/review/active') return handleFilteredReviewList(user, 'active');
37
+ if (normalized === '/reviews/priority' || normalized === '/review/priority') return handleFilteredReviewList(user, 'priority');
38
+ if (normalized === '/reviews/history' || normalized === '/review/history') return handleFilteredReviewList(user, 'history');
39
+ if (normalized === '/reviews/archive' || normalized === '/review/archive') return handleFilteredReviewList(user, 'archive');
40
+
41
+ if (normalized === '/reviews' || normalized === '/review') {
42
+ if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
43
+ let reviews = []; try { reviews = getDatabase().prepare('SELECT * FROM review ORDER BY updated_at DESC').all(); } catch { try { reviews = list('review', {}); } catch {} }
44
+ const engMap = {}; try { list('engagement', {}).forEach(e => { engMap[e.id] = e.name; }); } catch {}
45
+ return renderReviewListTabbed(user, reviews.map(r => ({ ...r, engagement_name: engMap[r.engagement_id] || r.engagement_name || null })));
46
+ }
47
+
48
+ if (segments[0] === 'review' && segments.length === 3 && segments[2] === 'sections') {
49
+ const reviewId = segments[1];
50
+ if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view');
51
+ const review = get('review', reviewId) || getDatabase().prepare('SELECT * FROM review WHERE id=?').get(reviewId);
52
+ if (!review) return null;
53
+ let sections = [];
54
+ try { sections = list('review_section', {}).filter(s => s.review_id === reviewId); } catch (e) { console.error('Failed to fetch sections', e); }
55
+ return renderSectionReport(user, review, sections);
56
+ }
57
+
58
+ if (normalized === '/reviews/analytics' || normalized === '/review/analytics') {
59
+ if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
60
+ let reviews = [], highlights = [], activity = [];
61
+ try { reviews = list('review', {}); } catch {} try { highlights = list('highlight', {}); } catch {} try { activity = list('activity_log', {}).slice(0, 50); } catch {}
62
+ return renderReviewAnalytics(user, { reviews, highlights, recentActivity: activity });
63
+ }
64
+
65
+ if (normalized === '/reviews/compare' || normalized === '/review/compare') {
66
+ if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
67
+ const url = new URL(req.url, `http://${req.headers.host||'localhost'}`);
68
+ const leftId = url.searchParams.get('left'), rightId = url.searchParams.get('right');
69
+ if (leftId && rightId) {
70
+ const leftReview = get('review', leftId), rightReview = get('review', rightId);
71
+ if (!leftReview || !rightReview) return null;
72
+ let leftH = [], rightH = [];
73
+ try { leftH = list('highlight', {}).filter(h => h.review_id === leftId); } catch {} try { rightH = list('highlight', {}).filter(h => h.review_id === rightId); } catch {}
74
+ return renderReviewComparison(user, leftReview, rightReview, leftH, rightH);
75
+ }
76
+ let reviews = []; try { reviews = list('review', {}); } catch {}
77
+ return renderComparisonPicker(user, reviews);
78
+ }
79
+
80
+ if (normalized === '/reviews/tenders' || normalized === '/review/tenders') {
81
+ if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
82
+ let tenders = [], reviews = [];
83
+ try { tenders = list('tender', {}); } catch {} try { reviews = list('review', {}); } catch {}
84
+ return renderTenderDashboard(user, tenders.map(t => { const r = reviews.find(rev => rev.id === t.review_id); return { ...t, review_name: r?.name || r?.title || '' }; }), reviews);
85
+ }
86
+
87
+ if (normalized === '/reviews/batch' || normalized === '/review/batch') {
88
+ if (!canEdit(user, 'review')) return renderAccessDenied(user, 'review', 'edit');
89
+ let reviews = []; try { reviews = list('review', {}); } catch {}
90
+ const spec = getSpec('review'); if (spec) reviews = resolveRefFields(reviews, spec);
91
+ return renderBatchOperations(user, reviews);
92
+ }
93
+
94
+ if (segments[0] === 'review' && segments.length === 3) {
95
+ const reviewId = segments[1], action = segments[2];
96
+ const getReview = (id) => get('review', id) || getDatabase().prepare('SELECT * FROM review WHERE id=?').get(id);
97
+ if (action === 'pdf') {
98
+ if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view');
99
+ const review = getReview(reviewId); if (!review) return null;
100
+ let h = [], s = [];
101
+ try { h = list('highlight', {}).filter(x => x.review_id === reviewId); } catch {}
102
+ try { s = list('review_section', {}).filter(x => x.review_id === reviewId); } catch {}
103
+ // Flatten bounding_rect JSON → x/y/width/height for the overlay renderer.
104
+ h = h.map((x) => {
105
+ let br = null; try { br = typeof x.bounding_rect === 'string' ? JSON.parse(x.bounding_rect) : x.bounding_rect; } catch {}
106
+ let rects = []; try { rects = typeof x.rects === 'string' ? JSON.parse(x.rects) : (x.rects || []); } catch {}
107
+ return {
108
+ ...x,
109
+ x: br?.x1 ?? 0,
110
+ y: br?.y1 ?? 0,
111
+ width: br?.width ?? 0,
112
+ height: br?.height ?? 0,
113
+ rects,
114
+ page_number: Number(x.page_number) || 1,
115
+ };
116
+ });
117
+ return renderPdfViewer(user, review, h, s);
118
+ }
119
+ if (action === 'editor') { if (!canEdit(user, 'review')) return renderAccessDenied(user, 'review', 'edit'); const review = getReview(reviewId); if (!review) return null; return renderPdfEditorPlaceholder(user, review); }
120
+ if (action === 'highlights') { if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view'); const review = getReview(reviewId); if (!review) return null; let h = []; try { h = list('highlight', {}).filter(x => x.review_id === reviewId); } catch {} const rm = {}; for (const x of h) { try { rm[x.id] = list('highlight_response', {}).filter(r => r.highlight_id === x.id); } catch { rm[x.id] = []; } } return renderHighlightThreading(user, review, h, rm); }
121
+ if (action === 'resolution') { if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view'); const review = getReview(reviewId); if (!review) return null; let s = []; try { s = list('review_section', {}).filter(x => x.review_id === reviewId); } catch {} const hbs = {}; for (const sec of s) { try { hbs[sec.id] = list('highlight', {}).filter(h => h.review_id === reviewId && h.section_id === sec.id); } catch { hbs[sec.id] = []; } } return renderSectionResolution(user, review, s, hbs); }
122
+ if (action === 'edit') {
123
+ if (!canEdit(user, 'review')) return renderAccessDenied(user, 'review', 'edit');
124
+ const review = get('review', reviewId) || getDatabase().prepare('SELECT * FROM review WHERE id=?').get(reviewId);
125
+ if (!review) return null;
126
+ let engagements = [], teams = [];
127
+ try { engagements = list('engagement', {}); } catch {}
128
+ try { teams = list('team', {}); } catch {}
129
+ const { renderReviewEdit } = await lazyRenderer('review-edit-renderer.js');
130
+ return renderReviewEdit(user, review, engagements, teams);
131
+ }
132
+ return null;
133
+ }
134
+
135
+ if (segments.length === 2 && segments[0] === 'review' && segments[1] !== 'new') {
136
+ const reviewId = segments[1];
137
+ if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view');
138
+ const review = get('review', reviewId) || getDatabase().prepare('SELECT * FROM review WHERE id=?').get(reviewId); if (!review) return null;
139
+ let highlights = [], collaborators = [], checklists = [], sections = [], tenders = [];
140
+ try { highlights = list('highlight', {}).filter(h => h.review_id === reviewId); } catch {}
141
+ try { collaborators = list('collaborator', {}).filter(c => c.review_id === reviewId); } catch {}
142
+ try { checklists = list('checklist', {}).filter(c => c.review_id === reviewId).map(c => { let items = []; try { items = list('checklist_item', {}).filter(i => i.checklist_id === c.id); } catch {} return { ...c, total_items: items.length, completed_items: items.filter(i => i.completed).length }; }); } catch {}
143
+ try { sections = list('review_section', {}).filter(s => s.review_id === reviewId); } catch {}
144
+ try { tenders = list('tender', {}).filter(t => t.review_id === reviewId); } catch {}
145
+ const { renderReviewDetail } = await lazyRenderer('review-detail-renderer.js');
146
+ return renderReviewDetail(user, review, highlights, collaborators, checklists, sections, tenders);
147
+ }
148
+
149
+ if (segments[0] === 'checklist' && segments.length === 2 && segments[1] !== 'new') {
150
+ const checklistId = segments[1];
151
+ if (!canView(user, 'checklist')) return renderAccessDenied(user, 'checklist', 'view');
152
+ const checklist = get('checklist', checklistId); if (!checklist) return null;
153
+ let items = []; try { items = getDatabase().prepare('SELECT * FROM checklist_item WHERE checklist_id=? ORDER BY "order" ASC, created_at ASC').all(checklistId); } catch {}
154
+ return renderChecklistDetails(user, checklist, items);
155
+ }
156
+
157
+ if (normalized === '/checklists' || normalized === '/checklist') {
158
+ if (!canList(user, 'checklist')) return renderAccessDenied(user, 'checklist', 'list');
159
+ let checklists = [];
160
+ try { const db = getDatabase(); const stats = db.prepare('SELECT checklist_id, COUNT(*) as total, SUM(CASE WHEN is_done=1 THEN 1 ELSE 0 END) as done FROM checklist_item GROUP BY checklist_id').all(); const statsMap = Object.fromEntries(stats.map(s => [s.checklist_id, s])); checklists = list('checklist', {}).map(c => ({ ...c, total_items: statsMap[c.id]?.total || 0, completed_items: statsMap[c.id]?.done || 0 })); } catch {}
161
+ return renderChecklistsHome(user, checklists);
162
+ }
163
+
164
+ return null;
165
+ }
@@ -0,0 +1,42 @@
1
+ import { list, get } from '@/lib/query-engine.js';
2
+ import { getDatabase } from '@/lib/database-core.js';
3
+ import { canView, canList, isClerk, isPartner } from '@/ui/permissions-ui.js';
4
+ import { renderAccessDenied } from '@/ui/renderer.js';
5
+ import { renderRfiList } from '@/ui/rfi-list-renderer.js';
6
+ import { fileURLToPath } from 'url';
7
+ const __dirname_rfi = fileURLToPath(new URL('.', import.meta.url));
8
+ const lazyR = (name) => import(`file://${__dirname_rfi}${name}?t=${globalThis.__reloadTs__ || Date.now()}`);
9
+
10
+ export async function handleRfiDetail(user, rfiId) {
11
+ if (!canView(user, 'rfi')) return renderAccessDenied(user, 'rfi', 'view');
12
+ const db = getDatabase();
13
+ const rfi = db.prepare('SELECT * FROM rfis WHERE id=?').get(rfiId) || get('rfi', rfiId); if (!rfi) return null;
14
+ let questions = []; try { questions = db.prepare('SELECT q.*, (SELECT COUNT(*) FROM rfi_responses r WHERE r.question_id=q.id) as response_count FROM rfi_questions q WHERE q.rfi_id=?').all(rfiId); } catch { try { questions = list('rfi_question', {}).filter(q => q.rfi_id === rfiId); } catch {} }
15
+ let sections = []; try { sections = list('rfi_section', {}).filter(s => s.rfi_id === rfiId || s.engagement_id === rfi.engagement_id); } catch {}
16
+ let engagement = null; try { if (rfi.engagement_id) engagement = get('engagement', rfi.engagement_id); } catch {}
17
+ const { renderRfiDetail } = await lazyR('rfi-detail-renderer.js');
18
+ return renderRfiDetail(user, rfi, questions, sections, engagement);
19
+ }
20
+
21
+ export async function handleRfiList(user) {
22
+ if (!canList(user, 'rfi')) return renderAccessDenied(user, 'rfi', 'list');
23
+ let rfis = []; try { rfis = getDatabase().prepare('SELECT * FROM rfis ORDER BY created_at ASC').all(); } catch {}
24
+ let engagements = []; try { engagements = list('engagement', {}); } catch {}
25
+ if (isClerk(user)) rfis = rfis.filter(r => engagements.filter(e => e.assigned_to === user.id || e.team_id === user.team_id).some(e => e.id === r.engagement_id));
26
+ return renderRfiList(user, rfis.map((r, i) => ({ ...r, display_name: 'RFI #' + (i + 1) })), engagements);
27
+ }
28
+
29
+ export async function handleRfiReport(user, rfiId, res) {
30
+ const db = getDatabase();
31
+ let rfi = null, questions = [], responses = [], engagement = null;
32
+ try { rfi = get('rfi', rfiId) || db.prepare('SELECT * FROM rfis WHERE id=?').get(rfiId); } catch {}
33
+ if (!rfi) return null;
34
+ try { questions = db.prepare('SELECT * FROM rfi_questions WHERE rfi_id=? ORDER BY created_at ASC').all(rfiId); } catch {}
35
+ try { const qIds = questions.map(q => q.id); if (qIds.length) { const ph = qIds.map(() => '?').join(','); responses = db.prepare(`SELECT * FROM rfi_responses WHERE question_id IN (${ph})`).all(...qIds); } } catch {}
36
+ try { engagement = rfi.engagement_id ? (get('engagement', rfi.engagement_id) || db.prepare('SELECT * FROM engagement WHERE id=?').get(rfi.engagement_id)) : null; } catch {}
37
+ const { renderRfiReport } = await lazyR('rfi-report-renderer.js');
38
+ const html = renderRfiReport(user, rfi, engagement, questions, responses);
39
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
40
+ res.setHeader('Content-Length', Buffer.byteLength(html, 'utf-8'));
41
+ res.writeHead(200); res.end(html); return 'HANDLED';
42
+ }
@@ -0,0 +1,210 @@
1
+ import { getUser, setCurrentRequest } from '@/engine.server.js';
2
+ import { hasGoogleAuth } from '@/config/env.js';
3
+ import { getSpec } from '@/config/spec-helpers.js';
4
+ import { list, get } from '@/lib/query-engine.js';
5
+ import { getDatabase } from '@/lib/database-core.js';
6
+ import { renderLogin, renderDashboard, renderEntityList, renderEntityDetail, renderEntityForm, renderAccessDenied, renderPasswordReset, renderPasswordResetConfirm, REDIRECT } from '@/ui/renderer.js';
7
+ import { renderClientDashboard, renderClientList } from '@/ui/client-renderer.js';
8
+ import { canList, canView, canCreate, canEdit, isPartner, isClerk, isClientUser, canClientAccessEntity } from '@/ui/permissions-ui.js';
9
+ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
10
+ import { renderClientProgress } from '@/ui/client-progress-renderer.js';
11
+ import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
12
+ import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
13
+ import { getDashboardStats, getClientDashboardStats, resolveRefFields, resolveEnumOptions, getRefOptions } from '@/ui/page-handler-helpers.js';
14
+ import { handleAdminPage } from '@/ui/page-handler-admin.js';
15
+ import { handleReviewRoutes } from '@/ui/page-handler-reviews.js';
16
+ import { handleRfiDetail, handleRfiList, handleRfiReport } from '@/ui/page-handler-rfi.js';
17
+ import { fileURLToPath } from 'url';
18
+
19
+ const __dirname_ph = fileURLToPath(new URL('.', import.meta.url));
20
+ const lazyRenderer = (name) => import(`file://${__dirname_ph}${name}?t=${globalThis.__reloadTs__ || Date.now()}`);
21
+
22
+ function reqUrl(req) {
23
+ return new URL(req.url, `http://${req.headers.host || 'localhost'}`);
24
+ }
25
+
26
+ async function handleEngagementDetail(user, engId) {
27
+ if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
28
+ const db = getDatabase();
29
+ const engagement = get('engagement', engId) || db.prepare('SELECT * FROM engagement WHERE id=?').get(engId);
30
+ if (!engagement) return null;
31
+ let client = null; try { client = engagement.client_id ? get('client', engagement.client_id) : null; } catch {}
32
+ let rfis = []; try { rfis = db.prepare('SELECT * FROM rfis WHERE engagement_id=?').all(engId); } catch { try { rfis = list('rfi', {}).filter(r => r.engagement_id === engId); } catch {} }
33
+ let sections = []; try { sections = db.prepare('SELECT * FROM rfi_section WHERE engagement_id=? ORDER BY sort_order ASC').all(engId); } catch {}
34
+ let team = null; try { team = engagement.team_id ? get('team', engagement.team_id) : null; } catch {}
35
+ let assignedUsers = [];
36
+ try { const ids = JSON.parse(engagement.users || '[]'); const um = Object.fromEntries(db.prepare('SELECT id, name, email FROM users').all().map(u => [u.id, u.name || u.email])); assignedUsers = ids.map(id => ({ id, name: um[id] || null })).filter(u => u.name); } catch {}
37
+ const { renderEngagementDetail } = await lazyRenderer('engagement-detail-renderer.js');
38
+ return renderEngagementDetail(user, { ...engagement, team_name: team?.name || engagement.team_name, client_name: client?.name || engagement.client_name, assigned_users_resolved: assignedUsers }, client, rfis, sections);
39
+ }
40
+ async function handleEngagementList(user, req) {
41
+ if (!canList(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'list');
42
+ let engagements = list('engagement', {});
43
+ const clientMap = Object.fromEntries(list('client', {}).map(c => [c.id, c.name]));
44
+ engagements = engagements.map(e => ({ ...e, client_name: clientMap[e.client_id] || e.client_name || '-' }));
45
+ const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
46
+ let teams = []; try { teams = list('team', {}); } catch {}
47
+ const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name]));
48
+ engagements = engagements.map(e => ({ ...e, team_name: teamMap[e.team_id] || e.team_name || '-' }));
49
+ const years = [...new Set(engagements.map(e => { if (!e.year) return null; const m = String(e.year).match(/\b(20\d{2}|19\d{2})\b/); return m ? m[1] : null; }).filter(Boolean))].sort().reverse();
50
+ const filter = reqUrl(req).searchParams.get('filter') || 'all';
51
+ return renderEngagementGrid(user, engagements, { filter, teams, years });
52
+ }
53
+ async function handleSearch(user, req) {
54
+ const url = reqUrl(req);
55
+ const q = url.searchParams.get('q') || '', entityFilter = url.searchParams.get('entity') || '', statusFilter = url.searchParams.get('status') || '';
56
+ let teams = []; try { teams = list('team', {}); } catch {}
57
+ const results = {};
58
+ for (const eName of (entityFilter ? [entityFilter] : ['engagement', 'client', 'rfi', 'review'])) {
59
+ try { let items = list(eName, {}); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
60
+ }
61
+ return renderAdvancedSearch(user, results, { teams, stages: ['info_gathering', 'commencement', 'team_execution', 'partner_review', 'finalization', 'closeout'] });
62
+ }
63
+ async function handleGenericEntityView(user, entityName, id) {
64
+ const spec = getSpec(entityName); if (!spec) return null;
65
+ if (isClientUser(user) && !canClientAccessEntity(user, entityName)) return renderAccessDenied(user, entityName, 'view');
66
+ if (id === 'new') {
67
+ if (!canCreate(user, entityName)) return renderAccessDenied(user, entityName, 'create');
68
+ const resolvedSpec = resolveEnumOptions(spec);
69
+ const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
70
+ return lazyEntityForm(entityName, null, resolvedSpec, user, true, getRefOptions(resolvedSpec));
71
+ }
72
+ if (!canView(user, entityName)) return renderAccessDenied(user, entityName, 'view');
73
+ const item = get(entityName, id); if (!item) return null;
74
+ if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
75
+ if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
76
+ const [resolvedItem] = resolveRefFields([item], spec);
77
+ const { renderEntityDetail: lazyEntityDetail } = await lazyRenderer('entity-renderer.js');
78
+ return lazyEntityDetail(entityName, resolvedItem, spec, user);
79
+ }
80
+ async function handleClientSubRoute(user, clientId, subRoute) {
81
+ if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
82
+ const client = get('client', clientId); if (!client) return null;
83
+ if (isClientUser(user) && user.client_id && user.client_id !== clientId) return renderAccessDenied(user, 'client', 'view');
84
+ if (subRoute === 'dashboard' || subRoute === 'users') return renderClientDashboard(user, client, getClientDashboardStats(clientId));
85
+ if (subRoute === 'progress') {
86
+ let engagements = []; try { engagements = list('engagement', {}).filter(e => e.client_id === clientId); } catch {}
87
+ const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
88
+ let rfiStats = { total: 0, responded: 0, overdue: 0 };
89
+ try { const allRfis = list('rfi', {}).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
90
+ return renderClientProgress(user, client, engagements, rfiStats);
91
+ }
92
+ return null;
93
+ }
94
+ async function handleGenericEntityEdit(user, entityName, id) {
95
+ const spec = getSpec(entityName); if (!spec) return null;
96
+ if (!canEdit(user, entityName)) return renderAccessDenied(user, entityName, 'edit');
97
+ const item = get(entityName, id); if (!item) return null;
98
+ if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'edit');
99
+ const resolvedSpec = resolveEnumOptions(spec);
100
+ const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
101
+ return lazyEntityForm(entityName, item, resolvedSpec, user, false, getRefOptions(resolvedSpec));
102
+ }
103
+ export async function handlePage(pathname, req, res) {
104
+ setCurrentRequest(req);
105
+ const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
106
+ const segments = normalized.split('/').filter(Boolean);
107
+
108
+ if (normalized === '/login') {
109
+ const user = await getUser();
110
+ if (user) { res.writeHead(302, { Location: '/' }); res.end(); return REDIRECT; }
111
+ return renderLogin(null, hasGoogleAuth());
112
+ }
113
+ if (normalized === '/password-reset') return renderPasswordReset();
114
+ if (normalized === '/password-reset/confirm') {
115
+ const token = reqUrl(req).searchParams.get('token') || '';
116
+ return renderPasswordResetConfirm(token);
117
+ }
118
+
119
+ const user = await getUser();
120
+ if (!user) { res.writeHead(302, { Location: '/login' }); res.end(); return REDIRECT; }
121
+ if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
122
+ if (normalized === '/notifications') { let notifs=[]; try{notifs=getDatabase().prepare('SELECT * FROM notification WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(user.id)}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
123
+ if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
124
+ if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user);
125
+ if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
126
+ if (isClerk(user) && segments.length >= 1 && ['user', 'team'].includes(segments[0])) return renderAccessDenied(user, segments[0], 'list');
127
+ if (normalized === '/mwr' || normalized === '/mwr/home') {
128
+ if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
129
+ const db = getDatabase();
130
+ let myReviews = [], sharedReviews = [], recentActivity = [];
131
+ try { myReviews = db.prepare('SELECT * FROM review WHERE created_by=? OR assigned_to=? ORDER BY updated_at DESC LIMIT 100').all(user.id, user.id); } catch { try { myReviews = list('review', {}).filter(r => r.created_by === user.id || r.assigned_to === user.id); } catch {} }
132
+ try { sharedReviews = db.prepare('SELECT r.* FROM review r JOIN collaborator c ON c.review_id=r.id WHERE c.user_id=? ORDER BY r.updated_at DESC LIMIT 100').all(user.id); } catch {}
133
+ try { recentActivity = db.prepare("SELECT * FROM audit_logs WHERE entity_type='review' ORDER BY timestamp DESC LIMIT 50").all(); } catch {}
134
+ const all = [...myReviews, ...sharedReviews];
135
+ const stats = { myReviews, sharedReviews, recentActivity, totalReviews: all.length, activeReviews: all.filter(r => (r.status||'open') !== 'archived' && (r.status||'open') !== 'completed' && (r.status||'open') !== 'closed').length, flaggedReviews: all.filter(r => r.flagged).length, overdueReviews: 0 };
136
+ const { renderMwrHome } = await lazyRenderer('review-mwr-renderer.js');
137
+ return renderMwrHome(user, stats);
138
+ }
139
+ const reviewResult = await handleReviewRoutes(normalized, segments, user, req);
140
+ if (reviewResult !== null) return reviewResult;
141
+
142
+ if (normalized === '/engagements') return handleEngagementList(user, req);
143
+ if (normalized === '/search') return handleSearch(user, req);
144
+ if (normalized === '/ml-console') {
145
+ const db = getDatabase();
146
+ let candidates = []; let reviewMap = {};
147
+ try {
148
+ candidates = db.prepare(`SELECT * FROM highlight
149
+ WHERE (comment IS NOT NULL AND comment != '')
150
+ AND (comment LIKE '%?' OR length(comment) > 40)
151
+ ORDER BY created_at DESC LIMIT 50`).all();
152
+ const revIds = [...new Set(candidates.map((c) => c.review_id))];
153
+ if (revIds.length) {
154
+ const ph = revIds.map(() => '?').join(',');
155
+ const rows = db.prepare(`SELECT id, name FROM review WHERE id IN (${ph})`).all(...revIds);
156
+ reviewMap = Object.fromEntries(rows.map((r) => [r.id, r.name || '-']));
157
+ }
158
+ } catch {}
159
+ const { renderMlConsole } = await lazyRenderer('ml-console-renderer.js');
160
+ return renderMlConsole(user, candidates, reviewMap);
161
+ }
162
+ if (segments.length === 2 && (segments[0] === 'engagements' || segments[0] === 'engagement') && segments[1] !== 'new') return handleEngagementDetail(user, segments[1]);
163
+ if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'letter') {
164
+ if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
165
+ const engagement = get('engagement', segments[1]); if (!engagement) return null;
166
+ return renderLetterWorkflow(user, engagement);
167
+ }
168
+ if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'report') {
169
+ if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
170
+ const engId = segments[1]; const engagement = get('engagement', engId); if (!engagement) return null;
171
+ const db = getDatabase(); let client=null,team=null,rfis=[],reviews=[],highlights=[],activity=[];
172
+ try{client=engagement.client_id?get('client',engagement.client_id):null}catch{}
173
+ try{team=engagement.team_id?get('team',engagement.team_id):null}catch{}
174
+ try{rfis=db.prepare('SELECT * FROM rfi WHERE engagement_id=? ORDER BY created_at DESC').all(engId)}catch{}
175
+ try{reviews=db.prepare('SELECT * FROM review WHERE engagement_id=? ORDER BY created_at DESC').all(engId)}catch{}
176
+ try{const rids=reviews.map(r=>r.id);if(rids.length){const ph=rids.map(()=>'?').join(',');highlights=db.prepare(`SELECT * FROM highlight WHERE review_id IN (${ph})`).all(...rids)}}catch{}
177
+ try{activity=db.prepare("SELECT * FROM audit_logs WHERE entity_type='engagement' AND entity_id=? ORDER BY timestamp DESC LIMIT 20").all(engId)}catch{}
178
+ const{renderFlexupReport}=await lazyRenderer('flexup-report-renderer.js');
179
+ return renderFlexupReport(user,engagement,client,rfis,reviews,highlights,activity,team);
180
+ }
181
+ if (segments.length === 3 && segments[0] === 'rfi' && segments[2] === 'report') return handleRfiReport(user, segments[1], res);
182
+ if (segments.length === 2 && segments[0] === 'rfi' && segments[1] !== 'new') return handleRfiDetail(user, segments[1]);
183
+ if (segments.length === 1 && segments[0] === 'rfi') return handleRfiList(user);
184
+ if (segments.length === 1 && segments[0] === 'client') {
185
+ if (!canList(user, 'client')) return renderAccessDenied(user, 'client', 'list');
186
+ let clients = list('client', {});
187
+ if (isClientUser(user) && user.client_id) clients = clients.filter(c => c.id === user.client_id);
188
+ return renderClientList(user, clients);
189
+ }
190
+ if (segments.length === 1) {
191
+ const entityName = segments[0];
192
+ const spec = getSpec(entityName); if (!spec) return null;
193
+ if (isClientUser(user) && !canClientAccessEntity(user, entityName)) return renderAccessDenied(user, entityName, 'list');
194
+ if (!canList(user, entityName)) return renderAccessDenied(user, entityName, 'list');
195
+ let items = list(entityName, {});
196
+ if (isClientUser(user) && user.client_id) items = items.filter(item => { if (item.client_id) return item.client_id === user.client_id; if (item.assigned_to) return item.assigned_to === user.id; return true; });
197
+ const { renderEntityList: lazyEntityList } = await lazyRenderer('entity-renderer.js');
198
+ return lazyEntityList(entityName, resolveRefFields(items, spec), spec, user);
199
+ }
200
+ if (segments.length === 2 && segments[0] === 'client' && segments[1] !== 'new') {
201
+ if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
202
+ if (isClientUser(user) && user.client_id && user.client_id !== segments[1]) return renderAccessDenied(user, 'client', 'view');
203
+ const client = get('client', segments[1]); if (!client) return null;
204
+ return renderClientDashboard(user, client, getClientDashboardStats(segments[1]));
205
+ }
206
+ if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1]);
207
+ if (segments.length === 3 && segments[2] === 'edit') return handleGenericEntityEdit(user, segments[0], segments[1]);
208
+
209
+ return null;
210
+ }
@@ -0,0 +1,146 @@
1
+ export function renderPasswordResetPage(params = {}) {
2
+ const token = params.token || '';
3
+ const isConfirm = !!token;
4
+ const title = isConfirm ? 'Set New Password' : 'Forgot Password';
5
+ const subtitle = isConfirm ? 'Enter your new password below' : 'Enter your email to receive a reset link';
6
+ const btnLabel = isConfirm ? 'Update Password' : 'Send Reset Link';
7
+
8
+ return `<!DOCTYPE html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="UTF-8">
12
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
13
+ <title>${title} | Moonlanding</title>
14
+ <style>
15
+ *{margin:0;padding:0;box-sizing:border-box}
16
+ :root{--bg:#f1f5f9;--surface:#fff;--border:#e2e8f0;--text:#0f172a;--text-muted:#64748b;--primary:#04141f;--accent:#2563eb;--danger:#b91c1c;--danger-bg:#fee2e2;--danger-border:#fca5a5;--success-bg:#dcfce7;--success:#15803d;--success-border:#86efac;--info-bg:#dbeafe;--info:#1d4ed8;--info-border:#93c5fd}
17
+ @media(prefers-color-scheme:dark){:root{--bg:#0f172a;--surface:#1a1a1a;--border:#334155;--text:#f1f5f9;--text-muted:#94a3b8;--primary:#1e3a5f}}
18
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;color:var(--text)}
19
+ .wrap{width:100%;max-width:400px}
20
+ .logo{text-align:center;margin-bottom:32px}
21
+ .logo-mark{display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;background:var(--primary);border-radius:14px;margin-bottom:14px;box-shadow:0 4px 12px rgba(4,20,31,0.25)}
22
+ .logo-name{font-size:1.35rem;font-weight:800;color:var(--text);letter-spacing:0.5px;display:block;margin-bottom:4px}
23
+ .logo-sub{color:var(--text-muted);font-size:0.875rem}
24
+ .card{background:var(--surface);border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,0.09);padding:32px;border:1px solid var(--border)}
25
+ .field{margin-bottom:18px}
26
+ label{display:block;font-size:0.8125rem;font-weight:600;color:var(--text);margin-bottom:6px}
27
+ input{width:100%;padding:10px 13px;border:1.5px solid var(--border);border-radius:8px;font-size:0.9rem;outline:none;transition:border-color 0.15s,box-shadow 0.15s;background:var(--surface);color:var(--text);min-height:44px}
28
+ input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,0.12)}
29
+ input::placeholder{color:var(--text-muted)}
30
+ .submit-btn{width:100%;padding:12px;background:var(--primary);color:#fff;border:none;border-radius:8px;font-size:0.9375rem;font-weight:600;cursor:pointer;transition:all 0.15s;min-height:46px;margin-top:4px}
31
+ .submit-btn:hover:not(:disabled){background:#0a2535}
32
+ .submit-btn:disabled{opacity:0.65;cursor:not-allowed}
33
+ .submit-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
34
+ .alert{padding:11px 14px;border-radius:8px;margin-bottom:18px;font-size:0.875rem;border:1px solid;line-height:1.4}
35
+ .alert-error{background:var(--danger-bg);color:var(--danger);border-color:var(--danger-border)}
36
+ .alert-success{background:var(--success-bg);color:var(--success);border-color:var(--success-border)}
37
+ .alert-info{background:var(--info-bg);color:var(--info);border-color:var(--info-border)}
38
+ .back{text-align:center;margin-top:18px;font-size:0.875rem;color:var(--text-muted)}
39
+ .back a{color:var(--accent);text-decoration:none;font-weight:500}
40
+ .back a:hover{text-decoration:underline}
41
+ @media(prefers-color-scheme:dark){
42
+ .card{background:#1a1a1a;border-color:#334155;box-shadow:0 4px 24px rgba(0,0,0,0.4)}
43
+ input{background:#1a1a1a;border-color:#334155;color:#f1f5f9}
44
+ .submit-btn{background:#1e3a5f}
45
+ .submit-btn:hover:not(:disabled){background:#1e293b}
46
+ }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div class="wrap">
51
+ <div class="logo">
52
+ <div class="logo-mark">
53
+ <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6M4.22 4.22l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M4.22 19.78l4.24-4.24m4.24-4.24l4.24-4.24"/></svg>
54
+ </div>
55
+ <span class="logo-name">MOONLANDING</span>
56
+ <div class="logo-sub">${subtitle}</div>
57
+ </div>
58
+ <div class="card">
59
+ <div id="alertContainer" role="alert" aria-live="assertive"></div>
60
+ <form id="resetForm" aria-label="${title}" novalidate>
61
+ ${!isConfirm ? `
62
+ <div class="field">
63
+ <label for="email">Email address</label>
64
+ <input type="email" name="email" id="email" placeholder="you@example.com" required autocomplete="email" autofocus>
65
+ </div>
66
+ <button type="submit" id="submitBtn" class="submit-btn">${btnLabel}</button>
67
+ ` : `
68
+ <input type="hidden" name="token" value="${token}">
69
+ <div class="field">
70
+ <label for="password">New password</label>
71
+ <input type="password" name="password" id="password" placeholder="At least 8 characters" required minlength="8" autocomplete="new-password" autofocus>
72
+ </div>
73
+ <div class="field">
74
+ <label for="confirm">Confirm new password</label>
75
+ <input type="password" name="confirm" id="confirm" placeholder="Same password again" required minlength="8" autocomplete="new-password">
76
+ </div>
77
+ <button type="submit" id="submitBtn" class="submit-btn">${btnLabel}</button>
78
+ `}
79
+ </form>
80
+ <div class="back"><a href="/login">Back to sign in</a></div>
81
+ </div>
82
+ </div>
83
+ <script src="/ui/event-delegation.js"></script>
84
+ <script>
85
+ var form = document.getElementById('resetForm');
86
+ var submitBtn = document.getElementById('submitBtn');
87
+ var alertContainer = document.getElementById('alertContainer');
88
+ var isConfirm = ${isConfirm};
89
+
90
+ function showAlert(message, type) {
91
+ alertContainer.innerHTML = '<div class="alert alert-' + (type || 'error') + '">' + message + '</div>';
92
+ }
93
+
94
+ if (isConfirm && !form.token.value) {
95
+ showAlert('Invalid or expired reset link. Please request a new one.', 'error');
96
+ submitBtn.disabled = true;
97
+ }
98
+
99
+ form.addEventListener('submit', async function(e) {
100
+ e.preventDefault();
101
+ alertContainer.innerHTML = '';
102
+ var formData = new FormData(form);
103
+ var data = Object.fromEntries(formData);
104
+
105
+ if (isConfirm && data.password !== data.confirm) {
106
+ showAlert('Passwords do not match.', 'error');
107
+ return;
108
+ }
109
+
110
+ var origText = submitBtn.textContent;
111
+ submitBtn.textContent = isConfirm ? 'Updating\u2026' : 'Sending\u2026';
112
+ submitBtn.disabled = true;
113
+
114
+ try {
115
+ var body = isConfirm ? { token: data.token, password: data.password } : { email: data.email };
116
+ var res = await fetch('/api/auth/password-reset', {
117
+ method: isConfirm ? 'PUT' : 'POST',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify(body)
120
+ });
121
+ var result = await res.json().catch(function() { return {}; });
122
+ if (res.ok) {
123
+ if (isConfirm) {
124
+ showAlert('Password updated. <a href="/login" style="color:var(--success);font-weight:600;text-decoration:underline">Sign in now</a>', 'success');
125
+ form.style.display = 'none';
126
+ } else {
127
+ form.reset();
128
+ showAlert('If an account exists with that email, a reset link has been sent.', 'info');
129
+ submitBtn.textContent = origText;
130
+ submitBtn.disabled = false;
131
+ }
132
+ } else {
133
+ showAlert(result.error || 'An error occurred. Please try again.', 'error');
134
+ submitBtn.textContent = origText;
135
+ submitBtn.disabled = false;
136
+ }
137
+ } catch(err) {
138
+ showAlert('Network error. Please try again.', 'error');
139
+ submitBtn.textContent = origText;
140
+ submitBtn.disabled = false;
141
+ }
142
+ });
143
+ </script>
144
+ </body>
145
+ </html>`;
146
+ }