@striae-org/striae 4.0.3 → 4.2.0

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 (118) hide show
  1. package/.env.example +8 -0
  2. package/app/components/actions/case-export/core-export.ts +14 -8
  3. package/app/components/actions/case-export/data-processing.ts +1 -0
  4. package/app/components/actions/case-export/download-handlers.ts +7 -0
  5. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  6. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  7. package/app/components/actions/case-import/orchestrator.ts +78 -32
  8. package/app/components/actions/case-import/storage-operations.ts +97 -8
  9. package/app/components/actions/case-import/zip-processing.ts +159 -86
  10. package/app/components/actions/case-manage.ts +430 -8
  11. package/app/components/actions/confirm-export.ts +13 -4
  12. package/app/components/actions/generate-pdf.ts +10 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +137 -945
  15. package/app/components/audit/user-audit.module.css +41 -0
  16. package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
  17. package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
  18. package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
  19. package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
  20. package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
  21. package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
  22. package/app/components/audit/viewer/types.ts +1 -0
  23. package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
  24. package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
  25. package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
  26. package/app/components/auth/mfa-enrollment.module.css +13 -5
  27. package/app/components/auth/mfa-verification.module.css +13 -5
  28. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  29. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  30. package/app/components/canvas/canvas.module.css +64 -54
  31. package/app/components/canvas/canvas.tsx +17 -16
  32. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  33. package/app/components/canvas/confirmation/confirmation.tsx +17 -47
  34. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  35. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  36. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  37. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  38. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  39. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  40. package/app/components/navbar/navbar.module.css +447 -0
  41. package/app/components/navbar/navbar.tsx +377 -0
  42. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
  43. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
  44. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  45. package/app/components/sidebar/case-export/case-export.tsx +14 -77
  46. package/app/components/sidebar/case-import/case-import.module.css +25 -0
  47. package/app/components/sidebar/case-import/case-import.tsx +64 -40
  48. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  49. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  50. package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
  51. package/app/components/sidebar/cases/cases-modal.module.css +45 -9
  52. package/app/components/sidebar/cases/cases-modal.tsx +16 -16
  53. package/app/components/sidebar/cases/cases.module.css +62 -21
  54. package/app/components/sidebar/files/files-modal.module.css +46 -10
  55. package/app/components/sidebar/files/files-modal.tsx +22 -23
  56. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  57. package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
  58. package/app/components/sidebar/notes/notes-modal.tsx +18 -17
  59. package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
  60. package/app/components/sidebar/notes/notes.module.css +155 -0
  61. package/app/components/sidebar/sidebar-container.tsx +15 -28
  62. package/app/components/sidebar/sidebar.module.css +7 -71
  63. package/app/components/sidebar/sidebar.tsx +24 -125
  64. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  65. package/app/components/toast/toast.module.css +2 -1
  66. package/app/components/toast/toast.tsx +16 -11
  67. package/app/components/user/delete-account.tsx +10 -31
  68. package/app/components/user/inactivity-warning.module.css +9 -6
  69. package/app/components/user/inactivity-warning.tsx +15 -2
  70. package/app/components/user/manage-profile.module.css +2 -0
  71. package/app/components/user/manage-profile.tsx +108 -40
  72. package/app/hooks/useOverlayDismiss.ts +116 -0
  73. package/app/routes/auth/login.example.tsx +19 -8
  74. package/app/routes/auth/login.tsx +785 -774
  75. package/app/routes/auth/passwordReset.module.css +23 -13
  76. package/app/routes/striae/striae.module.css +10 -3
  77. package/app/routes/striae/striae.tsx +477 -31
  78. package/app/routes.ts +7 -0
  79. package/app/services/audit/audit-export-csv.ts +2 -0
  80. package/app/services/audit/audit.service.ts +202 -32
  81. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  82. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  83. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  84. package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
  85. package/app/services/audit/builders/index.ts +1 -0
  86. package/app/types/audit.ts +5 -2
  87. package/app/types/case.ts +29 -0
  88. package/app/types/import.ts +3 -0
  89. package/app/types/user.ts +1 -0
  90. package/app/utils/data/permissions.ts +17 -1
  91. package/app/utils/forensics/audit-export-signature.ts +5 -1
  92. package/app/utils/forensics/confirmation-signature.ts +3 -0
  93. package/app/utils/forensics/export-verification.ts +497 -22
  94. package/functions/api/pdf/[[path]].ts +32 -1
  95. package/load-context.ts +9 -0
  96. package/package.json +6 -2
  97. package/primershear.emails.example +6 -0
  98. package/scripts/deploy-pages-secrets.sh +6 -0
  99. package/scripts/deploy-primershear-emails.sh +167 -0
  100. package/worker-configuration.d.ts +7493 -7491
  101. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  102. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  103. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  104. package/workers/data-worker/wrangler.jsonc.example +1 -1
  105. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  106. package/workers/image-worker/wrangler.jsonc.example +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
  110. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  111. package/workers/pdf-worker/src/report-types.ts +3 -0
  112. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  113. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  114. package/workers/user-worker/src/user-worker.example.ts +6 -1
  115. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  116. package/workers/user-worker/wrangler.jsonc.example +1 -1
  117. package/wrangler.toml.example +1 -1
  118. package/public/.well-known/keybase.txt +0 -56
