@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.
- package/app/components/actions/case-export/core-export.ts +1 -1
- package/app/components/actions/case-export/download-handlers.ts +10 -12
- package/app/components/actions/case-export/metadata-helpers.ts +1 -1
- package/app/components/actions/case-import/confirmation-import.ts +24 -9
- package/app/components/actions/case-import/orchestrator.ts +3 -4
- package/app/components/actions/case-import/validation.ts +3 -3
- package/app/components/actions/case-import/zip-processing.ts +12 -48
- package/app/components/actions/case-manage.ts +0 -1
- package/app/components/actions/confirm-export.ts +2 -2
- package/app/components/audit/user-audit-viewer.tsx +53 -15
- package/app/components/audit/user-audit.module.css +11 -4
- package/app/components/canvas/box-annotations/box-annotations.tsx +36 -7
- package/app/components/canvas/canvas.tsx +35 -24
- package/app/components/canvas/confirmation/confirmation.module.css +5 -2
- package/app/components/canvas/confirmation/confirmation.tsx +25 -8
- package/app/components/sidebar/case-export/case-export.module.css +194 -5
- package/app/components/sidebar/case-export/case-export.tsx +291 -11
- package/app/components/sidebar/case-import/case-import.module.css +9 -5
- package/app/components/sidebar/case-import/case-import.tsx +30 -7
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +2 -2
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +1 -1
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +34 -9
- package/app/components/sidebar/cases/case-sidebar.tsx +13 -13
- package/app/components/sidebar/cases/cases-modal.tsx +12 -2
- package/app/components/sidebar/files/files-modal.tsx +28 -8
- package/app/components/sidebar/sidebar.module.css +2 -3
- package/app/components/sidebar/sidebar.tsx +1 -16
- package/app/components/sidebar/upload/image-upload-zone.tsx +4 -4
- package/app/components/toolbar/toolbar-color-selector.tsx +3 -3
- package/app/components/toolbar/toolbar.tsx +19 -9
- package/app/components/user/delete-account.module.css +4 -1
- package/app/components/user/delete-account.tsx +22 -3
- package/app/components/user/manage-profile.tsx +0 -2
- package/app/entry.server.tsx +2 -3
- package/app/hooks/useInactivityTimeout.ts +5 -1
- package/app/routes/_index.tsx +1 -16
- package/app/routes/auth/emailVerification.tsx +1 -1
- package/app/routes/auth/route.ts +3 -12
- package/app/routes/striae/striae.tsx +1 -1
- package/app/services/audit.service.ts +29 -9
- package/app/tailwind.css +16 -1
- package/app/types/audit.ts +3 -3
- package/app/types/case.ts +1 -1
- package/app/types/import.ts +0 -2
- package/app/utils/SHA256.ts +3 -3
- package/app/utils/batch-operations.ts +6 -6
- package/app/utils/data-operations.ts +14 -7
- package/app/utils/permissions.ts +0 -2
- package/functions/[[path]].ts +0 -1
- package/package.json +1 -2
- package/public/assets/striae.jpg +0 -0
- package/scripts/run-eslint.cjs +14 -6
- package/worker-configuration.d.ts +2 -2
- package/workers/audit-worker/src/audit-worker.example.ts +9 -7
- package/workers/audit-worker/worker-configuration.d.ts +2 -2
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/data-worker.example.ts +1 -1
- package/workers/data-worker/worker-configuration.d.ts +2 -2
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +2 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/worker-configuration.d.ts +2 -2
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/pdf-worker.example.ts +3 -3
- package/workers/pdf-worker/worker-configuration.d.ts +2 -2
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/user-worker.example.ts +10 -10
- package/workers/user-worker/worker-configuration.d.ts +2 -2
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/sidebar/hash/hash-utility.module.css +0 -366
- package/app/components/sidebar/hash/hash-utility.tsx +0 -982
- package/app/routes/mobile-prevented/mobilePrevented.module.css +0 -47
- package/app/routes/mobile-prevented/mobilePrevented.tsx +0 -28
- package/app/routes/mobile-prevented/route.ts +0 -14
- package/app/utils/device-detection.ts +0 -5
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
415
|
+
<label htmlFor="includeImagesOption" className={styles.checkboxText}>
|
|
246
416
|
<span>Include Images (ZIP)</span>
|
|
247
|
-
<
|
|
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
|
-
</
|
|
250
|
-
</
|
|
251
|
-
</
|
|
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)
|
|
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)
|
|
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
|
|
57
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
263
|
-
|
|
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
|
-
<>✓
|
|
62
|
+
<>✓ Validation passed</>
|
|
63
63
|
) : (
|
|
64
|
-
<>✗
|
|
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}
|
|
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>
|
|
23
|
+
<strong>Current Review Case:</strong> "{existingReadOnlyCase}"
|
|
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
|
|
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 (
|
|
60
|
-
Object.values(
|
|
71
|
+
if (confirmations) {
|
|
72
|
+
Object.values(confirmations).forEach((imageConfirmations) => {
|
|
61
73
|
if (Array.isArray(imageConfirmations)) {
|
|
62
|
-
imageConfirmations.forEach((confirmation
|
|
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
|
|
73
|
-
fullName
|
|
74
|
-
exportDate
|
|
75
|
-
totalConfirmations
|
|
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 [
|
|
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
|
-
}, [
|
|
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);
|