@striae-org/striae 3.2.1 → 3.3.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 (104) hide show
  1. package/app/components/actions/case-export/core-export.ts +2 -2
  2. package/app/components/actions/case-export/data-processing.ts +19 -4
  3. package/app/components/actions/case-export/download-handlers.ts +57 -8
  4. package/app/components/actions/case-export/metadata-helpers.ts +1 -1
  5. package/app/components/actions/case-import/annotation-import.ts +2 -2
  6. package/app/components/actions/case-import/confirmation-import.ts +44 -20
  7. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  8. package/app/components/actions/case-import/image-operations.ts +1 -1
  9. package/app/components/actions/case-import/index.ts +1 -0
  10. package/app/components/actions/case-import/orchestrator.ts +16 -6
  11. package/app/components/actions/case-import/storage-operations.ts +7 -7
  12. package/app/components/actions/case-import/validation.ts +7 -100
  13. package/app/components/actions/case-import/zip-processing.ts +47 -5
  14. package/app/components/actions/case-manage.ts +3 -3
  15. package/app/components/actions/confirm-export.ts +47 -16
  16. package/app/components/actions/generate-pdf.ts +3 -3
  17. package/app/components/actions/image-manage.ts +3 -3
  18. package/app/components/actions/notes-manage.ts +3 -3
  19. package/app/components/actions/signout.tsx +1 -1
  20. package/app/components/audit/user-audit-viewer.tsx +2 -3
  21. package/app/components/auth/auth-provider.tsx +2 -2
  22. package/app/components/auth/mfa-enrollment.tsx +3 -3
  23. package/app/components/auth/mfa-verification.tsx +4 -4
  24. package/app/components/canvas/box-annotations/box-annotations.tsx +2 -2
  25. package/app/components/canvas/canvas.tsx +1 -1
  26. package/app/components/canvas/confirmation/confirmation.tsx +1 -1
  27. package/app/components/form/form-button.tsx +1 -1
  28. package/app/components/form/form.module.css +9 -0
  29. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  30. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  31. package/app/components/sidebar/case-export/case-export.tsx +2 -54
  32. package/app/components/sidebar/case-import/case-import.tsx +20 -8
  33. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -1
  34. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
  35. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +9 -7
  36. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -2
  37. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  38. package/app/components/sidebar/cases/case-sidebar.tsx +106 -50
  39. package/app/components/sidebar/cases/cases-modal.tsx +1 -1
  40. package/app/components/sidebar/cases/cases.module.css +101 -18
  41. package/app/components/sidebar/files/files-modal.tsx +3 -2
  42. package/app/components/sidebar/notes/notes-sidebar.tsx +3 -3
  43. package/app/components/sidebar/notes/notes.module.css +33 -13
  44. package/app/components/sidebar/sidebar-container.tsx +4 -3
  45. package/app/components/sidebar/sidebar.tsx +2 -2
  46. package/app/components/sidebar/upload/image-upload-zone.tsx +2 -2
  47. package/app/components/theme-provider/theme-provider.tsx +1 -1
  48. package/app/components/user/delete-account.tsx +1 -1
  49. package/app/components/user/manage-profile.tsx +3 -3
  50. package/app/components/user/mfa-phone-update.tsx +17 -14
  51. package/app/contexts/auth.context.ts +1 -1
  52. package/app/root.tsx +2 -2
  53. package/app/routes/auth/emailActionHandler.tsx +2 -2
  54. package/app/routes/auth/emailVerification.tsx +2 -2
  55. package/app/routes/auth/login.tsx +134 -11
  56. package/app/routes/auth/passwordReset.tsx +2 -2
  57. package/app/routes/striae/striae.tsx +2 -2
  58. package/app/services/audit/audit-console-logger.ts +46 -0
  59. package/app/services/audit/audit-export-csv.ts +126 -0
  60. package/app/services/audit/audit-export-report.ts +174 -0
  61. package/app/services/audit/audit-export-signing.ts +85 -0
  62. package/app/services/audit/audit-export.service.ts +334 -0
  63. package/app/services/audit/audit-file-type.ts +13 -0
  64. package/app/services/audit/audit-query-helpers.ts +88 -0
  65. package/app/services/audit/audit-worker-client.ts +95 -0
  66. package/app/services/audit/audit.service.ts +990 -0
  67. package/app/services/audit/builders/audit-entry-builder.ts +32 -0
  68. package/app/services/audit/builders/audit-event-builders-annotation.ts +150 -0
  69. package/app/services/audit/builders/audit-event-builders-case-file.ts +249 -0
  70. package/app/services/audit/builders/audit-event-builders-user-security.ts +449 -0
  71. package/app/services/audit/builders/audit-event-builders-workflow.ts +272 -0
  72. package/app/services/audit/builders/index.ts +40 -0
  73. package/app/services/audit/index.ts +2 -0
  74. package/app/types/case.ts +2 -2
  75. package/app/types/exceljs-bare.d.ts +3 -1
  76. package/app/types/user.ts +1 -1
  77. package/app/utils/SHA256.ts +5 -1
  78. package/app/utils/audit-export-signature.ts +2 -2
  79. package/app/utils/confirmation-signature.ts +8 -4
  80. package/app/utils/data-operations.ts +5 -5
  81. package/app/utils/export-verification.ts +353 -0
  82. package/app/utils/mfa-phone.ts +1 -1
  83. package/app/utils/mfa.ts +1 -1
  84. package/app/utils/permissions.ts +2 -2
  85. package/app/utils/signature-utils.ts +74 -4
  86. package/package.json +11 -9
  87. package/public/favicon.ico +0 -0
  88. package/public/icon-256.png +0 -0
  89. package/public/icon-512.png +0 -0
  90. package/public/manifest.json +39 -0
  91. package/public/shortcut.png +0 -0
  92. package/public/social-image.png +0 -0
  93. package/react-router.config.ts +5 -0
  94. package/worker-configuration.d.ts +4435 -562
  95. package/workers/data-worker/src/data-worker.example.ts +3 -3
  96. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  97. package/workers/pdf-worker/src/{generated-assets.ts → assets/generated-assets.ts} +117 -117
  98. package/workers/pdf-worker/src/{format-striae.ts → formats/format-striae.ts} +535 -535
  99. package/workers/pdf-worker/src/pdf-worker.example.ts +1 -1
  100. package/app/services/audit-export.service.ts +0 -755
  101. package/app/services/audit.service.ts +0 -1474
  102. package/public/favicon.svg +0 -9
  103. /package/app/services/{firebase-errors.ts → firebase/errors.ts} +0 -0
  104. /package/app/services/{firebase.ts → firebase/index.ts} +0 -0
