@striae-org/striae 3.0.5 → 3.1.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 (84) 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/icon/icons.svg +4 -71
  17. package/app/components/icon/manifest.json +3 -75
  18. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +171 -0
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +223 -0
  20. package/app/components/sidebar/case-export/case-export.module.css +36 -5
  21. package/app/components/sidebar/case-export/case-export.tsx +115 -12
  22. package/app/components/sidebar/case-import/case-import.module.css +9 -5
  23. package/app/components/sidebar/case-import/case-import.tsx +30 -7
  24. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +2 -2
  25. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
  26. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +1 -1
  27. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +34 -9
  28. package/app/components/sidebar/cases/case-sidebar.tsx +13 -13
  29. package/app/components/sidebar/cases/cases-modal.tsx +12 -2
  30. package/app/components/sidebar/files/files-modal.tsx +28 -8
  31. package/app/components/sidebar/sidebar.module.css +2 -3
  32. package/app/components/sidebar/sidebar.tsx +1 -16
  33. package/app/components/sidebar/upload/image-upload-zone.tsx +4 -4
  34. package/app/components/toolbar/toolbar-color-selector.tsx +3 -3
  35. package/app/components/toolbar/toolbar.tsx +19 -9
  36. package/app/components/user/delete-account.module.css +4 -1
  37. package/app/components/user/delete-account.tsx +22 -3
  38. package/app/components/user/manage-profile.tsx +0 -2
  39. package/app/entry.server.tsx +2 -3
  40. package/app/hooks/useInactivityTimeout.ts +5 -1
  41. package/app/routes/_index.tsx +1 -16
  42. package/app/routes/auth/emailVerification.tsx +1 -1
  43. package/app/routes/auth/route.ts +3 -12
  44. package/app/routes/striae/striae.tsx +1 -1
  45. package/app/services/audit-export.service.ts +1 -1
  46. package/app/services/audit.service.ts +29 -9
  47. package/app/tailwind.css +16 -1
  48. package/app/types/audit.ts +3 -3
  49. package/app/types/case.ts +1 -1
  50. package/app/types/import.ts +0 -2
  51. package/app/utils/SHA256.ts +3 -3
  52. package/app/utils/batch-operations.ts +6 -6
  53. package/app/utils/data-operations.ts +14 -7
  54. package/app/utils/permissions.ts +0 -2
  55. package/functions/[[path]].ts +0 -1
  56. package/package.json +5 -3
  57. package/public/assets/striae.jpg +0 -0
  58. package/scripts/deploy-pages.sh +2 -2
  59. package/scripts/run-eslint.cjs +14 -6
  60. package/worker-configuration.d.ts +2 -2
  61. package/workers/audit-worker/src/audit-worker.example.ts +9 -7
  62. package/workers/audit-worker/worker-configuration.d.ts +2 -2
  63. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  64. package/workers/data-worker/src/data-worker.example.ts +1 -1
  65. package/workers/data-worker/worker-configuration.d.ts +2 -2
  66. package/workers/data-worker/wrangler.jsonc.example +1 -1
  67. package/workers/image-worker/worker-configuration.d.ts +2 -2
  68. package/workers/image-worker/wrangler.jsonc.example +1 -1
  69. package/workers/keys-worker/worker-configuration.d.ts +2 -2
  70. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  71. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -3
  72. package/workers/pdf-worker/worker-configuration.d.ts +2 -2
  73. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  74. package/workers/user-worker/src/user-worker.example.ts +10 -10
  75. package/workers/user-worker/worker-configuration.d.ts +2 -2
  76. package/workers/user-worker/wrangler.jsonc.example +1 -1
  77. package/wrangler.toml.example +1 -1
  78. package/app/components/sidebar/hash/hash-utility.module.css +0 -366
  79. package/app/components/sidebar/hash/hash-utility.tsx +0 -982
  80. package/app/routes/mobile-prevented/mobilePrevented.module.css +0 -47
  81. package/app/routes/mobile-prevented/mobilePrevented.tsx +0 -28
  82. package/app/routes/mobile-prevented/route.ts +0 -14
  83. package/app/utils/device-detection.ts +0 -5
  84. package/app/utils/html-sanitizer.ts +0 -80
