@striae-org/striae 3.2.2 → 4.0.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 (82) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-export/download-handlers.ts +51 -3
  4. package/app/components/actions/case-import/confirmation-import.ts +65 -40
  5. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  6. package/app/components/actions/case-import/image-operations.ts +20 -49
  7. package/app/components/actions/case-import/index.ts +1 -0
  8. package/app/components/actions/case-import/orchestrator.ts +13 -3
  9. package/app/components/actions/case-import/storage-operations.ts +54 -89
  10. package/app/components/actions/case-import/validation.ts +7 -111
  11. package/app/components/actions/case-import/zip-processing.ts +44 -2
  12. package/app/components/actions/case-manage.ts +15 -27
  13. package/app/components/actions/confirm-export.ts +44 -13
  14. package/app/components/actions/generate-pdf.ts +3 -7
  15. package/app/components/actions/image-manage.ts +63 -129
  16. package/app/components/button/button.module.css +12 -8
  17. package/app/components/form/form-button.tsx +1 -1
  18. package/app/components/form/form.module.css +9 -0
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  21. package/app/components/sidebar/case-export/case-export.tsx +13 -60
  22. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  23. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  24. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  25. package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
  26. package/app/components/sidebar/cases/cases.module.css +101 -18
  27. package/app/components/sidebar/notes/notes.module.css +33 -13
  28. package/app/components/sidebar/sidebar.module.css +0 -2
  29. package/app/components/user/delete-account.tsx +7 -7
  30. package/app/components/user/manage-profile.tsx +1 -1
  31. package/app/components/user/mfa-phone-update.tsx +15 -12
  32. package/app/config-example/config.json +2 -8
  33. package/app/hooks/useInactivityTimeout.ts +2 -5
  34. package/app/root.tsx +96 -65
  35. package/app/routes/auth/login.tsx +132 -11
  36. package/app/routes/auth/route.ts +4 -3
  37. package/app/routes/striae/striae.tsx +4 -8
  38. package/app/services/audit/audit-api-client.ts +40 -0
  39. package/app/services/audit/audit-worker-client.ts +14 -17
  40. package/app/styles/root.module.css +13 -101
  41. package/app/tailwind.css +9 -2
  42. package/app/utils/SHA256.ts +5 -1
  43. package/app/utils/auth.ts +5 -32
  44. package/app/utils/confirmation-signature.ts +5 -1
  45. package/app/utils/data-api-client.ts +43 -0
  46. package/app/utils/data-operations.ts +59 -75
  47. package/app/utils/export-verification.ts +353 -0
  48. package/app/utils/image-api-client.ts +130 -0
  49. package/app/utils/pdf-api-client.ts +43 -0
  50. package/app/utils/permissions.ts +10 -23
  51. package/app/utils/signature-utils.ts +74 -4
  52. package/app/utils/user-api-client.ts +90 -0
  53. package/functions/api/_shared/firebase-auth.ts +255 -0
  54. package/functions/api/audit/[[path]].ts +150 -0
  55. package/functions/api/data/[[path]].ts +141 -0
  56. package/functions/api/image/[[path]].ts +127 -0
  57. package/functions/api/pdf/[[path]].ts +110 -0
  58. package/functions/api/user/[[path]].ts +196 -0
  59. package/package.json +8 -4
  60. package/public/favicon.ico +0 -0
  61. package/public/icon-256.png +0 -0
  62. package/public/icon-512.png +0 -0
  63. package/public/manifest.json +39 -0
  64. package/public/shortcut.png +0 -0
  65. package/public/social-image.png +0 -0
  66. package/react-router.config.ts +5 -0
  67. package/scripts/deploy-all.sh +22 -8
  68. package/scripts/deploy-config.sh +143 -148
  69. package/scripts/deploy-pages-secrets.sh +231 -0
  70. package/scripts/deploy-worker-secrets.sh +1 -1
  71. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  72. package/workers/data-worker/wrangler.jsonc.example +1 -8
  73. package/workers/image-worker/wrangler.jsonc.example +1 -8
  74. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  75. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  76. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  77. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  78. package/workers/user-worker/src/user-worker.example.ts +121 -41
  79. package/workers/user-worker/wrangler.jsonc.example +1 -8
  80. package/wrangler.toml.example +1 -1
  81. package/app/styles/legal-pages.module.css +0 -113
  82. package/public/favicon.svg +0 -9
@@ -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,69 +1,17 @@
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;
65
- onExport: (caseNumber: string, format: ExportFormat, includeImages?: boolean) => void;
66
- onExportAll: (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => void;
13
+ onExport: (caseNumber: string, format: ExportFormat, includeImages?: boolean, onProgress?: (progress: number, label: string) => void) => Promise<void>;
14
+ onExportAll: (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => Promise<void>;
67
15
  currentCaseNumber?: string;
68
16
  isReadOnly?: boolean;
69
17
  }
@@ -82,12 +30,12 @@ export const CaseExport = ({
82
30
  const [isExportingAll, setIsExportingAll] = useState(false);
83
31
  const [isExportingConfirmations, setIsExportingConfirmations] = useState(false);
84
32
  const [error, setError] = useState<string>('');
85
- const [exportProgress, setExportProgress] = useState<{ current: number; total: number; caseName: string } | null>(null);
33
+ const [exportProgress, setExportProgress] = useState<{ current: number; total: number; caseName: string; mode?: 'single' | 'all' } | null>(null);
86
34
  const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('json');
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(() => {
@@ -182,13 +130,16 @@ export const CaseExport = ({
182
130
  setExportProgress(null);
183
131
 
184
132
  try {
185
- await onExport(caseNumber.trim(), selectedFormat, includeImages);
133
+ await onExport(caseNumber.trim(), selectedFormat, includeImages, (progress, label) => {
134
+ setExportProgress({ current: progress, total: 100, caseName: label, mode: 'single' });
135
+ });
186
136
  onClose();
187
137
  } catch (error) {
188
138
  console.error('Export failed:', error);
189
139
  setError(error instanceof Error ? error.message : 'Export failed. Please try again.');
190
140
  } finally {
191
141
  setIsExporting(false);
142
+ setExportProgress(null);
192
143
  }
193
144
  };
194
145
 
@@ -367,7 +318,9 @@ export const CaseExport = ({
367
318
  {exportProgress && exportProgress.total > 0 && (
368
319
  <div className={styles.progressSection}>
369
320
  <div className={styles.progressText}>
370
- Exporting case {exportProgress.current} of {exportProgress.total}: {exportProgress.caseName}
321
+ {exportProgress.mode === 'single'
322
+ ? `${exportProgress.caseName} (${exportProgress.current}%)`
323
+ : `Exporting case ${exportProgress.current} of ${exportProgress.total}: ${exportProgress.caseName}`}
371
324
  </div>
372
325
  <div className={styles.progressBar}>
373
326
  <div
@@ -378,7 +331,7 @@ export const CaseExport = ({
378
331
  </div>
379
332
  )}
380
333
 
381
- {isExportingAll && !exportProgress && (
334
+ {(isExporting || isExportingAll) && !exportProgress && (
382
335
  <div className={styles.progressSection}>
383
336
  <div className={styles.progressText}>
384
337
  Preparing export...