@@ -1,8 +1,213 @@
1
- import { useEffect, useId, useState, type KeyboardEvent, type MouseEvent } from 'react';
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useRef,
5
+ useState,
6
+ type ChangeEvent,
7
+ type DragEvent,
8
+ type KeyboardEvent,
9
+ type MouseEvent
10
+ } from 'react';
2
11
  import styles from './public-signing-key-modal.module.css';
12
+ import { verifyExportFile } from '~/utils/export-verification';
3
13
 
4
14
  const NO_PUBLIC_KEY_MESSAGE = 'No public signing key is configured for this environment.';
5
15
 
16
+ interface SelectedPublicKeyFile {
17
+ name: string;
18
+ content: string;
19
+ source: 'download' | 'upload';
20
+ }
21
+
22
+ interface VerificationOutcome {
23
+ state: 'pass' | 'fail';
24
+ message: string;
25
+ }
26
+
27
+ interface VerificationDropZoneProps {
28
+ inputId: string;
29
+ label: string;
30
+ accept: string;
31
+ emptyText: string;
32
+ helperText: string;
33
+ selectedFileName?: string | null;
34
+ selectedDescription?: string;
35
+ errorMessage?: string;
36
+ isDisabled?: boolean;
37
+ onFileSelected: (file: File) => void | Promise<void>;
38
+ onClear?: () => void;
39
+ actionButton?: {
40
+ label: string;
41
+ onClick: () => void;
42
+ disabled?: boolean;
43
+ };
44
+ }
45
+
46
+ const VerificationDropZone = ({
47
+ inputId,
48
+ label,
49
+ accept,
50
+ emptyText,
51
+ helperText,
52
+ selectedFileName,
53
+ selectedDescription,
54
+ errorMessage,
55
+ isDisabled = false,
56
+ onFileSelected,
57
+ onClear,
58
+ actionButton
59
+ }: VerificationDropZoneProps) => {
60
+ const [isDragOver, setIsDragOver] = useState(false);
61
+ const inputRef = useRef<HTMLInputElement>(null);
62
+
63
+ const openFilePicker = () => {
64
+ if (!isDisabled) {
65
+ inputRef.current?.click();
66
+ }
67
+ };
68
+
69
+ const handleSelectedFile = (file?: File) => {
70
+ if (!file || isDisabled) {
71
+ return;
72
+ }
73
+
74
+ void onFileSelected(file);
75
+
76
+ if (inputRef.current) {
77
+ inputRef.current.value = '';
78
+ }
79
+ };
80
+
81
+ const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
82
+ handleSelectedFile(event.target.files?.[0]);
83
+ };
84
+
85
+ const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
86
+ event.preventDefault();
87
+ if (!isDisabled) {
88
+ setIsDragOver(true);
89
+ }
90
+ };
91
+
92
+ const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
93
+ event.preventDefault();
94
+ const relatedTarget = event.relatedTarget as HTMLElement | null;
95
+
96
+ if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) {
97
+ setIsDragOver(false);
98
+ }
99
+ };
100
+
101
+ const handleDrop = (event: DragEvent<HTMLDivElement>) => {
102
+ event.preventDefault();
103
+ setIsDragOver(false);
104
+ handleSelectedFile(event.dataTransfer.files?.[0]);
105
+ };
106
+
107
+ return (
108
+ <div className={styles.verificationField}>
109
+ <div className={styles.fieldHeader}>
110
+ <label htmlFor={inputId} className={styles.fieldLabel}>
111
+ {label}
112
+ </label>
113
+ {selectedFileName && onClear && (
114
+ <button type="button" className={styles.clearButton} onClick={onClear}>
115
+ Clear
116
+ </button>
117
+ )}
118
+ </div>
119
+
120
+ <input
121
+ id={inputId}
122
+ ref={inputRef}
123
+ type="file"
124
+ accept={accept}
125
+ onChange={handleInputChange}
126
+ disabled={isDisabled}
127
+ className={styles.hiddenFileInput}
128
+ />
129
+
130
+ <div
131
+ className={`${styles.dropZone} ${isDragOver ? styles.dropZoneActive : ''} ${isDisabled ? styles.dropZoneDisabled : ''}`}
132
+ onClick={openFilePicker}
133
+ onDragOver={handleDragOver}
134
+ onDragLeave={handleDragLeave}
135
+ onDrop={handleDrop}
136
+ role="button"
137
+ tabIndex={isDisabled ? -1 : 0}
138
+ aria-disabled={isDisabled}
139
+ aria-label={label}
140
+ onKeyDown={(event) => {
141
+ if ((event.key === 'Enter' || event.key === ' ') && !isDisabled) {
142
+ if (event.key === ' ') {
143
+ event.preventDefault();
144
+ }
145
+ openFilePicker();
146
+ }
147
+ }}
148
+ >
149
+ <p className={styles.dropZonePrimary}>
150
+ {isDragOver ? 'Drop file to continue' : selectedFileName || emptyText}
151
+ </p>
152
+ <p className={styles.dropZoneSecondary}>{selectedFileName ? selectedDescription : helperText}</p>
153
+ </div>
154
+
155
+ <div className={styles.fieldActions}>
156
+ <button type="button" className={styles.secondaryButton} onClick={openFilePicker} disabled={isDisabled}>
157
+ Choose File
158
+ </button>
159
+ {actionButton && (
160
+ <button
161
+ type="button"
162
+ className={styles.secondaryButton}
163
+ onClick={actionButton.onClick}
164
+ disabled={isDisabled || actionButton.disabled}
165
+ >
166
+ {actionButton.label}
167
+ </button>
168
+ )}
169
+ </div>
170
+
171
+ {errorMessage && <p className={styles.fieldError}>{errorMessage}</p>}
172
+ </div>
173
+ );
174
+ };
175
+
176
+ function createPublicKeyDownloadFileName(publicSigningKeyId?: string | null): string {
177
+ const normalizedKeyId =
178
+ typeof publicSigningKeyId === 'string' && publicSigningKeyId.trim().length > 0
179
+ ? `-${publicSigningKeyId.trim().replace(/[^a-z0-9_-]+/gi, '-')}`
180
+ : '';
181
+
182
+ return `striae-public-signing-key${normalizedKeyId}.pem`;
183
+ }
184
+
185
+ function downloadTextFile(fileName: string, content: string, mimeType: string): void {
186
+ const blob = new Blob([content], { type: mimeType });
187
+ const objectUrl = URL.createObjectURL(blob);
188
+ const linkElement = document.createElement('a');
189
+
190
+ linkElement.href = objectUrl;
191
+ linkElement.download = fileName;
192
+ linkElement.style.display = 'none';
193
+
194
+ document.body.appendChild(linkElement);
195
+ linkElement.click();
196
+ document.body.removeChild(linkElement);
197
+
198
+ window.setTimeout(() => {
199
+ URL.revokeObjectURL(objectUrl);
200
+ }, 0);
201
+ }
202
+
203
+ function formatFileSize(bytes: number): string {
204
+ if (bytes < 1024 * 1024) {
205
+ return `${(bytes / 1024).toFixed(1)} KB`;
206
+ }
207
+
208
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
209
+ }
210
+
6
211
  interface PublicSigningKeyModalProps {
7
212
  isOpen: boolean;
8
213
  onClose: () => void;
@@ -16,15 +221,24 @@ export const PublicSigningKeyModal = ({
16
221
  publicSigningKeyId,
17
222
  publicKeyPem
18
223
  }: PublicSigningKeyModalProps) => {
19
- const [isCopyingPublicKey, setIsCopyingPublicKey] = useState(false);
20
- const [publicKeyCopyMessage, setPublicKeyCopyMessage] = useState('');
224
+ const [selectedPublicKey, setSelectedPublicKey] = useState<SelectedPublicKeyFile | null>(null);
225
+ const [selectedExportFile, setSelectedExportFile] = useState<File | null>(null);
226
+ const [keyError, setKeyError] = useState('');
227
+ const [exportFileError, setExportFileError] = useState('');
228
+ const [verificationOutcome, setVerificationOutcome] = useState<VerificationOutcome | null>(null);
229
+ const [isVerifying, setIsVerifying] = useState(false);
21
230
  const publicSigningKeyTitleId = useId();
22
- const publicSigningKeyFieldId = useId();
231
+ const publicKeyInputId = useId();
232
+ const exportFileInputId = useId();
23
233
 
24
234
  useEffect(() => {
25
235
  if (!isOpen) {
26
- setIsCopyingPublicKey(false);
27
- setPublicKeyCopyMessage('');
236
+ setSelectedPublicKey(null);
237
+ setSelectedExportFile(null);
238
+ setKeyError('');
239
+ setExportFileError('');
240
+ setVerificationOutcome(null);
241
+ setIsVerifying(false);
28
242
  }
29
243
  }, [isOpen]);
30
244
 
@@ -67,64 +281,108 @@ export const PublicSigningKeyModal = ({
67
281
  }
68
282
  };
69
283
 
70
- const copyTextWithExecCommand = (text: string): boolean => {
71
- const tempTextarea = document.createElement('textarea');
72
- tempTextarea.value = text;
73
- tempTextarea.setAttribute('readonly', '');
74
- tempTextarea.style.position = 'fixed';
75
- tempTextarea.style.opacity = '0';
76
- tempTextarea.style.pointerEvents = 'none';
77
-
78
- document.body.appendChild(tempTextarea);
79
- tempTextarea.select();
284
+ const resetVerificationState = () => {
285
+ setVerificationOutcome(null);
286
+ };
80
287
 
81
- let copied = false;
288
+ const handlePublicKeySelected = async (file: File) => {
82
289
  try {
83
- copied = document.execCommand('copy');
84
- } finally {
85
- document.body.removeChild(tempTextarea);
86
- }
290
+ const lowerName = file.name.toLowerCase();
291
+ if (!lowerName.endsWith('.pem')) {
292
+ setKeyError('Select a PEM public key file.');
293
+ return;
294
+ }
295
+
296
+ const content = await file.text();
297
+ if (!content.includes('-----BEGIN PUBLIC KEY-----') || !content.includes('-----END PUBLIC KEY-----')) {
298
+ setKeyError('The selected file is not a valid PEM public key file.');
299
+ return;
300
+ }
87
301
 
88
- return copied;
302
+ setSelectedPublicKey({
303
+ name: file.name,
304
+ content,
305
+ source: 'upload'
306
+ });
307
+ setKeyError('');
308
+ resetVerificationState();
309
+ } catch {
310
+ setKeyError('The public key file could not be read.');
311
+ }
89
312
  };
90
313
 
91
- const handleCopyPublicKey = async () => {
314
+ const handleDownloadCurrentPublicKey = () => {
92
315
  if (!publicKeyPem) {
93
- setPublicKeyCopyMessage(NO_PUBLIC_KEY_MESSAGE);
316
+ setKeyError(NO_PUBLIC_KEY_MESSAGE);
94
317
  return;
95
318
  }
96
319
 
97
- setIsCopyingPublicKey(true);
98
- setPublicKeyCopyMessage('');
320
+ const fileName = createPublicKeyDownloadFileName(publicSigningKeyId);
321
+ downloadTextFile(fileName, publicKeyPem, 'application/x-pem-file');
322
+ setSelectedPublicKey({
323
+ name: fileName,
324
+ content: publicKeyPem,
325
+ source: 'download'
326
+ });
327
+ setKeyError('');
328
+ resetVerificationState();
329
+ };
330
+
331
+ const handleExportFileSelected = async (file: File) => {
332
+ const lowerName = file.name.toLowerCase();
333
+
334
+ if (!lowerName.endsWith('.zip') && !lowerName.endsWith('.json')) {
335
+ setExportFileError('Select a confirmation JSON/ZIP file or a case export ZIP file.');
336
+ return;
337
+ }
338
+
339
+ setSelectedExportFile(file);
340
+ setExportFileError('');
341
+ resetVerificationState();
342
+ };
343
+
344
+ const handleVerify = async () => {
345
+ const hasPublicKey = !!selectedPublicKey?.content;
346
+ const hasExportFile = !!selectedExportFile;
347
+
348
+ setKeyError(hasPublicKey ? '' : 'Select or download a public key PEM file first.');
349
+ setExportFileError(hasExportFile ? '' : 'Select a confirmation JSON/ZIP file or a case export ZIP file.');
350
+
351
+ if (!hasPublicKey || !hasExportFile || !selectedPublicKey || !selectedExportFile) {
352
+ return;
353
+ }
354
+
355
+ setIsVerifying(true);
356
+ setVerificationOutcome(null);
99
357
 
100
358
  try {
101
- if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
102
- await navigator.clipboard.writeText(publicKeyPem);
103
- setPublicKeyCopyMessage('Public key copied to clipboard.');
104
- } else {
105
- const copied = copyTextWithExecCommand(publicKeyPem);
106
- setPublicKeyCopyMessage(
107
- copied
108
- ? 'Public key copied to clipboard.'
109
- : 'Copy failed. Select and copy the key manually.'
110
- );
111
- }
112
- } catch (copyError) {
113
- const copied = copyTextWithExecCommand(publicKeyPem);
114
- setPublicKeyCopyMessage(
115
- copied
116
- ? 'Public key copied to clipboard.'
117
- : 'Copy failed. Select and copy the key manually.'
118
- );
119
-
120
- if (!copied) {
121
- console.error('Failed to copy public signing key:', copyError);
122
- }
359
+ const result = await verifyExportFile(selectedExportFile, selectedPublicKey.content);
360
+ setVerificationOutcome({
361
+ state: result.isValid ? 'pass' : 'fail',
362
+ message: result.message
363
+ });
123
364
  } finally {
124
- setIsCopyingPublicKey(false);
365
+ setIsVerifying(false);
125
366
  }
126
367
  };
127
368
 
369
+ const selectedKeyDescription = selectedPublicKey
370
+ ? selectedPublicKey.source === 'download'
371
+ ? 'Downloaded from this Striae environment and ready to use.'
372
+ : 'Loaded from your device and ready to use.'
373
+ : undefined;
374
+
375
+ const selectedExportDescription = selectedExportFile
376
+ ? `${(() => {
377
+ const lowerName = selectedExportFile.name.toLowerCase();
378
+ if (lowerName.endsWith('.zip')) {
379
+ return lowerName.includes('confirmation-data-') ? 'Confirmation ZIP' : 'Case export ZIP';
380
+ }
381
+
382
+ return 'Confirmation JSON';
383
+ })()} • ${formatFileSize(selectedExportFile.size)}`
384
+ : undefined;
385
+
128
386
  return (
129
387
  <div
130
388
  className={styles.overlay}
@@ -156,61 +414,80 @@ export const PublicSigningKeyModal = ({
156
414
 
157
415
  <div className={styles.content}>
158
416
  <p className={styles.description}>
159
- This key verifies digital signatures attached to Striae exports. It is safe to share for
160
- independent verification.
417
+ Drop a public key PEM file and a Striae confirmation JSON/ZIP or case export ZIP, then run
418
+ verification directly in the browser.
161
419
  </p>
162
420
 
163
421
  {publicSigningKeyId && (
164
422
  <p className={styles.meta}>
165
- Key ID: <span>{publicSigningKeyId}</span>
423
+ Current key ID: <span>{publicSigningKeyId}</span>
166
424
  </p>
167
425
  )}
168
426
 
169
- <label htmlFor={publicSigningKeyFieldId} className={styles.label}>
170
- Public signing key (PEM)
171
- </label>
172
- <textarea
173
- id={publicSigningKeyFieldId}
174
- className={styles.field}
175
- value={publicKeyPem || NO_PUBLIC_KEY_MESSAGE}
176
- readOnly
177
- rows={10}
178
- />
179
-
180
- <p className={styles.howToTitle}>How to verify Striae exports</p>
181
- <ol className={styles.howToList}>
182
- <li>
183
- Locate signature metadata in the export (for case ZIP exports, see FORENSIC_MANIFEST.json;
184
- for confirmation exports, see metadata.signature).
185
- </li>
186
- <li>
187
- Use this public key with your signature verification workflow (for example OpenSSL or an
188
- internal verifier) to validate the signed payload.
189
- </li>
190
- <li>
191
- Trust the export only when signature verification succeeds and the key ID matches the export
192
- metadata.
193
- </li>
194
- </ol>
195
-
196
- {publicKeyCopyMessage && (
197
- <p className={styles.status} role="status" aria-live="polite">
198
- {publicKeyCopyMessage}
199
- </p>
427
+ <div className={styles.verifierLayout}>
428
+ <VerificationDropZone
429
+ inputId={publicKeyInputId}
430
+ label="1. Public Key PEM"
431
+ accept=".pem"
432
+ emptyText="Drop a public key PEM file here"
433
+ helperText="Use a .pem file containing the Striae public signing key."
434
+ selectedFileName={selectedPublicKey?.name}
435
+ selectedDescription={selectedKeyDescription}
436
+ errorMessage={keyError}
437
+ onFileSelected={handlePublicKeySelected}
438
+ onClear={() => {
439
+ setSelectedPublicKey(null);
440
+ setKeyError('');
441
+ resetVerificationState();
442
+ }}
443
+ actionButton={{
444
+ label: 'Download Current Public Key',
445
+ onClick: handleDownloadCurrentPublicKey,
446
+ disabled: !publicKeyPem
447
+ }}
448
+ />
449
+
450
+ <VerificationDropZone
451
+ inputId={exportFileInputId}
452
+ label="2. Confirmation File or Export ZIP"
453
+ accept=".json,.zip"
454
+ emptyText="Drop a confirmation JSON/ZIP or case export ZIP here"
455
+ helperText="Case exports use .zip. Confirmation exports can be .json or .zip."
456
+ selectedFileName={selectedExportFile?.name}
457
+ selectedDescription={selectedExportDescription}
458
+ errorMessage={exportFileError}
459
+ onFileSelected={handleExportFileSelected}
460
+ onClear={() => {
461
+ setSelectedExportFile(null);
462
+ setExportFileError('');
463
+ resetVerificationState();
464
+ }}
465
+ />
466
+ </div>
467
+
468
+ {verificationOutcome && (
469
+ <div
470
+ className={`${styles.resultCard} ${verificationOutcome.state === 'pass' ? styles.resultPass : styles.resultFail}`}
471
+ role="status"
472
+ aria-live="polite"
473
+ >
474
+ <p className={styles.resultTitle}>{verificationOutcome.state === 'pass' ? 'PASS' : 'FAIL'}</p>
475
+ <p className={styles.resultMessage}>{verificationOutcome.message}</p>
476
+ </div>
200
477
  )}
201
478
 
202
479
  <div className={styles.actions}>
203
480
  <button
204
481
  type="button"
205
- className={styles.copyButton}
206
- onClick={handleCopyPublicKey}
207
- disabled={isCopyingPublicKey || !publicKeyPem}
482
+ className={styles.primaryButton}
483
+ onClick={handleVerify}
484
+ disabled={isVerifying || !selectedPublicKey || !selectedExportFile}
208
485
  >
209
- {isCopyingPublicKey ? 'Copying...' : 'Copy Key'}
486
+ {isVerifying ? 'Verifying...' : 'Verify File'}
210
487
  </button>
211
488
  <button
212
489
  type="button"
213
- className={styles.closeModalButton}
490
+ className={styles.secondaryButton}
214
491
  onClick={onClose}
215
492
  >
216
493
  Close
@@ -1,64 +1,12 @@
1
1
  import { useState, useEffect, useContext } from 'react';
2
2
  import styles from './case-export.module.css';
3
- import config from '~/config/config.json';
4
3
  import { AuthContext } from '~/contexts/auth.context';
5
4
  import { PublicSigningKeyModal } from '~/components/public-signing-key-modal/public-signing-key-modal';
6
- import { getVerificationPublicKey } from '~/utils/signature-utils';
5
+ import { getCurrentPublicSigningKeyDetails } from '~/utils/signature-utils';
7
6
  import { getCaseConfirmations, exportConfirmationData } from '../../actions/confirm-export';
8
7
 
9
8
  export type ExportFormat = 'json' | 'csv';
10
9
 
11
- type ManifestSigningConfig = {
12
- manifest_signing_key_id?: string;
13
- manifest_signing_public_key?: string;
14
- manifest_signing_public_keys?: Record<string, string>;
15
- };
16
-
17
- function getPublicSigningKeyDetails(): { keyId: string | null; publicKeyPem: string | null } {
18
- const signingConfig = config as unknown as ManifestSigningConfig;
19
- const configuredKeyId =
20
- typeof signingConfig.manifest_signing_key_id === 'string' &&
21
- signingConfig.manifest_signing_key_id.trim().length > 0
22
- ? signingConfig.manifest_signing_key_id
23
- : null;
24
-
25
- if (configuredKeyId) {
26
- return {
27
- keyId: configuredKeyId,
28
- publicKeyPem: getVerificationPublicKey(configuredKeyId)
29
- };
30
- }
31
-
32
- const keyMap = signingConfig.manifest_signing_public_keys;
33
- if (keyMap && typeof keyMap === 'object') {
34
- const firstConfiguredEntry = Object.entries(keyMap).find(
35
- ([, value]) => typeof value === 'string' && value.trim().length > 0
36
- );
37
-
38
- if (firstConfiguredEntry) {
39
- return {
40
- keyId: firstConfiguredEntry[0],
41
- publicKeyPem: firstConfiguredEntry[1]
42
- };
43
- }
44
- }
45
-
46
- if (
47
- typeof signingConfig.manifest_signing_public_key === 'string' &&
48
- signingConfig.manifest_signing_public_key.trim().length > 0
49
- ) {
50
- return {
51
- keyId: null,
52
- publicKeyPem: signingConfig.manifest_signing_public_key
53
- };
54
- }
55
-
56
- return {
57
- keyId: null,
58
- publicKeyPem: null
59
- };
60
- }
61
-
62
10
  interface CaseExportProps {
63
11
  isOpen: boolean;
64
12
  onClose: () => void;
@@ -87,7 +35,7 @@ export const CaseExport = ({
87
35
  const [includeImages, setIncludeImages] = useState(false);
88
36
  const [hasConfirmationData, setHasConfirmationData] = useState(false);
89
37
  const [isPublicKeyModalOpen, setIsPublicKeyModalOpen] = useState(false);
90
- const { keyId: publicSigningKeyId, publicKeyPem } = getPublicSigningKeyDetails();
38
+ const { keyId: publicSigningKeyId, publicKeyPem } = getCurrentPublicSigningKeyDetails();
91
39
 
92
40
  // Update caseNumber when currentCaseNumber prop changes
93
41
  useEffect(() => {
@@ -5,8 +5,8 @@ import {
5
5
  deleteReadOnlyCase
6
6
  } from '~/components/actions/case-review';
7
7
  import {
8
- ImportResult,
9
- ConfirmationImportResult
8
+ type ImportResult,
9
+ type ConfirmationImportResult
10
10
  } from '~/types';
11
11
  import {
12
12
  FileSelector,
@@ -19,7 +19,7 @@ import {
19
19
  useFilePreview,
20
20
  useImportExecution,
21
21
  isValidImportFile,
22
- getImportType,
22
+ resolveImportType,
23
23
  resetFileInput
24
24
  } from './index';
25
25
  import styles from './case-import.module.css';
@@ -146,12 +146,18 @@ export const CaseImport = ({
146
146
  clearMessages();
147
147
 
148
148
  if (!isValidImportFile(file)) {
149
- setError('Only ZIP files (case imports) or JSON files (confirmation imports) are allowed. Please select a valid file.');
149
+ setError('Only Striae case ZIP files, confirmation ZIP files, or confirmation JSON files are allowed.');
150
+ clearImportData();
151
+ return;
152
+ }
153
+
154
+ const importType = await resolveImportType(file);
155
+ if (!importType) {
156
+ setError('The selected file is not a supported Striae case or confirmation import package.');
150
157
  clearImportData();
151
158
  return;
152
159
  }
153
160
 
154
- const importType = getImportType(file);
155
161
  updateImportState({
156
162
  selectedFile: file,
157
163
  importType
@@ -172,12 +178,18 @@ export const CaseImport = ({
172
178
  clearMessages();
173
179
 
174
180
  if (!isValidImportFile(file)) {
175
- setError('Only ZIP files (case imports) or JSON files (confirmation imports) are allowed. Please select a valid file.');
181
+ setError('Only Striae case ZIP files, confirmation ZIP files, or confirmation JSON files are allowed.');
182
+ clearImportData();
183
+ return;
184
+ }
185
+
186
+ const importType = await resolveImportType(file);
187
+ if (!importType) {
188
+ setError('The selected file is not a supported Striae case or confirmation import package.');
176
189
  clearImportData();
177
190
  return;
178
191
  }
179
192
 
180
- const importType = getImportType(file);
181
193
  updateImportState({
182
194
  selectedFile: file,
183
195
  importType
@@ -404,7 +416,7 @@ export const CaseImport = ({
404
416
  <br />
405
417
  <h3 className={styles.instructionsTitle}>Confirmation Import Instructions:</h3>
406
418
  <ul className={styles.instructionsList}>
407
- <li>Only JSON files (.json) with confirmation data exported from Striae are accepted</li>
419
+ <li>Confirmation imports accept either confirmation JSON files or confirmation ZIP packages exported from Striae</li>
408
420
  <li>Only one confirmation file can be imported at a time</li>
409
421
  <li>Confirmed images will become read-only and cannot be modified</li>
410
422
  <li>If an image has a pre-existing confirmation, it will be skipped</li>
@@ -1,4 +1,4 @@
1
- import { CaseImportPreview } from '~/types';
1
+ import { type CaseImportPreview } from '~/types';
2
2
  import styles from '../case-import.module.css';
3
3
 
4
4
  interface CasePreviewSectionProps {
@@ -1,4 +1,4 @@
1
- import { CaseImportPreview } from '~/types';
1
+ import { type CaseImportPreview } from '~/types';
2
2
  import styles from '../case-import.module.css';
3
3
 
4
4
  interface ConfirmationDialogProps {