@@ -1,14 +1,63 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { type CaseExportData, type CaseImportPreview } from '~/types';
3
+ import { getCaseData } from '~/utils/data';
3
4
  import { validateCaseNumber } from '../case-manage';
4
5
  import {
5
- extractForensicManifestData,
6
6
  type SignedForensicManifest,
7
- validateCaseIntegritySecure as validateForensicIntegrity,
8
- verifyForensicManifestSignature
7
+ verifyCasePackageIntegrity
9
8
  } from '~/utils/forensics';
10
9
  import { validateExporterUid, removeForensicWarning } from './validation';
11
10
 
11
+ function isArchivedExportData(parsedData: unknown): boolean {
12
+ if (!parsedData || typeof parsedData !== 'object') {
13
+ return false;
14
+ }
15
+
16
+ const root = parsedData as Record<string, unknown>;
17
+
18
+ if (root.archived === true) {
19
+ return true;
20
+ }
21
+
22
+ if (typeof root.archivedAt === 'string' && root.archivedAt.trim().length > 0) {
23
+ return true;
24
+ }
25
+
26
+ const metadata = root.metadata;
27
+ if (!metadata || typeof metadata !== 'object') {
28
+ return false;
29
+ }
30
+
31
+ const metadataRecord = metadata as Record<string, unknown>;
32
+
33
+ if (metadataRecord.archived === true) {
34
+ return true;
35
+ }
36
+
37
+ if (typeof metadataRecord.archivedAt === 'string' && metadataRecord.archivedAt.trim().length > 0) {
38
+ return true;
39
+ }
40
+
41
+ return false;
42
+ }
43
+
44
+ async function allowSelfImportForArchivedCase(
45
+ currentUser: User,
46
+ caseNumber: string,
47
+ parsedData: unknown
48
+ ): Promise<boolean> {
49
+ if (isArchivedExportData(parsedData)) {
50
+ return true;
51
+ }
52
+
53
+ try {
54
+ const existingCase = await getCaseData(currentUser, caseNumber);
55
+ return existingCase?.archived === true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
12
61
  function getLeafFileName(path: string): string {
13
62
  const segments = path.split('/').filter(Boolean);
14
63
  return segments.length > 0 ? segments[segments.length - 1] : path;
@@ -54,9 +103,11 @@ async function extractVerificationPublicKeyFromZip(
54
103
  * a reasonable portion from the end.
55
104
  */
56
105
  function extractImageIdFromFilename(exportFilename: string): string | null {
106
+ const leafFilename = getLeafFileName(exportFilename);
107
+
57
108
  // Remove extension first
58
- const lastDotIndex = exportFilename.lastIndexOf('.');
59
- const filenameWithoutExt = lastDotIndex === -1 ? exportFilename : exportFilename.substring(0, lastDotIndex);
109
+ const lastDotIndex = leafFilename.lastIndexOf('.');
110
+ const filenameWithoutExt = lastDotIndex === -1 ? leafFilename : leafFilename.substring(0, lastDotIndex);
60
111
 
61
112
  // UUID pattern: 8-4-4-4-12 (36 chars including hyphens)
62
113
  // Look for a pattern that matches this at the end
@@ -134,79 +185,72 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
134
185
  forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
135
186
 
136
187
  if (forensicManifest) {
137
- const manifestForValidation = extractForensicManifestData(forensicManifest);
138
- if (!manifestForValidation) {
139
- hashValid = false;
140
- hashError = 'Validation failed.';
141
-
142
- validationDetails = {
143
- hasForensicManifest: true,
144
- dataValid: false,
145
- manifestValid: false,
146
- signatureValid: false,
147
- validationSummary: 'Manifest schema validation failed',
148
- integrityErrors: [hashError]
149
- };
150
- } else {
151
- // Extract image files for comprehensive validation
152
- const imageFiles: { [filename: string]: Blob } = {};
153
- const imagesFolder = zip.folder('images');
154
- if (imagesFolder) {
155
- await Promise.all(Object.keys(imagesFolder.files).map(async (path) => {
156
- if (path.startsWith('images/') && !path.endsWith('/')) {
157
- const filename = path.replace('images/', '');
158
- const file = zip.file(path);
159
- if (file) {
160
- const blob = await file.async('blob');
161
- imageFiles[filename] = blob;
162
- }
188
+ // Extract image files for comprehensive validation
189
+ const imageFiles: { [filename: string]: Blob } = {};
190
+ const imagesFolder = zip.folder('images');
191
+ if (imagesFolder) {
192
+ await Promise.all(Object.keys(imagesFolder.files).map(async (path) => {
193
+ if (path.startsWith('images/') && !path.endsWith('/')) {
194
+ const filename = path.replace('images/', '');
195
+ const file = zip.file(path);
196
+ if (file) {
197
+ const blob = await file.async('blob');
198
+ imageFiles[filename] = blob;
163
199
  }
164
- }));
200
+ }
201
+ }));
202
+ }
203
+
204
+ const casePackageResult = await verifyCasePackageIntegrity({
205
+ cleanedContent,
206
+ imageFiles,
207
+ forensicManifest,
208
+ verificationPublicKeyPem,
209
+ bundledAuditFiles: {
210
+ auditTrailContent: await zip.file('audit/case-audit-trail.json')?.async('text'),
211
+ auditSignatureContent: await zip.file('audit/case-audit-signature.json')?.async('text')
165
212
  }
213
+ });
166
214
 
167
- const signatureResult = await verifyForensicManifestSignature(
168
- forensicManifest,
169
- verificationPublicKeyPem
170
- );
171
-
172
- // Perform comprehensive validation
173
- const validation = await validateForensicIntegrity(
174
- cleanedContent,
175
- imageFiles,
176
- manifestForValidation
177
- );
178
-
179
- hashValid = validation.isValid && signatureResult.isValid;
215
+ const signatureResult = casePackageResult.signatureResult;
216
+ const validation = casePackageResult.integrityResult;
217
+ const bundledAuditVerification = casePackageResult.bundledAuditVerification;
180
218
 
181
- if (!hashValid) {
182
- const errorParts: string[] = [];
183
- if (!signatureResult.isValid) {
184
- errorParts.push('Signature validation failed.');
185
- }
186
- if (!validation.isValid) {
187
- errorParts.push('Integrity validation failed.');
188
- }
189
- hashError = errorParts.length > 0 ? errorParts.join(' ') : 'Validation failed.';
190
- }
219
+ hashValid = casePackageResult.isValid;
191
220
 
192
- // Capture detailed validation information
193
- const integrityErrors = [...validation.errors];
221
+ if (!hashValid) {
222
+ const errorParts: string[] = [];
194
223
  if (!signatureResult.isValid) {
195
- integrityErrors.push(`Signature validation failed: ${signatureResult.error || 'Unknown signature error'}`);
224
+ errorParts.push('Signature validation failed.');
196
225
  }
226
+ if (!validation.isValid) {
227
+ errorParts.push('Integrity validation failed.');
228
+ }
229
+ if (bundledAuditVerification) {
230
+ errorParts.push(bundledAuditVerification.message);
231
+ }
232
+ hashError = errorParts.length > 0 ? errorParts.join(' ') : 'Validation failed.';
233
+ }
197
234
 
198
- validationDetails = {
199
- hasForensicManifest: true,
200
- dataValid: validation.dataValid,
201
- imageValidation: validation.imageValidation,
202
- manifestValid: validation.manifestValid,
203
- signatureValid: signatureResult.isValid,
204
- signatureKeyId: signatureResult.keyId,
205
- signatureError: signatureResult.error,
206
- validationSummary: validation.summary,
207
- integrityErrors
208
- };
235
+ const integrityErrors = [...validation.errors];
236
+ if (!signatureResult.isValid) {
237
+ integrityErrors.push(`Signature validation failed: ${signatureResult.error || 'Unknown signature error'}`);
209
238
  }
239
+ if (bundledAuditVerification) {
240
+ integrityErrors.push(bundledAuditVerification.message);
241
+ }
242
+
243
+ validationDetails = {
244
+ hasForensicManifest: true,
245
+ dataValid: validation.dataValid,
246
+ imageValidation: validation.imageValidation,
247
+ manifestValid: validation.manifestValid,
248
+ signatureValid: signatureResult.isValid,
249
+ signatureKeyId: signatureResult.keyId,
250
+ signatureError: signatureResult.error,
251
+ validationSummary: validation.summary,
252
+ integrityErrors
253
+ };
210
254
 
211
255
  } else {
212
256
  // No forensic manifest found - cannot validate
@@ -239,7 +283,8 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
239
283
  };
240
284
  }
241
285
 
242
- const caseData: CaseExportData = JSON.parse(cleanedContent);
286
+ const parsedCaseData = JSON.parse(cleanedContent) as unknown;
287
+ const caseData: CaseExportData = parsedCaseData as CaseExportData;
243
288
 
244
289
  // Validate case data structure
245
290
  if (!caseData.metadata?.caseNumber) {
@@ -250,6 +295,12 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
250
295
  throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
251
296
  }
252
297
 
298
+ const isArchivedExport = await allowSelfImportForArchivedCase(
299
+ currentUser,
300
+ caseData.metadata.caseNumber,
301
+ parsedCaseData
302
+ );
303
+
253
304
  // Validate exporter UID exists in user database and is not current user
254
305
  if (caseData.metadata.exportedByUid) {
255
306
  const validation = await validateExporterUid(caseData.metadata.exportedByUid, currentUser);
@@ -258,7 +309,7 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
258
309
  throw new Error(`The original exporter is not a valid Striae user. This case cannot be imported.`);
259
310
  }
260
311
 
261
- if (validation.isSelf) {
312
+ if (validation.isSelf && !isArchivedExport) {
262
313
  throw new Error(`You cannot import a case that you originally exported. Original analysts cannot review their own cases.`);
263
314
  }
264
315
  } else {
@@ -278,9 +329,11 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
278
329
 
279
330
  return {
280
331
  caseNumber: caseData.metadata.caseNumber,
332
+ archived: isArchivedExport,
281
333
  exportedBy: caseData.metadata.exportedBy || null,
282
334
  exportedByName: caseData.metadata.exportedByName || null,
283
335
  exportedByCompany: caseData.metadata.exportedByCompany || null,
336
+ exportedByBadgeId: caseData.metadata.exportedByBadgeId || null,
284
337
  exportDate: caseData.metadata.exportDate,
285
338
  totalFiles,
286
339
  caseCreatedDate: caseData.metadata.caseCreatedDate,
@@ -304,6 +357,11 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
304
357
  caseData: CaseExportData;
305
358
  imageFiles: { [filename: string]: Blob };
306
359
  imageIdMapping: { [exportFilename: string]: string }; // exportFilename -> originalImageId
360
+ isArchivedExport: boolean;
361
+ bundledAuditFiles?: {
362
+ auditTrailContent?: string;
363
+ auditSignatureContent?: string;
364
+ };
307
365
  metadata?: Record<string, unknown>;
308
366
  cleanedContent?: string; // Add cleaned content for hash validation
309
367
  verificationPublicKeyPem?: string;
@@ -333,6 +391,7 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
333
391
 
334
392
  // Extract and parse case data
335
393
  let caseData: CaseExportData;
394
+ let parsedCaseData: unknown;
336
395
  let cleanedContent: string = '';
337
396
  if (isJsonFormat) {
338
397
  const dataContent = await zip.file(dataFileName)?.async('text');
@@ -342,7 +401,8 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
342
401
 
343
402
  // Handle forensic protection warnings in JSON
344
403
  cleanedContent = removeForensicWarning(dataContent);
345
- caseData = JSON.parse(cleanedContent);
404
+ parsedCaseData = JSON.parse(cleanedContent) as unknown;
405
+ caseData = parsedCaseData as CaseExportData;
346
406
  } else {
347
407
  throw new Error('CSV import not yet supported. Please use JSON format.');
348
408
  }
@@ -356,6 +416,12 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
356
416
  throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
357
417
  }
358
418
 
419
+ const isArchivedExport = await allowSelfImportForArchivedCase(
420
+ currentUser,
421
+ caseData.metadata.caseNumber,
422
+ parsedCaseData
423
+ );
424
+
359
425
  // Validate exporter UID exists in user database and is not current user
360
426
  if (caseData.metadata.exportedByUid) {
361
427
  const validation = await validateExporterUid(caseData.metadata.exportedByUid, currentUser);
@@ -364,7 +430,7 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
364
430
  throw new Error(`The original exporter is not a valid Striae user. This case cannot be imported.`);
365
431
  }
366
432
 
367
- if (validation.isSelf) {
433
+ if (validation.isSelf && !isArchivedExport) {
368
434
  throw new Error(`You cannot import a case that you originally exported. Original analysts cannot review their own cases.`);
369
435
  }
370
436
  } else {
@@ -377,19 +443,19 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
377
443
  const imagesFolder = zip.folder('images');
378
444
 
379
445
  if (imagesFolder) {
380
- for (const [, file] of Object.entries(imagesFolder.files)) {
381
- if (!file.dir && file.name.includes('/')) {
382
- const exportFilename = file.name.split('/').pop();
383
- if (exportFilename) {
384
- const blob = await file.async('blob');
385
- imageFiles[exportFilename] = blob;
386
-
387
- // Extract original image ID from filename
388
- const originalImageId = extractImageIdFromFilename(exportFilename);
389
- if (originalImageId) {
390
- imageIdMapping[exportFilename] = originalImageId;
391
- }
392
- }
446
+ for (const [path, file] of Object.entries(imagesFolder.files)) {
447
+ if (!path.startsWith('images/') || path.endsWith('/') || file.dir) {
448
+ continue;
449
+ }
450
+
451
+ const exportFilename = path.replace('images/', '');
452
+ const blob = await file.async('blob');
453
+ imageFiles[exportFilename] = blob;
454
+
455
+ // Extract original image ID from filename
456
+ const originalImageId = extractImageIdFromFilename(exportFilename);
457
+ if (originalImageId) {
458
+ imageIdMapping[exportFilename] = originalImageId;
393
459
  }
394
460
  }
395
461
  }
@@ -397,6 +463,8 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
397
463
  // Extract forensic manifest if present
398
464
  let metadata: Record<string, unknown> | undefined;
399
465
  const manifestFile = zip.file('FORENSIC_MANIFEST.json');
466
+ const auditTrailContent = await zip.file('audit/case-audit-trail.json')?.async('text');
467
+ const auditSignatureContent = await zip.file('audit/case-audit-signature.json')?.async('text');
400
468
 
401
469
  if (manifestFile) {
402
470
  const manifestContent = await manifestFile.async('text');
@@ -407,6 +475,11 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
407
475
  caseData,
408
476
  imageFiles,
409
477
  imageIdMapping,
478
+ isArchivedExport,
479
+ bundledAuditFiles: {
480
+ auditTrailContent,
481
+ auditSignatureContent
482
+ },
410
483
  metadata,
411
484
  cleanedContent,
412
485
  verificationPublicKeyPem