@@ -0,0 +1,171 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background-color: color-mix(in lab, var(--background) 60%, transparent);
5
+ display: flex;
6
+ justify-content: center;
7
+ align-items: center;
8
+ z-index: var(--zIndex5);
9
+ padding: var(--spaceL);
10
+ }
11
+
12
+ .modal {
13
+ width: 100%;
14
+ max-width: 640px;
15
+ max-height: 90vh;
16
+ background: var(--backgroundLight);
17
+ border-radius: var(--spaceXS);
18
+ display: flex;
19
+ flex-direction: column;
20
+ box-shadow: 0 var(--spaceXS) var(--spaceL)
21
+ color-mix(in lab, var(--black) 18%, transparent);
22
+ overflow: hidden;
23
+ cursor: default;
24
+ }
25
+
26
+ .header {
27
+ display: flex;
28
+ justify-content: space-between;
29
+ align-items: center;
30
+ padding: var(--spaceL);
31
+ border-bottom: 1px solid color-mix(in lab, var(--text) 10%, transparent);
32
+ }
33
+
34
+ .title {
35
+ margin: 0;
36
+ font-size: var(--fontSizeBodyL);
37
+ font-weight: 600;
38
+ color: var(--textTitle);
39
+ }
40
+
41
+ .closeButton {
42
+ background: none;
43
+ border: none;
44
+ font-size: var(--fontSizeH5);
45
+ cursor: pointer;
46
+ padding: var(--spaceS);
47
+ color: var(--textLight);
48
+ transition: color var(--durationS) var(--bezierFastoutSlowin);
49
+ }
50
+
51
+ .closeButton:hover {
52
+ color: var(--text);
53
+ }
54
+
55
+ .content {
56
+ padding: var(--spaceL);
57
+ flex: 1 1 auto;
58
+ min-height: 0;
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: var(--spaceM);
62
+ overflow-y: auto;
63
+ overflow-x: hidden;
64
+ }
65
+
66
+ .description {
67
+ margin: 0;
68
+ font-size: var(--fontSizeBodyS);
69
+ color: var(--textBody);
70
+ }
71
+
72
+ .meta {
73
+ margin: 0;
74
+ font-size: var(--fontSizeBodyS);
75
+ color: var(--textTitle);
76
+ }
77
+
78
+ .meta span {
79
+ font-weight: var(--fontWeightMedium);
80
+ }
81
+
82
+ .label {
83
+ font-size: var(--fontSizeBodyXS);
84
+ font-weight: var(--fontWeightMedium);
85
+ color: var(--textTitle);
86
+ }
87
+
88
+ .field {
89
+ width: 100%;
90
+ max-width: 100%;
91
+ box-sizing: border-box;
92
+ min-height: 180px;
93
+ padding: var(--spaceM);
94
+ border: 1px solid color-mix(in lab, var(--text) 10%, transparent);
95
+ border-radius: var(--spaceXS);
96
+ background: color-mix(in lab, var(--background) 96%, transparent);
97
+ color: var(--textBody);
98
+ font-size: var(--fontSizeBodyXS);
99
+ line-height: 1.4;
100
+ font-family: Consolas, "Courier New", monospace;
101
+ resize: vertical;
102
+ }
103
+
104
+ .howToTitle {
105
+ margin: 0;
106
+ font-size: var(--fontSizeBodyS);
107
+ font-weight: var(--fontWeightMedium);
108
+ color: var(--textTitle);
109
+ }
110
+
111
+ .howToList {
112
+ margin: 0;
113
+ padding-left: var(--spaceL);
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: var(--spaceXS);
117
+ color: var(--textBody);
118
+ font-size: var(--fontSizeBodyS);
119
+ }
120
+
121
+ .actions {
122
+ display: flex;
123
+ justify-content: flex-end;
124
+ gap: var(--spaceS);
125
+ }
126
+
127
+ .status {
128
+ margin: 0;
129
+ font-size: var(--fontSizeBodyXS);
130
+ color: var(--textBody);
131
+ }
132
+
133
+ .copyButton {
134
+ background: transparent;
135
+ color: var(--primary);
136
+ border: 1px solid color-mix(in lab, var(--primary) 35%, transparent);
137
+ border-radius: var(--spaceXS);
138
+ padding: var(--spaceS) var(--spaceL);
139
+ font-size: var(--fontSizeBodyS);
140
+ font-weight: var(--fontWeightMedium);
141
+ cursor: pointer;
142
+ transition: all var(--durationS) var(--bezierFastoutSlowin);
143
+ }
144
+
145
+ .copyButton:hover:not(:disabled) {
146
+ background: color-mix(in lab, var(--primary) 10%, transparent);
147
+ border-color: color-mix(in lab, var(--primary) 55%, transparent);
148
+ }
149
+
150
+ .copyButton:disabled {
151
+ background: color-mix(in lab, var(--background) 95%, transparent);
152
+ color: var(--textLight);
153
+ border-color: color-mix(in lab, var(--text) 10%, transparent);
154
+ cursor: not-allowed;
155
+ }
156
+
157
+ .closeModalButton {
158
+ background: var(--primary);
159
+ color: white;
160
+ border: none;
161
+ border-radius: var(--spaceXS);
162
+ padding: var(--spaceS) var(--spaceL);
163
+ font-size: var(--fontSizeBodyS);
164
+ font-weight: var(--fontWeightMedium);
165
+ cursor: pointer;
166
+ transition: all var(--durationS) var(--bezierFastoutSlowin);
167
+ }
168
+
169
+ .closeModalButton:hover {
170
+ background: color-mix(in lab, var(--primary) 85%, var(--black));
171
+ }
@@ -0,0 +1,223 @@
1
+ import { useEffect, useId, useState, type KeyboardEvent, type MouseEvent } from 'react';
2
+ import styles from './public-signing-key-modal.module.css';
3
+
4
+ const NO_PUBLIC_KEY_MESSAGE = 'No public signing key is configured for this environment.';
5
+
6
+ interface PublicSigningKeyModalProps {
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ publicSigningKeyId?: string | null;
10
+ publicKeyPem?: string | null;
11
+ }
12
+
13
+ export const PublicSigningKeyModal = ({
14
+ isOpen,
15
+ onClose,
16
+ publicSigningKeyId,
17
+ publicKeyPem
18
+ }: PublicSigningKeyModalProps) => {
19
+ const [isCopyingPublicKey, setIsCopyingPublicKey] = useState(false);
20
+ const [publicKeyCopyMessage, setPublicKeyCopyMessage] = useState('');
21
+ const publicSigningKeyTitleId = useId();
22
+ const publicSigningKeyFieldId = useId();
23
+
24
+ useEffect(() => {
25
+ if (!isOpen) {
26
+ setIsCopyingPublicKey(false);
27
+ setPublicKeyCopyMessage('');
28
+ }
29
+ }, [isOpen]);
30
+
31
+ useEffect(() => {
32
+ if (!isOpen) {
33
+ return;
34
+ }
35
+
36
+ const handleEscapeKey = (event: globalThis.KeyboardEvent) => {
37
+ if (event.key === 'Escape') {
38
+ onClose();
39
+ }
40
+ };
41
+
42
+ document.addEventListener('keydown', handleEscapeKey);
43
+
44
+ return () => {
45
+ document.removeEventListener('keydown', handleEscapeKey);
46
+ };
47
+ }, [isOpen, onClose]);
48
+
49
+ if (!isOpen) {
50
+ return null;
51
+ }
52
+
53
+ const handleOverlayMouseDown = (event: MouseEvent<HTMLDivElement>) => {
54
+ if (event.target === event.currentTarget) {
55
+ onClose();
56
+ }
57
+ };
58
+
59
+ const handleOverlayKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
60
+ if (event.target !== event.currentTarget) {
61
+ return;
62
+ }
63
+
64
+ if (event.key === 'Enter' || event.key === ' ') {
65
+ event.preventDefault();
66
+ onClose();
67
+ }
68
+ };
69
+
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();
80
+
81
+ let copied = false;
82
+ try {
83
+ copied = document.execCommand('copy');
84
+ } finally {
85
+ document.body.removeChild(tempTextarea);
86
+ }
87
+
88
+ return copied;
89
+ };
90
+
91
+ const handleCopyPublicKey = async () => {
92
+ if (!publicKeyPem) {
93
+ setPublicKeyCopyMessage(NO_PUBLIC_KEY_MESSAGE);
94
+ return;
95
+ }
96
+
97
+ setIsCopyingPublicKey(true);
98
+ setPublicKeyCopyMessage('');
99
+
100
+ 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
+ }
123
+ } finally {
124
+ setIsCopyingPublicKey(false);
125
+ }
126
+ };
127
+
128
+ return (
129
+ <div
130
+ className={styles.overlay}
131
+ onMouseDown={handleOverlayMouseDown}
132
+ onKeyDown={handleOverlayKeyDown}
133
+ role="button"
134
+ tabIndex={0}
135
+ aria-label="Close public signing key dialog"
136
+ >
137
+ <div
138
+ className={styles.modal}
139
+ role="dialog"
140
+ aria-modal="true"
141
+ aria-labelledby={publicSigningKeyTitleId}
142
+ >
143
+ <div className={styles.header}>
144
+ <h3 id={publicSigningKeyTitleId} className={styles.title}>
145
+ Striae Public Signing Key
146
+ </h3>
147
+ <button
148
+ type="button"
149
+ className={styles.closeButton}
150
+ onClick={onClose}
151
+ aria-label="Close public signing key dialog"
152
+ >
153
+ &times;
154
+ </button>
155
+ </div>
156
+
157
+ <div className={styles.content}>
158
+ <p className={styles.description}>
159
+ This key verifies digital signatures attached to Striae exports. It is safe to share for
160
+ independent verification.
161
+ </p>
162
+
163
+ {publicSigningKeyId && (
164
+ <p className={styles.meta}>
165
+ Key ID: <span>{publicSigningKeyId}</span>
166
+ </p>
167
+ )}
168
+
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>
200
+ )}
201
+
202
+ <div className={styles.actions}>
203
+ <button
204
+ type="button"
205
+ className={styles.copyButton}
206
+ onClick={handleCopyPublicKey}
207
+ disabled={isCopyingPublicKey || !publicKeyPem}
208
+ >
209
+ {isCopyingPublicKey ? 'Copying...' : 'Copy Key'}
210
+ </button>
211
+ <button
212
+ type="button"
213
+ className={styles.closeModalButton}
214
+ onClick={onClose}
215
+ >
216
+ Close
217
+ </button>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ );
223
+ };
@@ -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,7 +16,12 @@
15
16
  width: 90%;
