@striae-org/striae 5.2.1 → 5.3.1

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 (117) hide show
  1. package/.env.example +2 -10
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +5 -174
  4. package/app/components/actions/case-export/download-handlers.ts +84 -751
  5. package/app/components/actions/case-export/index.ts +6 -30
  6. package/app/components/actions/case-export/metadata-helpers.ts +0 -78
  7. package/app/components/actions/case-export/types-constants.ts +0 -43
  8. package/app/components/actions/case-import/confirmation-import.ts +75 -36
  9. package/app/components/actions/case-import/confirmation-package.ts +68 -1
  10. package/app/components/actions/case-import/index.ts +1 -1
  11. package/app/components/actions/case-import/orchestrator.ts +78 -53
  12. package/app/components/actions/case-import/zip-processing.ts +160 -330
  13. package/app/components/actions/generate-pdf.ts +3 -2
  14. package/app/components/audit/user-audit-viewer.tsx +0 -19
  15. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  16. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  17. package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
  18. package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
  19. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
  20. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
  21. package/app/components/navbar/navbar.tsx +1 -1
  22. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  23. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +51 -3
  24. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  25. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
  26. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
  27. package/app/components/sidebar/case-import/index.ts +1 -4
  28. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  29. package/app/components/toast/toast.module.css +36 -0
  30. package/app/components/toast/toast.tsx +6 -2
  31. package/app/components/user/manage-profile.tsx +4 -3
  32. package/app/config-example/config.json +1 -2
  33. package/app/root.tsx +0 -7
  34. package/app/routes/_index.tsx +1 -1
  35. package/app/routes/auth/login.example.tsx +22 -103
  36. package/app/routes/auth/login.tsx +22 -103
  37. package/app/routes/auth/route.ts +1 -1
  38. package/app/routes/striae/striae.tsx +117 -59
  39. package/app/services/firebase/index.ts +0 -3
  40. package/app/types/case.ts +1 -0
  41. package/app/types/export.ts +2 -2
  42. package/app/types/import.ts +10 -0
  43. package/app/utils/auth/index.ts +0 -1
  44. package/app/utils/data/permissions.ts +3 -2
  45. package/package.json +9 -16
  46. package/public/_headers +0 -4
  47. package/public/_routes.json +0 -1
  48. package/worker-configuration.d.ts +20 -17
  49. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  50. package/workers/audit-worker/src/config.ts +7 -0
  51. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  52. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  53. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  54. package/workers/audit-worker/src/types.ts +56 -0
  55. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  56. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  57. package/workers/data-worker/src/config.ts +11 -0
  58. package/workers/data-worker/src/data-worker.example.ts +21 -942
  59. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  60. package/workers/data-worker/src/handlers/signing.ts +174 -0
  61. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  62. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  63. package/workers/data-worker/src/types.ts +46 -0
  64. package/workers/data-worker/worker-configuration.d.ts +1 -1
  65. package/workers/data-worker/wrangler.jsonc.example +1 -1
  66. package/workers/image-worker/worker-configuration.d.ts +1 -1
  67. package/workers/image-worker/wrangler.jsonc.example +1 -1
  68. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  69. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  70. package/workers/user-worker/src/auth.ts +30 -0
  71. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  72. package/workers/user-worker/src/config.ts +4 -0
  73. package/workers/user-worker/src/encryption-utils.ts +25 -0
  74. package/workers/user-worker/src/firebase/admin.ts +152 -0
  75. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  76. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  77. package/workers/user-worker/src/storage/user-records.ts +34 -0
  78. package/workers/user-worker/src/types.ts +106 -0
  79. package/workers/user-worker/src/user-worker.example.ts +18 -964
  80. package/workers/user-worker/worker-configuration.d.ts +4 -2
  81. package/workers/user-worker/wrangler.jsonc.example +12 -1
  82. package/wrangler.toml.example +1 -1
  83. package/app/components/actions/case-export/data-processing.ts +0 -223
  84. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  85. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  86. package/app/types/exceljs-bare.d.ts +0 -9
  87. package/app/utils/auth/auth.ts +0 -11
  88. package/public/.well-known/security.txt +0 -6
  89. package/public/favicon.ico +0 -0
  90. package/public/icon-256.png +0 -0
  91. package/public/icon-512.png +0 -0
  92. package/public/manifest.json +0 -39
  93. package/public/shortcut.png +0 -0
  94. package/public/social-image.png +0 -0
  95. package/public/vendor/exceljs.LICENSE +0 -22
  96. package/public/vendor/exceljs.bare.min.js +0 -45
  97. package/scripts/deploy-all.sh +0 -166
  98. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  99. package/scripts/deploy-config/modules/keys.sh +0 -404
  100. package/scripts/deploy-config/modules/prompt.sh +0 -372
  101. package/scripts/deploy-config/modules/scaffolding.sh +0 -344
  102. package/scripts/deploy-config/modules/validation.sh +0 -365
  103. package/scripts/deploy-config.sh +0 -236
  104. package/scripts/deploy-pages-secrets.sh +0 -231
  105. package/scripts/deploy-pages.sh +0 -34
  106. package/scripts/deploy-primershear-emails.sh +0 -167
  107. package/scripts/deploy-worker-secrets.sh +0 -374
  108. package/scripts/dev.cjs +0 -23
  109. package/scripts/install-workers.sh +0 -88
  110. package/scripts/run-eslint.cjs +0 -43
  111. package/scripts/update-compatibility-dates.cjs +0 -124
  112. package/scripts/update-markdown-versions.cjs +0 -43
  113. package/workers/keys-worker/package.json +0 -18
  114. package/workers/keys-worker/src/keys.example.ts +0 -67
  115. package/workers/keys-worker/src/keys.ts +0 -67
  116. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  117. package/workers/keys-worker/wrangler.jsonc.example +0 -15
