@striae-org/striae 3.0.5 → 3.1.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 (78) hide show
  1. package/app/components/actions/case-export/core-export.ts +1 -1
  2. package/app/components/actions/case-export/download-handlers.ts +10 -12
  3. package/app/components/actions/case-export/metadata-helpers.ts +1 -1
  4. package/app/components/actions/case-import/confirmation-import.ts +24 -9
  5. package/app/components/actions/case-import/orchestrator.ts +3 -4
  6. package/app/components/actions/case-import/validation.ts +3 -3
  7. package/app/components/actions/case-import/zip-processing.ts +12 -48
  8. package/app/components/actions/case-manage.ts +0 -1
  9. package/app/components/actions/confirm-export.ts +2 -2
  10. package/app/components/audit/user-audit-viewer.tsx +53 -15
  11. package/app/components/audit/user-audit.module.css +11 -4
  12. package/app/components/canvas/box-annotations/box-annotations.tsx +36 -7
  13. package/app/components/canvas/canvas.tsx +35 -24
  14. package/app/components/canvas/confirmation/confirmation.module.css +5 -2
  15. package/app/components/canvas/confirmation/confirmation.tsx +25 -8
  16. package/app/components/sidebar/case-export/case-export.module.css +194 -5
  17. package/app/components/sidebar/case-export/case-export.tsx +291 -11
  18. package/app/components/sidebar/case-import/case-import.module.css +9 -5
  19. package/app/components/sidebar/case-import/case-import.tsx +30 -7
  20. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +2 -2
  21. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
  22. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +1 -1
  23. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +34 -9
  24. package/app/components/sidebar/cases/case-sidebar.tsx +13 -13
  25. package/app/components/sidebar/cases/cases-modal.tsx +12 -2
  26. package/app/components/sidebar/files/files-modal.tsx +28 -8
  27. package/app/components/sidebar/sidebar.module.css +2 -3
  28. package/app/components/sidebar/sidebar.tsx +1 -16
  29. package/app/components/sidebar/upload/image-upload-zone.tsx +4 -4
  30. package/app/components/toolbar/toolbar-color-selector.tsx +3 -3
  31. package/app/components/toolbar/toolbar.tsx +19 -9
  32. package/app/components/user/delete-account.module.css +4 -1
  33. package/app/components/user/delete-account.tsx +22 -3
  34. package/app/components/user/manage-profile.tsx +0 -2
  35. package/app/entry.server.tsx +2 -3
  36. package/app/hooks/useInactivityTimeout.ts +5 -1
  37. package/app/routes/_index.tsx +1 -16
  38. package/app/routes/auth/emailVerification.tsx +1 -1
  39. package/app/routes/auth/route.ts +3 -12
  40. package/app/routes/striae/striae.tsx +1 -1
  41. package/app/services/audit.service.ts +29 -9
  42. package/app/tailwind.css +16 -1
  43. package/app/types/audit.ts +3 -3
  44. package/app/types/case.ts +1 -1
  45. package/app/types/import.ts +0 -2
  46. package/app/utils/SHA256.ts +3 -3
  47. package/app/utils/batch-operations.ts +6 -6
  48. package/app/utils/data-operations.ts +14 -7
  49. package/app/utils/permissions.ts +0 -2
  50. package/functions/[[path]].ts +0 -1
  51. package/package.json +1 -2
  52. package/public/assets/striae.jpg +0 -0
  53. package/scripts/run-eslint.cjs +14 -6
  54. package/worker-configuration.d.ts +2 -2
  55. package/workers/audit-worker/src/audit-worker.example.ts +9 -7
  56. package/workers/audit-worker/worker-configuration.d.ts +2 -2
  57. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  58. package/workers/data-worker/src/data-worker.example.ts +1 -1
  59. package/workers/data-worker/worker-configuration.d.ts +2 -2
  60. package/workers/data-worker/wrangler.jsonc.example +1 -1
  61. package/workers/image-worker/worker-configuration.d.ts +2 -2
  62. package/workers/image-worker/wrangler.jsonc.example +1 -1
  63. package/workers/keys-worker/worker-configuration.d.ts +2 -2
  64. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  65. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -3
  66. package/workers/pdf-worker/worker-configuration.d.ts +2 -2
  67. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  68. package/workers/user-worker/src/user-worker.example.ts +10 -10
  69. package/workers/user-worker/worker-configuration.d.ts +2 -2
  70. package/workers/user-worker/wrangler.jsonc.example +1 -1
  71. package/wrangler.toml.example +1 -1
  72. package/app/components/sidebar/hash/hash-utility.module.css +0 -366
  73. package/app/components/sidebar/hash/hash-utility.tsx +0 -982
  74. package/app/routes/mobile-prevented/mobilePrevented.module.css +0 -47
  75. package/app/routes/mobile-prevented/mobilePrevented.tsx +0 -28
  76. package/app/routes/mobile-prevented/route.ts +0 -14
  77. package/app/utils/device-detection.ts +0 -5
  78. package/app/utils/html-sanitizer.ts +0 -80