16
17
  max-width: 480px;
17
18
  max-height: 90vh;
18
- box-shadow: 0 var(--spaceXS) var(--spaceL) color-mix(in lab, var(--black) 10%, transparent);
19
+ display: flex;
20
+ flex-direction: column;
21
+ overflow: hidden;
22
+ box-shadow: 0 var(--spaceXS) var(--spaceL)
23
+ color-mix(in lab, var(--black) 10%, transparent);
24
+ cursor: default;
19
25
  transition: background-color var(--durationM) var(--bezierFastoutSlowin);
20
26
  }
21
27
 
@@ -52,8 +58,10 @@
52
58
 
53
59
  .content {
54
60
  padding: var(--spaceL);
61
+ flex: 1 1 auto;
62
+ min-height: 0;
55
63
  overflow-y: auto;
56
- max-height: calc(90vh - var(--space3XL));
64
+ overflow-x: hidden;
57
65
  }
58
66
 
59
67
  .formatSelector {
@@ -154,7 +162,7 @@
154
162
  }
155
163
 
156
164
  .checkbox:checked::after {
157
- content: '';
165
+ content: "";
158
166
  position: absolute;
159
167
  left: 3px;
160
168
  top: 0px;
@@ -191,6 +199,7 @@
191
199
  }
192
200
 