@@ -1,15 +1,8 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { type CaseExportData, type CaseImportPreview } from '~/types';
3
- import { validateCaseNumber } from '../case-manage';
4
- import {
5
- type SignedForensicManifest,
6
- verifyCasePackageIntegrity
7
- } from '~/utils/forensics';
8
- import {
9
- isArchivedExportData,
10
- removeForensicWarning,
11
- validateCaseExporterUidForImport
12
- } from './validation';
3
+ import type { EncryptionManifest } from '~/utils/forensics/export-encryption';
4
+ import { decryptExportBatch } from '~/utils/data/operations/signing-operations';
5
+ import { isArchivedExportData } from './validation';
13
6
 
14
7
  function getLeafFileName(path: string): string {
15
8
  const segments = path.split('/').filter(Boolean);
@@ -103,6 +96,21 @@ function extractImageIdFromFilename(exportFilename: string): string | null {
103
96
  return filenameWithoutExt.substring(lastHyphenIndex + 1);
104
97
  }
105
98
 
99
+ function isEncryptionManifest(value: unknown): value is EncryptionManifest {
100
+ if (!value || typeof value !== 'object') {
101
+ return false;
102
+ }
103
+ const candidate = value as Partial<EncryptionManifest>;
104
+ return (
105
+ typeof candidate.encryptionVersion === 'string' &&
106
+ typeof candidate.algorithm === 'string' &&
107
+ typeof candidate.keyId === 'string' &&
108
+ typeof candidate.wrappedKey === 'string' &&
109
+ typeof candidate.dataIv === 'string' &&
110
+ Array.isArray(candidate.encryptedImages)
111
+ );
112
+ }
113
+
106
114
  /**
107
115
  * Preview case information from ZIP file without importing
108
116
  */
@@ -111,247 +119,135 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
111
119
 
112
120
  try {
113
121
  const zip = await JSZip.loadAsync(zipFile);
114
- const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
115
-
122
+
116
123
  // Check if export is encrypted
117
124
  const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
118
125
  if (encryptionManifestFile) {
119
- // For encrypted exports, we can't read the plaintext data to extract case info
120
- // Return an encrypted preview that requires decryption during import
126
+ let parsedManifest: unknown;
121
127
  try {
122
128
  const manifestContent = await encryptionManifestFile.async('text');
123
- JSON.parse(manifestContent); // Validate it's valid JSON
124
-
125
- // Count image files
126
- let totalFiles = 0;
127
- const imagesFolder = zip.folder('images');
128
- if (imagesFolder) {
129
- for (const [, file] of Object.entries(imagesFolder.files)) {
130
- if (!file.dir && file.name.includes('/')) {
131
- totalFiles++;
132
- }
133
- }
134
- }
135
-
136
- const hasForensicManifest = zip.file('FORENSIC_MANIFEST.json') !== null;
137
-
138
- return {
139
- caseNumber: 'ENCRYPTED',
140
- archived: false,
141
- exportedBy: null,
142
- exportedByName: null,
143
- exportedByCompany: null,
144
- exportedByBadgeId: null,
145
- exportDate: new Date().toISOString(),
146
- totalFiles,
147
- hasAnnotations: false,
148
- validationSummary: 'Export is encrypted. Integrity validation will occur during import.',
149
- hashValid: undefined,
150
- hashError: undefined,
151
- validationDetails: {
152
- hasForensicManifest,
153
- dataValid: undefined,
154
- manifestValid: undefined,
155
- signatureValid: undefined,
156
- validationSummary: 'Encrypted export — integrity validation deferred to import stage',
157
- integrityErrors: []
158
- }
159
- };
129
+ parsedManifest = JSON.parse(manifestContent);
160
130
  } catch (error) {
161
131
  throw new Error(
162
132
  `Encrypted export detected but encryption manifest is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`
163
133
  );
164
134
  }
165
- }
166
-
167
- // First, validate hash if forensic metadata exists
168
- let hashValid: boolean | undefined = undefined;
169
- let hashError: string | undefined = undefined;
170
- let validationDetails: CaseImportPreview['validationDetails'];
171
-
172
- // Find the main data file (JSON or CSV)
173
- const dataFiles = Object.keys(zip.files).filter(name =>
174
- name.endsWith('_data.json') || name.endsWith('_data.csv')
175
- );
176
-
177
- if (dataFiles.length === 0) {
178
- throw new Error('No valid data file found in ZIP archive');
179
- }
180
-
181
- if (dataFiles.length > 1) {
182
- throw new Error('Multiple data files found in ZIP archive');
183
- }
184
-
185
- const dataFileName = dataFiles[0];
186
- const isJsonFormat = dataFileName.endsWith('.json');
187
-
188
- if (!isJsonFormat) {
189
- throw new Error('CSV import not yet supported. Please use JSON format.');
190
- }
191
-
192
- // Extract and parse case data
193
- const dataContent = await zip.file(dataFileName)?.async('text');
194
- if (!dataContent) {
195
- throw new Error('Failed to read data file from ZIP');
196
- }
197
-
198
- // Handle forensic protection warnings in JSON
199
- const cleanedContent = removeForensicWarning(dataContent);
200
-
201
- // Validate forensic manifest integrity
202
- const manifestFile = zip.file('FORENSIC_MANIFEST.json');
203
-
204
- if (manifestFile) {
205
- try {
206
- let forensicManifest: SignedForensicManifest | null = null;
207
-
208
- // Get forensic manifest from dedicated file
209
- const manifestContent = await manifestFile.async('text');
210
- forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
211
-
212
- if (forensicManifest) {
213
- // Extract image files for comprehensive validation
214
- const imageFiles: { [filename: string]: Blob } = {};
215
- const imagesFolder = zip.folder('images');
216
- if (imagesFolder) {
217
- await Promise.all(Object.keys(imagesFolder.files).map(async (path) => {
218
- if (path.startsWith('images/') && !path.endsWith('/')) {
219
- const filename = path.replace('images/', '');
220
- const file = zip.file(path);
221
- if (file) {
222
- const blob = await file.async('blob');
223
- imageFiles[filename] = blob;
224
- }
225
- }
226
- }));
227
- }
228
135
 
229
- const casePackageResult = await verifyCasePackageIntegrity({
230
- cleanedContent,
231
- imageFiles,
232
- forensicManifest,
233
- verificationPublicKeyPem,
234
- bundledAuditFiles: {
235
- auditTrailContent: await zip.file('audit/case-audit-trail.json')?.async('text'),
236
- auditSignatureContent: await zip.file('audit/case-audit-signature.json')?.async('text')
237
- }
238
- });
136
+ if (!isEncryptionManifest(parsedManifest)) {
137
+ throw new Error('Encrypted export manifest is missing required fields.');
138
+ }
239
139
 
240
- const signatureResult = casePackageResult.signatureResult;
241
- const validation = casePackageResult.integrityResult;
242
- const bundledAuditVerification = casePackageResult.bundledAuditVerification;
140
+ const encryptionManifest = parsedManifest;
243
141
 
244
- hashValid = casePackageResult.isValid;
142
+ // Find the encrypted data file
143
+ const encDataFiles = Object.keys(zip.files).filter(name => /_data\.json$/i.test(name));
144
+ if (encDataFiles.length === 0) {
145
+ throw new Error('No data file found in encrypted case ZIP archive.');
146
+ }
147
+ if (encDataFiles.length > 1) {
148
+ throw new Error('Multiple data files found in encrypted case ZIP archive. The archive may be corrupt or tampered.');
149
+ }
245
150
 
246
- if (!hashValid) {
247
- const errorParts: string[] = [];
248
- if (!signatureResult.isValid) {
249
- errorParts.push('Signature validation failed.');
250
- }
251
- if (!validation.isValid) {
252
- errorParts.push('Integrity validation failed.');
253
- }
254
- if (bundledAuditVerification) {
255
- errorParts.push(bundledAuditVerification.message);
256
- }
257
- hashError = errorParts.length > 0 ? errorParts.join(' ') : 'Validation failed.';
258
- }
151
+ const encDataFileName = encDataFiles[0];
152
+ const encryptedDataBytes = await zip.file(encDataFileName)?.async('uint8array');
153
+ if (!encryptedDataBytes) {
154
+ throw new Error('Failed to read encrypted data file from ZIP archive.');
155
+ }
259
156
 
260
- const integrityErrors = [...validation.errors];
261
- if (!signatureResult.isValid) {
262
- integrityErrors.push(`Signature validation failed: ${signatureResult.error || 'Unknown signature error'}`);
263
- }
264
- if (bundledAuditVerification) {
265
- integrityErrors.push(bundledAuditVerification.message);
266
- }
157
+ const encryptedDataBase64 = uint8ArrayToBase64Url(encryptedDataBytes);
267
158
 
268
- validationDetails = {
269
- hasForensicManifest: true,
270
- dataValid: validation.dataValid,
271
- imageValidation: validation.imageValidation,
272
- manifestValid: validation.manifestValid,
273
- signatureValid: signatureResult.isValid,
274
- signatureKeyId: signatureResult.keyId,
275
- signatureError: signatureResult.error,
276
- validationSummary: validation.summary,
277
- integrityErrors
278
- };
279
-
280
- } else {
281
- // No forensic manifest found - cannot validate
282
- hashValid = false;
283
- hashError = 'Validation failed.';
284
-
285
- validationDetails = {
286
- hasForensicManifest: false,
287
- dataValid: false,
288
- validationSummary: 'No forensic manifest found - comprehensive validation not available',
289
- integrityErrors: ['Export does not contain forensic manifest required for validation']
290
- };
159
+ // Decrypt data only (no images) to obtain preview metadata
160
+ let decryptedCaseData: CaseExportData;
161
+ try {
162
+ const decryptResult = await decryptExportBatch(
163
+ currentUser,
164
+ encryptionManifest,
165
+ encryptedDataBase64,
166
+ {}
167
+ );
168
+ decryptedCaseData = JSON.parse(decryptResult.plaintext) as CaseExportData;
169
+ } catch (error) {
170
+ throw new Error(
171
+ `Failed to decrypt export for preview: ${error instanceof Error ? error.message : 'Unknown error'}`
172
+ );
173
+ }
174
+
175
+ if (!decryptedCaseData.metadata?.caseNumber) {
176
+ throw new Error('Decrypted export data is missing required case number.');
177
+ }
178
+
179
+ // Validate that the data file name matches the decrypted case number
180
+ const encDataFileLeaf = encDataFileName.split('/').filter(Boolean).pop()?.toLowerCase() ?? '';
181
+ const expectedEncDataFile = `${decryptedCaseData.metadata.caseNumber.toLowerCase()}_data.json`;
182
+ if (encDataFileLeaf !== expectedEncDataFile) {
183
+ throw new Error(
184
+ `Data file name does not match case number. ` +
185
+ `Expected "${expectedEncDataFile}", found "${encDataFileLeaf}". ` +
186
+ 'The archive may be corrupt or tampered.'
187
+ );
188
+ }
189
+
190
+ // Prefer totalFiles from decrypted metadata; fall back to counting image entries
191
+ let totalFiles = decryptedCaseData.metadata.totalFiles ?? 0;
192
+ if (!totalFiles) {
193
+ const imagesFolder = zip.folder('images');
194
+ if (imagesFolder) {
195
+ for (const [, file] of Object.entries(imagesFolder.files)) {
196
+ if (!file.dir && file.name.includes('/')) {
197
+ totalFiles++;
198
+ }
199
+ }
291
200
  }
292
- } catch {
293
- hashError = 'Validation failed.';
294
- hashValid = false;
295
-
296
- validationDetails = {
297
- hasForensicManifest: true,
298
- validationSummary: 'Validation failed due to metadata parsing error',
299
- integrityErrors: [hashError]
300
- };
301
201
  }
302
- } else {
303
- // No forensic manifest found
304
- validationDetails = {
305
- hasForensicManifest: false,
306
- validationSummary: 'No forensic manifest found - integrity cannot be verified',
307
- integrityErrors: []
308
- };
309
- }
310
-
311
- const parsedCaseData = JSON.parse(cleanedContent) as unknown;
312
- const caseData: CaseExportData = parsedCaseData as CaseExportData;
313
-
314
- // Validate case data structure
315
- if (!caseData.metadata?.caseNumber) {
316
- throw new Error('Invalid case data: missing case number');
317
- }
318
-
319
- if (!validateCaseNumber(caseData.metadata.caseNumber)) {
320
- throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
321
- }
322
-
323
- const isArchivedExport = isArchivedExportData(parsedCaseData);
324
202
 
325
- await validateCaseExporterUidForImport(caseData, currentUser, parsedCaseData);
326
-
327
- // Count image files
328
- let totalFiles = 0;
329
- const imagesFolder = zip.folder('images');
330
- if (imagesFolder) {
331
- for (const [, file] of Object.entries(imagesFolder.files)) {
332
- if (!file.dir && file.name.includes('/')) {
333
- totalFiles++;
203
+ const hasForensicManifest = zip.file('FORENSIC_MANIFEST.json') !== null;
204
+ const isArchivedExport = isArchivedExportData(decryptedCaseData);
205
+ const hasAnnotations = decryptedCaseData.files.some(f => f.hasAnnotations);
206
+
207
+ // Designated reviewer check — must run before returning preview data
208
+ const designatedReviewerEmail = decryptedCaseData.metadata.designatedReviewerEmail;
209
+ if (designatedReviewerEmail) {
210
+ if (!currentUser.email) {
211
+ throw new Error(
212
+ 'Unable to verify reviewer designation: your account email is unavailable.'
213
+ );
214
+ }
215
+ if (designatedReviewerEmail.toLowerCase() !== currentUser.email.toLowerCase()) {
216
+ throw new Error(
217
+ 'This case package is designated for a specific reviewer. You are not authorized to import this case.'
218
+ );
334
219
  }
335
220
  }
221
+
222
+ return {
223
+ caseNumber: decryptedCaseData.metadata.caseNumber,
224
+ archived: isArchivedExport,
225
+ exportedBy: decryptedCaseData.metadata.exportedBy,
226
+ exportedByName: decryptedCaseData.metadata.exportedByName || null,
227
+ exportedByCompany: decryptedCaseData.metadata.exportedByCompany || null,
228
+ exportedByBadgeId: decryptedCaseData.metadata.exportedByBadgeId ?? null,
229
+ exportDate: decryptedCaseData.metadata.exportDate,
230
+ totalFiles,
231
+ caseCreatedDate: decryptedCaseData.metadata.caseCreatedDate ?? undefined,
232
+ hasAnnotations,
233
+ validationSummary: 'Export decrypted successfully. Full integrity validation will occur during import.',
234
+ hashValid: undefined,
235
+ hashError: undefined,
236
+ validationDetails: {
237
+ hasForensicManifest,
238
+ dataValid: undefined,
239
+ manifestValid: undefined,
240
+ signatureValid: undefined,
241
+ validationSummary: 'Encrypted export — integrity validation deferred to import stage',
242
+ integrityErrors: []
243
+ }
244
+ };
336
245
  }
337
-
338
- return {
339
- caseNumber: caseData.metadata.caseNumber,
340
- archived: isArchivedExport,
341
- exportedBy: caseData.metadata.exportedBy || null,
342
- exportedByName: caseData.metadata.exportedByName || null,
343
- exportedByCompany: caseData.metadata.exportedByCompany || null,
344
- exportedByBadgeId: caseData.metadata.exportedByBadgeId || null,
345
- exportDate: caseData.metadata.exportDate,
346
- totalFiles,
347
- caseCreatedDate: caseData.metadata.caseCreatedDate,
348
- hasAnnotations: false, // We'll need to determine this during parsing if needed
349
- validationSummary: hashValid ? 'Validation passed' : 'Validation failed',
350
- hashValid,
351
- hashError,
352
- validationDetails
353
- };
354
-
246
+
247
+ throw new Error(
248
+ 'This case package is not encrypted. Only encrypted case packages exported from Striae can be imported.'
249
+ );
250
+
355
251
  } catch (error) {
356
252
  console.error('Error previewing case import:', error);
357
253
  throw new Error(`Failed to preview case: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -361,9 +257,8 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
361
257
  /**
362
258
  * Parse and validate ZIP file contents for case import
363
259
  */
364
- export async function parseImportZip(zipFile: File, currentUser: User): Promise<{
260
+ export async function parseImportZip(zipFile: File): Promise<{
365
261
  caseData: CaseExportData;
366
- imageFiles: { [filename: string]: Blob };
367
262
  imageIdMapping: { [exportFilename: string]: string }; // exportFilename -> originalImageId
368
263
  isArchivedExport: boolean;
369
264
  bundledAuditFiles?: {
@@ -377,6 +272,7 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
377
272
  encryptedDataBase64?: string; // Optional: encrypted data file content (base64url)
378
273
  encryptedImages?: { [filename: string]: string }; // Optional: encrypted image files (filename -> base64url)
379
274
  isEncrypted?: boolean;
275
+ dataFileName?: string; // The encrypted data file name (leaf), for post-decrypt case number validation
380
276
  }> {
381
277
  // Dynamic import of JSZip to avoid bundle size issues
382
278
  const JSZip = (await import('jszip')).default;
@@ -399,25 +295,28 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
399
295
  }
400
296
 
401
297
  const dataFileName = dataFiles[0];
402
- const isJsonFormat = dataFileName.endsWith('.json');
403
-
404
- // Check for encryption manifest first
298
+
299
+ // Only encrypted case packages are supported
405
300
  const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
406
- let encryptionManifest: Record<string, unknown> | undefined;
407
- let encryptedDataBase64: string | undefined;
301
+ if (!encryptionManifestFile) {
302
+ throw new Error(
303
+ 'This case package is not encrypted. Only encrypted case packages exported from Striae can be imported.'
304
+ );
305
+ }
306
+
307
+ let encryptionManifest: Record<string, unknown>;
308
+ let encryptedDataBase64: string;
408
309
  const encryptedImages: { [filename: string]: string } = {};
409
- let isEncrypted = false;
310
+ const imageIdMapping: { [exportFilename: string]: string } = {};
311
+ const isEncrypted = true;
410
312
 
411
- // Initialize variables before if-else to ensure scope
412
313
  let caseData: CaseExportData;
413
314
  let parsedCaseData: unknown;
414
- let cleanedContent: string = '';
315
+ const cleanedContent = '';
415
316
 
416
- if (encryptionManifestFile) {
417
- try {
418
- const manifestContent = await encryptionManifestFile.async('text');
419
- encryptionManifest = JSON.parse(manifestContent) as Record<string, unknown>;
420
- isEncrypted = true;
317
+ try {
318
+ const manifestContent = await encryptionManifestFile.async('text');
319
+ encryptionManifest = JSON.parse(manifestContent) as Record<string, unknown>;
421
320
 
422
321
  // Extract the encrypted data file
423
322
  const dataContent = await zip.file(dataFileName)?.async('uint8array');
@@ -447,7 +346,14 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
447
346
  }
448
347
 
449
348
  const filename = isImageFile ? filePath.replace(/^images\//, '') : filePath;
450
-
349
+
350
+ if (isImageFile) {
351
+ const originalImageId = extractImageIdFromFilename(filename);
352
+ if (originalImageId) {
353
+ imageIdMapping[filename] = originalImageId;
354
+ }
355
+ }
356
+
451
357
  encryptedImagePromises.push((async () => {
452
358
  try {
453
359
  const encryptedBlob = await file.async('uint8array');
@@ -468,96 +374,20 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
468
374
 
469
375
  // For encrypted exports, data file will be processed after decryption
470
376
  // Set placeholder values that will be replaced after decryption
471
- caseData = { metadata: { caseNumber: 'ENCRYPTED' } } as CaseExportData;
472
- parsedCaseData = caseData;
473
- cleanedContent = '';
474
-
475
- } catch (error) {
476
- throw new Error(`Failed to process encrypted export: ${error instanceof Error ? error.message : 'Unknown error'}`);
477
- }
478
- } else {
479
- // Standard unencrypted extract and parse case data
480
- if (isJsonFormat) {
481
- const dataContent = await zip.file(dataFileName)?.async('text');
482
- if (!dataContent) {
483
- throw new Error('Failed to read data file from ZIP');
484
- }
485
-
486
- // Handle forensic protection warnings in JSON
487
- cleanedContent = removeForensicWarning(dataContent);
488
- parsedCaseData = JSON.parse(cleanedContent) as unknown;
489
- caseData = parsedCaseData as CaseExportData;
490
- } else {
491
- throw new Error('CSV import not yet supported. Please use JSON format.');
492
- }
377
+ caseData = { metadata: { caseNumber: 'ENCRYPTED' } } as CaseExportData;
378
+ parsedCaseData = caseData;
379
+ } catch (error) {
380
+ throw new Error(`Failed to process encrypted export: ${error instanceof Error ? error.message : 'Unknown error'}`);
493
381
  }
494
-
495
- // Validate case data structure only for unencrypted exports
496
- // (encrypted exports will be validated after decryption in orchestrator)
497
- if (!isEncrypted) {
498
- if (!caseData.metadata?.caseNumber) {
499
- throw new Error('Invalid case data: missing case number');
500
- }
501
382
 
502
- if (!validateCaseNumber(caseData.metadata.caseNumber)) {
503
- throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
504
- }
505
- }
506
-
507
383
  const isArchivedExport = isArchivedExportData(parsedCaseData);
508
384
 
509
- // Validate exporter UID exists in user database and is not current user (skip for encrypted)
510
- if (!isEncrypted) {
511
- await validateCaseExporterUidForImport(caseData, currentUser, parsedCaseData);
512
- }
513
-
514
- // Extract image files and create ID mapping - iterate through zip.files directly
515
- const imageFiles: { [filename: string]: Blob } = {};
516
- const imageIdMapping: { [exportFilename: string]: string } = {};
517
-
518
- const imageExtractionPromises: Promise<void>[] = [];
519
-
520
- const fileListForImages = Object.keys(zip.files);
521
- for (const filePath of fileListForImages) {
522
- // Only process files in the images folder
523
- if (!filePath.startsWith('images/') || filePath === 'images/' || filePath.endsWith('/')) {
524
- continue;
525
- }
526
-
527
- const file = zip.files[filePath];
528
- if (!file || file.dir) {
529
- continue;
530
- }
531
-
532
- imageExtractionPromises.push((async () => {
533
- try {
534
- const exportFilename = filePath.replace(/^images\//, '');
535
- const blob = await file.async('blob');
536
- imageFiles[exportFilename] = blob;
537
-
538
- // Extract original image ID from filename
539
- const originalImageId = extractImageIdFromFilename(exportFilename);
540
- if (originalImageId) {
541
- imageIdMapping[exportFilename] = originalImageId;
542
- }
543
- } catch (err) {
544
- console.error(`Failed to extract image ${filePath}:`, err);
545
- }
546
- })());
547
- }
548
-
549
- // Wait for all image extractions to complete
550
- await Promise.all(imageExtractionPromises);
551
-
552
385
  // Extract forensic manifest if present
553
386
  let metadata: Record<string, unknown> | undefined;
554
387
  const manifestFile = zip.file('FORENSIC_MANIFEST.json');
555
- const auditTrailContent = isEncrypted
556
- ? undefined
557
- : await zip.file('audit/case-audit-trail.json')?.async('text');
558
- const auditSignatureContent = isEncrypted
559
- ? undefined
560
- : await zip.file('audit/case-audit-signature.json')?.async('text');
388
+ // Audit trail files are encrypted — decrypted by the orchestrator
389
+ const auditTrailContent: string | undefined = undefined;
390
+ const auditSignatureContent: string | undefined = undefined;
561
391
 
562
392
  if (manifestFile) {
563
393
  const manifestContent = await manifestFile.async('text');
@@ -566,7 +396,6 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
566
396
 
567
397
  return {
568
398
  caseData,
569
- imageFiles,
570
399
  imageIdMapping,
571
400
  isArchivedExport,
572
401
  bundledAuditFiles: {
@@ -579,7 +408,8 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
579
408
  encryptionManifest,
580
409
  encryptedDataBase64,
581
410
  encryptedImages: Object.keys(encryptedImages).length > 0 ? encryptedImages : undefined,
582
- isEncrypted
411
+ isEncrypted,
412
+ dataFileName
583
413
  };
584
414
 
585
415
  } catch (error) {
@@ -2,6 +2,7 @@ import { type AnnotationData } from '~/types/annotations';
2
2
  import { auditService } from '~/services/audit';
3
3
  import type { User } from 'firebase/auth';
4
4
  import { fetchPdfApi } from '~/utils/api';
5
+ import type { ToastType } from '~/components/toast/toast';
5
6
 
6
7
  interface GeneratePDFParams {
7
8
  user: User;
@@ -16,7 +17,7 @@ interface GeneratePDFParams {
16
17
  annotationData: AnnotationData | null;
17
18
  activeAnnotations: Set<string>;
18
19
  setIsGeneratingPDF: (isGenerating: boolean) => void;
19
- setToastType: (type: 'success' | 'error') => void;
20
+ setToastType: (type: ToastType) => void;
20
21
  setToastMessage: (message: string) => void;
21
22
  setShowToast: (show: boolean) => void;
22
23
  setToastDuration?: (duration: number) => void;
@@ -90,7 +91,7 @@ export const generatePDF = async ({
90
91
  const startTime = Date.now();
91
92
 
92
93
  // Show generating toast immediately with duration 0 (stays until manually closed or completion)
93
- setToastType('success');
94
+ setToastType('loading');
94
95
  setToastMessage('Generating PDF report... This may take up to a minute.');
95
96
  if (setToastDuration) setToastDuration(0);
96
97
  setShowToast(true);
@@ -9,7 +9,6 @@ import { AuditEntriesList } from './viewer/audit-entries-list';
9
9
  import { summarizeAuditEntries } from './viewer/audit-viewer-utils';
10
10
  import { useAuditViewerData } from './viewer/use-audit-viewer-data';
11
11
  import { useAuditViewerFilters } from './viewer/use-audit-viewer-filters';
12
- import { useAuditViewerExport } from './viewer/use-audit-viewer-export';
13
12
  import styles from './user-audit.module.css';
14
13
 
15
14
  interface UserAuditViewerProps {
@@ -56,8 +55,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
56
55
  userData,
57
56
  loading,
58
57
  error,
59
- setError,
60
- auditTrail,
61
58
  isArchivedReadOnlyCase,
62
59
  bundledAuditWarning,
63
60
  loadAuditData
@@ -73,18 +70,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
73
70
  const filteredEntries = useMemo(() => getFilteredEntries(auditEntries), [auditEntries, getFilteredEntries]);
74
71
  const auditSummary = useMemo(() => summarizeAuditEntries(auditEntries), [auditEntries]);
75
72
 
76
- const {
77
- handleExportCSV,
78
- handleExportJSON,
79
- handleGenerateReport
80
- } = useAuditViewerExport({
81
- user,
82
- effectiveCaseNumber,
83
- filteredEntries,
84
- auditTrail,
85
- setError
86
- });
87
-
88
73
  const {
89
74
  requestClose,
90
75
  overlayProps
@@ -106,10 +91,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
106
91
  <div className={styles.modal}>
107
92
  <AuditViewerHeader
108
93
  title={title || (effectiveCaseNumber ? `Audit Trail - Case ${effectiveCaseNumber}` : 'My Audit Trail')}
109
- hasEntries={auditEntries.length > 0}
110
- onExportCSV={handleExportCSV}
111
- onExportJSON={handleExportJSON}
112
- onGenerateReport={handleGenerateReport}
113
94
  onClose={requestClose}
114
95
  />
115
96