@@ -1,10 +1,63 @@
1
1
  import { useState, useEffect, useContext } from 'react';
2
2
  import styles from './case-export.module.css';
3
+ import config from '~/config/config.json';
3
4
  import { AuthContext } from '~/contexts/auth.context';
5
+ import { getVerificationPublicKey } from '~/utils/signature-utils';
4
6
  import { getCaseConfirmations, exportConfirmationData } from '../../actions/confirm-export';
5
7
 
6
8
  export type ExportFormat = 'json' | 'csv';
7
9
 
10
+ type ManifestSigningConfig = {
11
+ manifest_signing_key_id?: string;
12
+ manifest_signing_public_key?: string;
13
+ manifest_signing_public_keys?: Record<string, string>;
14
+ };
15
+
16
+ function getPublicSigningKeyDetails(): { keyId: string | null; publicKeyPem: string | null } {
17
+ const signingConfig = config as unknown as ManifestSigningConfig;
18
+ const configuredKeyId =
19
+ typeof signingConfig.manifest_signing_key_id === 'string' &&
20
+ signingConfig.manifest_signing_key_id.trim().length > 0
21
+ ? signingConfig.manifest_signing_key_id
22
+ : null;
23
+
24
+ if (configuredKeyId) {
25
+ return {
26
+ keyId: configuredKeyId,
27
+ publicKeyPem: getVerificationPublicKey(configuredKeyId)
28
+ };
29
+ }
30
+
31
+ const keyMap = signingConfig.manifest_signing_public_keys;
32
+ if (keyMap && typeof keyMap === 'object') {
33
+ const firstConfiguredEntry = Object.entries(keyMap).find(
34
+ ([, value]) => typeof value === 'string' && value.trim().length > 0
35
+ );
36
+
37
+ if (firstConfiguredEntry) {
38
+ return {
39
+ keyId: firstConfiguredEntry[0],
40
+ publicKeyPem: firstConfiguredEntry[1]
41
+ };
42
+ }
43
+ }
44
+
45
+ if (
46
+ typeof signingConfig.manifest_signing_public_key === 'string' &&
47
+ signingConfig.manifest_signing_public_key.trim().length > 0
48
+ ) {
49
+ return {
50
+ keyId: null,
51
+ publicKeyPem: signingConfig.manifest_signing_public_key
52
+ };
53
+ }
54
+
55
+ return {
56
+ keyId: null,
57
+ publicKeyPem: null
58
+ };
59
+ }
60
+
8
61
  interface CaseExportProps {
9
62
  isOpen: boolean;
10
63
  onClose: () => void;
@@ -32,6 +85,10 @@ export const CaseExport = ({
32
85
  const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('json');
33
86
  const [includeImages, setIncludeImages] = useState(false);
34
87
  const [hasConfirmationData, setHasConfirmationData] = useState(false);
88
+ const [isPublicKeyModalOpen, setIsPublicKeyModalOpen] = useState(false);
89
+ const [isCopyingPublicKey, setIsCopyingPublicKey] = useState(false);
90
+ const [publicKeyCopyMessage, setPublicKeyCopyMessage] = useState('');
91
+ const { keyId: publicSigningKeyId, publicKeyPem } = getPublicSigningKeyDetails();
35
92
 
36
93
  // Update caseNumber when currentCaseNumber prop changes
37
94
  useEffect(() => {
@@ -63,7 +120,7 @@ export const CaseExport = ({
63
120
  };
64
121
 
65
122
  checkConfirmationData();
66
- }, [isReadOnly, user?.uid, caseNumber]);
123
+ }, [isReadOnly, user, caseNumber]);
67
124
 
68
125
  // Additional useEffect to check when modal opens
69
126
  useEffect(() => {
@@ -80,7 +137,7 @@ export const CaseExport = ({
80
137
  };
81
138
  checkOnOpen();
82
139
  }
83
- }, [isOpen, isReadOnly, user?.uid, caseNumber]);
140
+ }, [isOpen, isReadOnly, user, caseNumber]);
84
141
 