193
201
  .checkboxTooltip {
202
+ display: block;
194
203
  font-size: var(--fontSizeBodyXS);
195
204
  color: var(--textBody);
196
205
  margin: 0;
@@ -286,6 +295,28 @@
286
295
  box-shadow: none;
287
296
  }
288
297
 
298
+ .publicKeySection {
299
+ margin-top: var(--spaceS);
300
+ }
301
+
302
+ .publicKeyButton {
303
+ width: 100%;
304
+ background: transparent;
305
+ color: var(--primary);
306
+ border: 1px solid color-mix(in lab, var(--primary) 35%, transparent);
307
+ border-radius: var(--spaceXS);
308
+ padding: var(--spaceS) var(--spaceM);
309
+ font-size: var(--fontSizeBodyS);
310
+ font-weight: var(--fontWeightMedium);
311
+ cursor: pointer;
312
+ transition: all var(--durationS) var(--bezierFastoutSlowin);
313
+ }
314
+
315
+ .publicKeyButton:hover {
316
+ background: color-mix(in lab, var(--primary) 10%, transparent);
317
+ border-color: color-mix(in lab, var(--primary) 55%, transparent);
318
+ }
319
+
289
320
  .divider {
290
321
  margin: var(--spaceL) 0;
291
322
  text-align: center;
@@ -296,7 +327,7 @@
296
327
  }
297
328
 
298
329
  .divider::before {
299
- content: '';
330
+ content: "";
300
331
  position: absolute;
301
332
  top: 50%;
302
333
  left: 0;
@@ -383,4 +414,4 @@
383
414
  color: var(--error);
384
415
  font-size: var(--fontSizeBodyS);
385
416
  font-weight: var(--fontWeightMedium);
386
- }
417
+ }
@@ -1,10 +1,64 @@
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 { PublicSigningKeyModal } from '~/components/public-signing-key-modal/public-signing-key-modal';
6
+ import { getVerificationPublicKey } from '~/utils/signature-utils';
4
7
  import { getCaseConfirmations, exportConfirmationData } from '../../actions/confirm-export';
5
8
 
6
9
  export type ExportFormat = 'json' | 'csv';
7
10
 
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
+
8
62
  interface CaseExportProps {
9
63
  isOpen: boolean;
10
64
  onClose: () => void;
@@ -32,6 +86,8 @@ export const CaseExport = ({
32
86
  const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('json');
33
87
  const [includeImages, setIncludeImages] = useState(false);
34
88
  const [hasConfirmationData, setHasConfirmationData] = useState(false);
89
+ const [isPublicKeyModalOpen, setIsPublicKeyModalOpen] = useState(false);
90
+ const { keyId: publicSigningKeyId, publicKeyPem } = getPublicSigningKeyDetails();
35
91
 
36
92
  // Update caseNumber when currentCaseNumber prop changes
37
93
  useEffect(() => {
@@ -63,7 +119,7 @@ export const CaseExport = ({
63
119
  };
64
120
 
65
121
  checkConfirmationData();
66
- }, [isReadOnly, user?.uid, caseNumber]);
122
+ }, [isReadOnly, user, caseNumber]);
67
123
 
68
124
  // Additional useEffect to check when modal opens
69
125
  useEffect(() => {
@@ -80,7 +136,7 @@ export const CaseExport = ({
80
136
  };
81
137
  checkOnOpen();
82
138
  }
83
- }, [isOpen, isReadOnly, user?.uid, caseNumber]);
139
+ }, [isOpen, isReadOnly, user, caseNumber]);
84
140
 
85
141
  // Force JSON format and disable images for read-only cases