85
142
  // Force JSON format and disable images for read-only cases
86
143
  useEffect(() => {
@@ -90,10 +147,28 @@ export const CaseExport = ({
90
147
  }
91
148
  }, [isReadOnly]);
92
149
 
150
+ useEffect(() => {
151
+ if (!isOpen) {
152
+ setIsPublicKeyModalOpen(false);
153
+ }
154
+ }, [isOpen]);
155
+
156
+ useEffect(() => {
157
+ if (!isPublicKeyModalOpen) {
158
+ setIsCopyingPublicKey(false);
159
+ setPublicKeyCopyMessage('');
160
+ }
161
+ }, [isPublicKeyModalOpen]);
162
+
93
163
  // Handle Escape key to close modal
94
164
  useEffect(() => {
95
165
  const handleEscapeKey = (event: KeyboardEvent) => {
96
166
  if (event.key === 'Escape' && isOpen) {
167
+ if (isPublicKeyModalOpen) {
168
+ setIsPublicKeyModalOpen(false);
169
+ return;
170
+ }
171
+
97
172
  onClose();
98
173
  }
99
174
  };
@@ -105,7 +180,7 @@ export const CaseExport = ({
105
180
  return () => {
106
181
  document.removeEventListener('keydown', handleEscapeKey);
107
182
  };
108
- }, [isOpen, onClose]);
183
+ }, [isOpen, isPublicKeyModalOpen, onClose]);
109
184
 
110
185
  if (!isOpen) return null;
111
186
 
@@ -169,14 +244,107 @@ export const CaseExport = ({
169
244
  }
170
245
  };
171
246
 
172
- const handleOverlayClick = (e: React.MouseEvent) => {
247
+ const handleOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
173
248
  if (e.target === e.currentTarget) {
174
249
  onClose();
175
250
  }
176
251
  };
177
252
 
253
+ const handleOverlayKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
254
+ if (e.target !== e.currentTarget) {
255
+ return;
256
+ }
257
+
258
+ if (e.key === 'Enter' || e.key === ' ') {
259
+ e.preventDefault();
260
+ onClose();
261
+ }
262
+ };
263
+
264
+ const handlePublicKeyOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
265
+ if (e.target === e.currentTarget) {
266
+ setIsPublicKeyModalOpen(false);
267
+ }
268
+ };
269
+
270
+ const handlePublicKeyOverlayKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
271
+ if (e.target !== e.currentTarget) {
272
+ return;
273
+ }
274
+
275
+ if (e.key === 'Enter' || e.key === ' ') {
276
+ e.preventDefault();
277
+ setIsPublicKeyModalOpen(false);
278
+ }
279
+ };
280
+
281
+ const copyTextWithExecCommand = (text: string): boolean => {
282
+ const tempTextarea = document.createElement('textarea');
283
+ tempTextarea.value = text;
284
+ tempTextarea.setAttribute('readonly', '');
285
+ tempTextarea.style.position = 'fixed';
286
+ tempTextarea.style.opacity = '0';
287
+ tempTextarea.style.pointerEvents = 'none';
288
+
289
+ document.body.appendChild(tempTextarea);
290
+ tempTextarea.select();
291
+
292
+ let copied = false;
293
+ try {
294
+ copied = document.execCommand('copy');
295
+ } finally {
296
+ document.body.removeChild(tempTextarea);
297
+ }
298
+
299
+ return copied;
300
+ };
301
+
302
+ const handleCopyPublicKey = async () => {
303
+ if (!publicKeyPem) {
304
+ setPublicKeyCopyMessage('No public signing key is configured for this environment.');
305
+ return;
306
+ }
307
+
308
+ setIsCopyingPublicKey(true);
309
+ setPublicKeyCopyMessage('');
310
+
311
+ try {
312
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
313
+ await navigator.clipboard.writeText(publicKeyPem);
314
+ setPublicKeyCopyMessage('Public key copied to clipboard.');
315
+ } else {
316
+ const copied = copyTextWithExecCommand(publicKeyPem);
317
+ setPublicKeyCopyMessage(
318
+ copied
319
+ ? 'Public key copied to clipboard.'
320
+ : 'Copy failed. Select and copy the key manually.'
321
+ );
322
+ }
323
+ } catch (copyError) {
324
+ const copied = copyTextWithExecCommand(publicKeyPem);
325
+ setPublicKeyCopyMessage(
326
+ copied
327
+ ? 'Public key copied to clipboard.'
328
+ : 'Copy failed. Select and copy the key manually.'
329
+ );
330
+
331
+ if (!copied) {
332
+ console.error('Failed to copy public signing key:', copyError);
333
+ }
334
+ } finally {
335
+ setIsCopyingPublicKey(false);
336
+ }
337
+ };
338
+
178
339
  return (
179
- <div className={styles.overlay} onClick={handleOverlayClick}>
340
+ <div
341
+ className={styles.overlay}
342
+ onMouseDown={handleOverlayMouseDown}
343
+ onKeyDown={handleOverlayKeyDown}
344
+ role="button"
345
+ tabIndex={0}
346
+ aria-label="Close case export dialog"
347
+ >
180
348
  <div className={styles.modal}>
181
349
  <div className={styles.header}>
182
350
  <h2 className={styles.title}>Export Case Data</h2>
@@ -234,21 +402,23 @@ export const CaseExport = ({
234
402
 
235
403
  {/* 3. Image inclusion option - disabled for read-only cases */}
236
404
  <div className={styles.imageOption}>
237
- <label className={styles.checkboxLabel}>
405
+ <div className={styles.checkboxLabel}>
238
406
  <input
407
+ id="includeImagesOption"
239
408
  type="checkbox"
240
409
  className={styles.checkbox}
241
410
  checked={includeImages}
242
411
  onChange={(e) => setIncludeImages(e.target.checked)}
243
412
  disabled={!caseNumber.trim() || isExporting || isExportingAll || isReadOnly}
413
+ aria-label="Include images in ZIP export"
244
414
  />
245
- <div className={styles.checkboxText}>
415
+ <label htmlFor="includeImagesOption" className={styles.checkboxText}>
246
416
  <span>Include Images (ZIP)</span>
247
- <p className={styles.checkboxTooltip}>
417
+ <span className={styles.checkboxTooltip}>
248
418
  Available for single case exports only. Downloads a ZIP file containing data and all associated image files. Case imports support only JSON data format.
249
- </p>
250
- </div>
251
- </label>
419
+ </span>
420
+ </label>
421
+ </div>
252
422
  </div>
253
423
 
254
424
  {/* 4. Export buttons (case OR all cases) */}
@@ -281,6 +451,20 @@ export const CaseExport = ({
281
451
  </div>
282
452
  </>
283
453
  )}
454
+
455
+ <div className={styles.divider}>
456
+ <span>Verification</span>
457
+ </div>
458
+
459
+ <div className={styles.publicKeySection}>
460
+ <button
461
+ type="button"
462
+ className={styles.publicKeyButton}
463
+ onClick={() => setIsPublicKeyModalOpen(true)}
464
+ >
465
+ View Public Signing Key
466
+ </button>
467
+ </div>
284
468
 
285
469
  {exportProgress && exportProgress.total > 0 && (
286
470
  <div className={styles.progressSection}>
@@ -312,6 +496,102 @@ export const CaseExport = ({
312
496
  </div>
313
497
  </div>
314
498
  </div>
499
+
500
+ {isPublicKeyModalOpen && (
501
+ <div
502
+ className={styles.publicKeyOverlay}
503
+ onMouseDown={handlePublicKeyOverlayMouseDown}
504
+ onKeyDown={handlePublicKeyOverlayKeyDown}
505
+ role="button"
506
+ tabIndex={0}
507
+ aria-label="Close public signing key dialog"
508
+ >
509
+ <div
510
+ className={styles.publicKeyModal}
511
+ role="dialog"
512
+ aria-modal="true"
513
+ aria-labelledby="publicSigningKeyTitle"
514
+ >
515
+ <div className={styles.publicKeyHeader}>
516
+ <h3 id="publicSigningKeyTitle" className={styles.publicKeyTitle}>
517
+ Striae Public Signing Key
518
+ </h3>
519
+ <button
520
+ type="button"
521
+ className={styles.closeButton}
522
+ onClick={() => setIsPublicKeyModalOpen(false)}
523
+ aria-label="Close public signing key dialog"
524
+ >
525
+ ×
526
+ </button>
527
+ </div>
528
+
529
+ <div className={styles.publicKeyContent}>
530
+ <p className={styles.publicKeyDescription}>
531
+ This key verifies digital signatures attached to Striae exports. It is safe to share for
532
+ independent verification.
533
+ </p>
534
+
535
+ {publicSigningKeyId && (
536
+ <p className={styles.publicKeyMeta}>
537
+ Key ID: <span>{publicSigningKeyId}</span>
538
+ </p>
539
+ )}
540
+
541
+ <label htmlFor="publicSigningKey" className={styles.publicKeyLabel}>
542
+ Public signing key (PEM)
543
+ </label>
544
+ <textarea
545
+ id="publicSigningKey"
546
+ className={styles.publicKeyField}
547
+ value={publicKeyPem || 'No public signing key is configured for this environment.'}
548
+ readOnly
549
+ rows={10}
550
+ />
551
+
552
+ <p className={styles.publicKeyHowToTitle}>How to verify Striae exports</p>
553
+ <ol className={styles.publicKeyHowToList}>
554
+ <li>
555
+ Locate signature metadata in the export (for case ZIP exports, see FORENSIC_MANIFEST.json;
556
+ for confirmation exports, see metadata.signature).
557
+ </li>
558
+ <li>
559
+ Use this public key with your signature verification workflow (for example OpenSSL or an
560
+ internal verifier) to validate the signed payload.
561
+ </li>
562
+ <li>
563
+ Trust the export only when signature verification succeeds and the key ID matches the export
564
+ metadata.
565
+ </li>
566
+ </ol>
567
+
568
+ {publicKeyCopyMessage && (
569
+ <p className={styles.publicKeyStatus} role="status" aria-live="polite">
570
+ {publicKeyCopyMessage}
571
+ </p>
572
+ )}
573
+
574
+ <div className={styles.publicKeyActions}>
575
+ <button
576
+ type="button"
577
+ className={styles.publicKeyCopyButton}
578
+ onClick={handleCopyPublicKey}
579
+ disabled={isCopyingPublicKey || !publicKeyPem}
580
+ >
581
+ {isCopyingPublicKey ? 'Copying...' : 'Copy Key'}
582
+ </button>
583
+ <button
584
+ type="button"
585
+ className={styles.publicKeyCloseButton}
586
+ onClick={() => setIsPublicKeyModalOpen(false)}
587
+ >
588
+ Close
589
+ </button>
590
+ </div>
591
+ </div>
592
+ </div>
593
+ </div>
594
+ )}
315
595
  </div>
316
596
  );
317
597
  };
@@ -6,6 +6,7 @@
6
6
  justify-content: center;
7
7
  align-items: center;
8
8
  z-index: var(--zIndex5);
9
+ cursor: default;
9
10
  transition: background-color var(--durationM) var(--bezierFastoutSlowin);
10
11
  }
11
12
 
@@ -15,10 +16,12 @@
15
16
  width: 90%;
16
17
  max-width: 600px;
17
18
  max-height: 95vh;
18
- box-shadow: 0 var(--spaceXS) var(--spaceL) color-mix(in lab, var(--black) 10%, transparent);
19
+ box-shadow: 0 var(--spaceXS) var(--spaceL)
20
+ color-mix(in lab, var(--black) 10%, transparent);
19
21
  transition: background-color var(--durationM) var(--bezierFastoutSlowin);
20
22
  display: flex;
21
23
  flex-direction: column;
24
+ cursor: default;
22
25
  }
23
26
 
24
27
  .header {
@@ -337,7 +340,7 @@
337
340
  box-shadow: 0 1px 3px color-mix(in lab, var(--error) 30%, transparent);
338
341
  }
339
342
 
340
- .cancelButton:hover:not(:disabled) {
343
+ .cancelButton:hover:not(:disabled) {
341
344
  background: color-mix(in lab, var(--error) 80%, white);
342
345
  border-color: color-mix(in lab, var(--error) 60%, transparent);
343
346
  box-shadow: 0 2px 6px color-mix(in lab, var(--error) 40%, transparent);
@@ -525,7 +528,8 @@
525
528
  border-radius: var(--spaceXS);
526
529
  width: 90%;
527
530
  max-width: 400px;
528
- box-shadow: 0 var(--spaceM) var(--spaceXL) color-mix(in lab, var(--black) 20%, transparent);
531
+ box-shadow: 0 var(--spaceM) var(--spaceXL)
532
+ color-mix(in lab, var(--black) 20%, transparent);
529
533
  }
530
534
 
531
535
  .confirmationContent {
@@ -610,7 +614,7 @@
610
614
  transition: all var(--durationS) var(--bezierFastoutSlowin);
611
615
  }
612
616
 
613
- .confirmButton:hover {
617
+ .confirmButton:hover {
614
618
  box-shadow: 0 2px 6px color-mix(in lab, var(--primary) 30%, transparent);
615
619
  }
616
620
 
@@ -623,4 +627,4 @@
623
627
  margin-top: var(--spaceM);
624
628
  font-size: var(--fontSizeBodyS);
625
629
  font-weight: var(--fontWeightMedium);
626
- }
630
+ }
@@ -53,10 +53,9 @@ export const CaseImport = ({
53
53
  const [existingReadOnlyCase, setExistingReadOnlyCase] = useState<string | null>(null);
54
54
  const fileInputRef = useRef<HTMLInputElement>(null);
55
55
 
56
- // Clear import data helper
57
- const clearImportData = useCallback(() => {
56
+ // Clear import selection state (used by preview hook on validation failure)
57
+ const clearImportSelection = useCallback(() => {
58
58
  updateImportState({ selectedFile: null, importType: null });
59
- clearPreviews();
60
59
  resetFileInput(fileInputRef);
61
60
  }, [updateImportState]);
62
61
 
@@ -71,9 +70,15 @@ export const CaseImport = ({
71
70
  user,
72
71
  setError,
73
72
  (loading) => updateImportState({ isLoadingPreview: loading }),
74
- clearImportData
73
+ clearImportSelection
75
74
  );
76
75
 
76
+ // Clear import data helper used by UI actions.
77
+ const clearImportData = useCallback(() => {
78
+ clearImportSelection();
79
+ clearPreviews();
80
+ }, [clearImportSelection, clearPreviews]);
81
+
77
82
  // Import execution hook
78
83
  const { executeImport } = useImportExecution({
79
84
  user,
@@ -212,12 +217,23 @@ export const CaseImport = ({
212
217
  onClose();
213
218
  }, [clearImportData, onClose]);
214
219
 
215
- const handleOverlayClick = useCallback((e: React.MouseEvent) => {
220
+ const handleOverlayMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
216
221
  if (e.target === e.currentTarget && !importState.isImporting && !importState.isClearing) {
217
222
  onClose();
218
223
  }
219
224
  }, [importState.isImporting, importState.isClearing, onClose]);
220
225
 
226
+ const handleOverlayKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
227
+ if (e.target !== e.currentTarget || importState.isImporting || importState.isClearing) {
228
+ return;
229
+ }
230
+
231
+ if (e.key === 'Enter' || e.key === ' ') {
232
+ e.preventDefault();
233
+ onClose();
234
+ }
235
+ }, [importState.isImporting, importState.isClearing, onClose]);
236
+
221
237
  // Effects
222
238
  useEffect(() => {
223
239
  if (user && isOpen) {
@@ -259,8 +275,15 @@ export const CaseImport = ({
259
275
 
260
276
  return (
261
277
  <>
262
- <div className={styles.overlay} onClick={handleOverlayClick}>
263
- <div className={styles.modal}>
278
+ <div
279
+ className={styles.overlay}
280
+ onMouseDown={handleOverlayMouseDown}
281
+ onKeyDown={handleOverlayKeyDown}
282
+ role="button"
283
+ tabIndex={0}
284
+ aria-label="Close case import dialog"
285
+ >
286
+ <div className={styles.modal}>
264
287
  <div className={styles.header}>
265
288
  <h2 className={styles.title}>Import RO Case or Confirmations</h2>
266
289
  <button
@@ -59,9 +59,9 @@ export const CasePreviewSection = ({ casePreview, isLoadingPreview }: CasePrevie
59
59
  <div className={styles.validationItem}>
60
60
  <span className={`${styles.validationValue} ${casePreview.hashValid ? styles.validationSuccess : styles.validationError}`}>
61
61
  {casePreview.hashValid ? (
62
- <>✓ Verified (SHA-256: {casePreview.expectedHash})</>
62
+ <>✓ Validation passed</>
63
63
  ) : (
64
- <>✗ FAILED - {casePreview.hashError}</>
64
+ <>✗ Validation failed</>
65
65
  )}
66
66
  </span>
67
67
  </div>
@@ -17,7 +17,7 @@ export const ConfirmationDialog = ({
17
17
  if (!showConfirmation || !casePreview) return null;
18
18
 
19
19
  return (
20
- <div className={styles.confirmationOverlay} onClick={(e) => e.stopPropagation()}>
20
+ <div className={styles.confirmationOverlay}>
21
21
  <div className={styles.confirmationModal}>
22
22
  <div className={styles.confirmationContent}>
23
23
  <h3 className={styles.confirmationTitle}>Confirm Case Import</h3>
@@ -20,7 +20,7 @@ export const ExistingCaseSection = ({
20
20
  return (
21
21
  <div className={styles.warningSection}>
22
22
  <div className={styles.warningText}>
23
- <strong>Current Review Case:</strong> "{existingReadOnlyCase}"
23
+ <strong>Current Review Case:</strong> &quot;{existingReadOnlyCase}&quot;
24
24
  <p className={styles.warningSubtext}>
25
25
  {selectedFile
26
26
  ? 'Importing a new case will automatically replace the existing one.'
@@ -4,6 +4,11 @@ import { previewCaseImport } from '~/components/actions/case-review';
4
4
  import { CaseImportPreview } from '~/types';
5
5
  import { ConfirmationPreview } from '../components/ConfirmationPreviewSection';
6
6
 
7
+ type UnknownRecord = Record<string, unknown>;
8
+
9
+ const isRecord = (value: unknown): value is UnknownRecord =>
10
+ typeof value === 'object' && value !== null && !Array.isArray(value);
11
+
7
12
  interface UseFilePreviewReturn {
8
13
  casePreview: CaseImportPreview | null;
9
14
  confirmationPreview: ConfirmationPreview | null;
@@ -52,15 +57,22 @@ export const useFilePreview = (
52
57
  setIsLoadingPreview(true);
53
58
  try {
54
59
  const text = await file.text();
55
- const data = JSON.parse(text);
60
+ const parsed = JSON.parse(text) as unknown;
61
+
62
+ if (!isRecord(parsed)) {
63
+ throw new Error('Invalid confirmation data format');
64
+ }
65
+
66
+ const metadata = isRecord(parsed.metadata) ? parsed.metadata : undefined;
67
+ const confirmations = isRecord(parsed.confirmations) ? parsed.confirmations : undefined;
56
68
 
57
69
  // Extract confirmation IDs from the confirmations object
58
70
  const confirmationIds: string[] = [];
59
- if (data.confirmations) {
60
- Object.values(data.confirmations).forEach((imageConfirmations: any) => {
71
+ if (confirmations) {
72
+ Object.values(confirmations).forEach((imageConfirmations) => {
61
73
  if (Array.isArray(imageConfirmations)) {
62
- imageConfirmations.forEach((confirmation: any) => {
63
- if (confirmation.confirmationId) {
74
+ imageConfirmations.forEach((confirmation) => {
75
+ if (isRecord(confirmation) && typeof confirmation.confirmationId === 'string') {
64
76
  confirmationIds.push(confirmation.confirmationId);
65
77
  }
66
78
  });
@@ -68,11 +80,24 @@ export const useFilePreview = (
68
80
  });
69
81
  }
70
82
 
83
+ const caseNumber =
84
+ metadata && typeof metadata.caseNumber === 'string' ? metadata.caseNumber : 'Unknown';
85
+ const fullName =
86
+ metadata && typeof metadata.exportedByName === 'string' ? metadata.exportedByName : 'Unknown';
87
+ const exportDate =
88
+ metadata && typeof metadata.exportDate === 'string'
89
+ ? metadata.exportDate
90
+ : new Date().toISOString();
91
+ const totalConfirmations =
92
+ metadata && typeof metadata.totalConfirmations === 'number'
93
+ ? metadata.totalConfirmations
94
+ : confirmationIds.length;
95
+
71
96
  const preview: ConfirmationPreview = {
72
- caseNumber: data.metadata?.caseNumber || 'Unknown',
73
- fullName: data.metadata?.exportedByName || 'Unknown',
74
- exportDate: data.metadata?.exportDate || new Date().toISOString(),
75
- totalConfirmations: data.metadata?.totalConfirmations || confirmationIds.length,
97
+ caseNumber,
98
+ fullName,
99
+ exportDate,
100
+ totalConfirmations,
76
101
  confirmationIds
77
102
  };
78
103
 
@@ -96,7 +96,7 @@ export const CaseSidebar = ({
96
96
  const [isRenaming, setIsRenaming] = useState(false);
97
97
  const [isLoading, setIsLoading] = useState(false);
98
98
  const [isModalOpen, setIsModalOpen] = useState(false);
99
- const [fileError, setFileError] = useState('');
99
+ const [, setFileError] = useState('');
100
100
  const [newCaseName, setNewCaseName] = useState('');
101
101
  const [showCaseActions, setShowCaseActions] = useState(false);
102
102
  const [canCreateNewCase, setCanCreateNewCase] = useState(true);
@@ -137,13 +137,8 @@ export const CaseSidebar = ({
137
137
  };
138
138
  }, [files]);
139
139
 
140
- // Check user permissions on mount and when user changes
141
- useEffect(() => {
142
- checkUserPermissions();
143
- }, [user]);
144
-
145
140
  // Function to check user permissions (extracted for reuse)
146
- const checkUserPermissions = async () => {
141
+ const checkUserPermissions = useCallback(async () => {
147
142
  setPermissionChecking(true);
148
143
  try {
149
144
  const casePermission = await canCreateCase(user);
@@ -164,10 +159,10 @@ export const CaseSidebar = ({
164
159
  } finally {
165
160
  setPermissionChecking(false);
166
161
  }
167
- };
162
+ }, [user]);
168
163
 
169
164
  // Function to check file upload permissions (extracted for reuse)
170
- const checkFileUploadPermissions = async (fileCount?: number) => {
165
+ const checkFileUploadPermissions = useCallback(async (fileCount?: number) => {
171
166
  if (currentCase) {
172
167
  try {
173
168
  // Use provided fileCount or fall back to current files.length
@@ -184,12 +179,17 @@ export const CaseSidebar = ({
184
179
  setCanUploadNewFile(true);
185
180
  setUploadFileError('');
186
181
  }
187
- };
182
+ }, [currentCase, files.length, user]);
183
+
184
+ // Check user permissions on mount and when user changes
185
+ useEffect(() => {
186
+ checkUserPermissions();
187
+ }, [checkUserPermissions]);
188
188
 
189
189
  // Check file upload permissions when currentCase or files change
190
190
  useEffect(() => {
191
191
  checkFileUploadPermissions();
192
- }, [user, currentCase, files.length]);
192
+ }, [checkFileUploadPermissions]);
193
193
 
194
194
  useEffect(() => {
195
195
  if (currentCase) {
@@ -267,7 +267,7 @@ export const CaseSidebar = ({
267
267
  return () => {
268
268
  isCancelled = true;
269
269
  };
270
- }, [currentCase, fileIdsKey, user, calculateCaseConfirmationStatus]);
270
+ }, [currentCase, fileIdsKey, user, files, calculateCaseConfirmationStatus]);
271
271
 
272
272
  // Refresh only selected file confirmation status after confirmation-related data is persisted
273
273
  useEffect(() => {
@@ -308,7 +308,7 @@ export const CaseSidebar = ({
308
308
  return () => {
309
309
  isCancelled = true;
310
310
  };
311
- }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, calculateCaseConfirmationStatus]);
311
+ }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, files.length, calculateCaseConfirmationStatus]);
312
312
 
313
313
  const handleCase = async () => {
314
314
  setIsLoading(true);