86
142
  useEffect(() => {
@@ -90,10 +146,16 @@ export const CaseExport = ({
90
146
  }
91
147
  }, [isReadOnly]);
92
148
 
149
+ useEffect(() => {
150
+ if (!isOpen) {
151
+ setIsPublicKeyModalOpen(false);
152
+ }
153
+ }, [isOpen]);
154
+
93
155
  // Handle Escape key to close modal
94
156
  useEffect(() => {
95
157
  const handleEscapeKey = (event: KeyboardEvent) => {
96
- if (event.key === 'Escape' && isOpen) {
158
+ if (event.key === 'Escape' && isOpen && !isPublicKeyModalOpen) {
97
159
  onClose();
98
160
  }
99
161
  };
@@ -105,7 +167,7 @@ export const CaseExport = ({
105
167
  return () => {
106
168
  document.removeEventListener('keydown', handleEscapeKey);
107
169
  };
108
- }, [isOpen, onClose]);
170
+ }, [isOpen, isPublicKeyModalOpen, onClose]);
109
171
 
110
172
  if (!isOpen) return null;
111
173
 
@@ -169,14 +231,32 @@ export const CaseExport = ({
169
231
  }
170
232
  };
171
233
 
172
- const handleOverlayClick = (e: React.MouseEvent) => {
234
+ const handleOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
173
235
  if (e.target === e.currentTarget) {
174
236
  onClose();
175
237
  }
176
238
  };
177
239
 
240
+ const handleOverlayKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
241
+ if (e.target !== e.currentTarget) {
242
+ return;
243
+ }
244
+
245
+ if (e.key === 'Enter' || e.key === ' ') {
246
+ e.preventDefault();
247
+ onClose();
248
+ }
249
+ };
250
+
178
251
  return (
179
- <div className={styles.overlay} onClick={handleOverlayClick}>
252
+ <div
253
+ className={styles.overlay}
254
+ onMouseDown={handleOverlayMouseDown}
255
+ onKeyDown={handleOverlayKeyDown}
256
+ role="button"
257
+ tabIndex={0}
258
+ aria-label="Close case export dialog"
259
+ >
180
260
  <div className={styles.modal}>
181
261
  <div className={styles.header}>
182
262
  <h2 className={styles.title}>Export Case Data</h2>
@@ -234,21 +314,23 @@ export const CaseExport = ({
234
314
 
235
315
  {/* 3. Image inclusion option - disabled for read-only cases */}
236
316
  <div className={styles.imageOption}>
237
- <label className={styles.checkboxLabel}>
317
+ <div className={styles.checkboxLabel}>
238
318
  <input
319
+ id="includeImagesOption"
239
320
  type="checkbox"
240
321
  className={styles.checkbox}
241
322
  checked={includeImages}
242
323
  onChange={(e) => setIncludeImages(e.target.checked)}
243
324
  disabled={!caseNumber.trim() || isExporting || isExportingAll || isReadOnly}
325
+ aria-label="Include images in ZIP export"
244
326
  />
245
- <div className={styles.checkboxText}>
327
+ <label htmlFor="includeImagesOption" className={styles.checkboxText}>
246
328
  <span>Include Images (ZIP)</span>
247
- <p className={styles.checkboxTooltip}>
329
+ <span className={styles.checkboxTooltip}>
248
330
  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>
331
+ </span>
332
+ </label>
333
+ </div>
252
334
  </div>
253
335
 
254
336
  {/* 4. Export buttons (case OR all cases) */}
@@ -281,6 +363,20 @@ export const CaseExport = ({
281
363
  </div>
282
364
  </>
283
365
  )}
366
+
367
+ <div className={styles.divider}>
368
+ <span>Verification</span>
369
+ </div>
370
+
371
+ <div className={styles.publicKeySection}>
372
+ <button
373
+ type="button"
374
+ className={styles.publicKeyButton}
375
+ onClick={() => setIsPublicKeyModalOpen(true)}
376
+ >
377
+ View Public Signing Key
378
+ </button>
379
+ </div>
284
380
 
285
381
  {exportProgress && exportProgress.total > 0 && (
286
382
  <div className={styles.progressSection}>
@@ -312,6 +408,13 @@ export const CaseExport = ({
312
408
  </div>
313
409
  </div>
314
410
  </div>
411
+
412
+ <PublicSigningKeyModal
413
+ isOpen={isPublicKeyModalOpen}
414
+ onClose={() => setIsPublicKeyModalOpen(false)}
415
+ publicSigningKeyId={publicSigningKeyId}
416
+ publicKeyPem={publicKeyPem}
417
+ />
315
418
  </div>
316
419
  );
317
420
  };