@striae-org/striae 3.2.2 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/actions/case-export/download-handlers.ts +51 -3
- package/app/components/actions/case-import/confirmation-import.ts +41 -17
- package/app/components/actions/case-import/confirmation-package.ts +86 -0
- package/app/components/actions/case-import/index.ts +1 -0
- package/app/components/actions/case-import/orchestrator.ts +12 -2
- package/app/components/actions/case-import/validation.ts +5 -98
- package/app/components/actions/case-import/zip-processing.ts +44 -2
- package/app/components/actions/confirm-export.ts +44 -13
- package/app/components/form/form-button.tsx +1 -1
- package/app/components/form/form.module.css +9 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
- package/app/components/sidebar/case-export/case-export.tsx +2 -54
- package/app/components/sidebar/case-import/case-import.tsx +18 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
- package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
- package/app/components/sidebar/cases/case-sidebar.tsx +101 -46
- package/app/components/sidebar/cases/cases.module.css +101 -18
- package/app/components/sidebar/notes/notes.module.css +33 -13
- package/app/components/user/manage-profile.tsx +1 -1
- package/app/components/user/mfa-phone-update.tsx +15 -12
- package/app/root.tsx +2 -2
- package/app/routes/auth/login.tsx +129 -6
- package/app/utils/SHA256.ts +5 -1
- package/app/utils/confirmation-signature.ts +5 -1
- package/app/utils/export-verification.ts +353 -0
- package/app/utils/signature-utils.ts +74 -4
- package/package.json +7 -4
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +39 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/react-router.config.ts +5 -0
- package/workers/pdf-worker/scripts/generate-assets.js +94 -0
- package/public/favicon.svg +0 -9
|
@@ -1,8 +1,213 @@
|
|
|
1
|
-
import {
|
|
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 [
|
|
20
|
-
const [
|
|
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
|
|
231
|
+
const publicKeyInputId = useId();
|
|
232
|
+
const exportFileInputId = useId();
|
|
23
233
|
|
|
24
234
|
useEffect(() => {
|
|
25
235
|
if (!isOpen) {
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
288
|
+
const handlePublicKeySelected = async (file: File) => {
|
|
82
289
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
314
|
+
const handleDownloadCurrentPublicKey = () => {
|
|
92
315
|
if (!publicKeyPem) {
|
|
93
|
-
|
|
316
|
+
setKeyError(NO_PUBLIC_KEY_MESSAGE);
|
|
94
317
|
return;
|
|
95
318
|
}
|
|
96
319
|
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
423
|
+
Current key ID: <span>{publicSigningKeyId}</span>
|
|
166
424
|
</p>
|
|
167
425
|
)}
|
|
168
426
|
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
{
|
|
199
|
-
|
|
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.
|
|
206
|
-
onClick={
|
|
207
|
-
disabled={
|
|
482
|
+
className={styles.primaryButton}
|
|
483
|
+
onClick={handleVerify}
|
|
484
|
+
disabled={isVerifying || !selectedPublicKey || !selectedExportFile}
|
|
208
485
|
>
|
|
209
|
-
{
|
|
486
|
+
{isVerifying ? 'Verifying...' : 'Verify File'}
|
|
210
487
|
</button>
|
|
211
488
|
<button
|
|
212
489
|
type="button"
|
|
213
|
-
className={styles.
|
|
490
|
+
className={styles.secondaryButton}
|
|
214
491
|
onClick={onClose}
|
|
215
492
|
>
|
|
216
493
|
Close
|
|
@@ -1,64 +1,12 @@
|
|
|
1
1
|
import { useState, useEffect, useContext } from 'react';
|
|
2
2
|
import styles from './case-export.module.css';
|
|
3
|
-
import config from '~/config/config.json';
|
|
4
3
|
import { AuthContext } from '~/contexts/auth.context';
|
|
5
4
|
import { PublicSigningKeyModal } from '~/components/public-signing-key-modal/public-signing-key-modal';
|
|
6
|
-
import {
|
|
5
|
+
import { getCurrentPublicSigningKeyDetails } from '~/utils/signature-utils';
|
|
7
6
|
import { getCaseConfirmations, exportConfirmationData } from '../../actions/confirm-export';
|
|
8
7
|
|
|
9
8
|
export type ExportFormat = 'json' | 'csv';
|
|
10
9
|
|
|
11
|
-
type ManifestSigningConfig = {
|
|
12
|
-
manifest_signing_key_id?: string;
|
|
13
|
-
manifest_signing_public_key?: string;
|
|
14
|
-
manifest_signing_public_keys?: Record<string, string>;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
function getPublicSigningKeyDetails(): { keyId: string | null; publicKeyPem: string | null } {
|
|
18
|
-
const signingConfig = config as unknown as ManifestSigningConfig;
|
|
19
|
-
const configuredKeyId =
|
|
20
|
-
typeof signingConfig.manifest_signing_key_id === 'string' &&
|
|
21
|
-
signingConfig.manifest_signing_key_id.trim().length > 0
|
|
22
|
-
? signingConfig.manifest_signing_key_id
|
|
23
|
-
: null;
|
|
24
|
-
|
|
25
|
-
if (configuredKeyId) {
|
|
26
|
-
return {
|
|
27
|
-
keyId: configuredKeyId,
|
|
28
|
-
publicKeyPem: getVerificationPublicKey(configuredKeyId)
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const keyMap = signingConfig.manifest_signing_public_keys;
|
|
33
|
-
if (keyMap && typeof keyMap === 'object') {
|
|
34
|
-
const firstConfiguredEntry = Object.entries(keyMap).find(
|
|
35
|
-
([, value]) => typeof value === 'string' && value.trim().length > 0
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
if (firstConfiguredEntry) {
|
|
39
|
-
return {
|
|
40
|
-
keyId: firstConfiguredEntry[0],
|
|
41
|
-
publicKeyPem: firstConfiguredEntry[1]
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
typeof signingConfig.manifest_signing_public_key === 'string' &&
|
|
48
|
-
signingConfig.manifest_signing_public_key.trim().length > 0
|
|
49
|
-
) {
|
|
50
|
-
return {
|
|
51
|
-
keyId: null,
|
|
52
|
-
publicKeyPem: signingConfig.manifest_signing_public_key
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
keyId: null,
|
|
58
|
-
publicKeyPem: null
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
10
|
interface CaseExportProps {
|
|
63
11
|
isOpen: boolean;
|
|
64
12
|
onClose: () => void;
|
|
@@ -87,7 +35,7 @@ export const CaseExport = ({
|
|
|
87
35
|
const [includeImages, setIncludeImages] = useState(false);
|
|
88
36
|
const [hasConfirmationData, setHasConfirmationData] = useState(false);
|
|
89
37
|
const [isPublicKeyModalOpen, setIsPublicKeyModalOpen] = useState(false);
|
|
90
|
-
const { keyId: publicSigningKeyId, publicKeyPem } =
|
|
38
|
+
const { keyId: publicSigningKeyId, publicKeyPem } = getCurrentPublicSigningKeyDetails();
|
|
91
39
|
|
|
92
40
|
// Update caseNumber when currentCaseNumber prop changes
|
|
93
41
|
useEffect(() => {
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
useFilePreview,
|
|
20
20
|
useImportExecution,
|
|
21
21
|
isValidImportFile,
|
|
22
|
-
|
|
22
|
+
resolveImportType,
|
|
23
23
|
resetFileInput
|
|
24
24
|
} from './index';
|
|
25
25
|
import styles from './case-import.module.css';
|
|
@@ -146,12 +146,18 @@ export const CaseImport = ({
|
|
|
146
146
|
clearMessages();
|
|
147
147
|
|
|
148
148
|
if (!isValidImportFile(file)) {
|
|
149
|
-
setError('Only ZIP files
|
|
149
|
+
setError('Only Striae case ZIP files, confirmation ZIP files, or confirmation JSON files are allowed.');
|
|
150
|
+
clearImportData();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const importType = await resolveImportType(file);
|
|
155
|
+
if (!importType) {
|
|
156
|
+
setError('The selected file is not a supported Striae case or confirmation import package.');
|
|
150
157
|
clearImportData();
|
|
151
158
|
return;
|
|
152
159
|
}
|
|
153
160
|
|
|
154
|
-
const importType = getImportType(file);
|
|
155
161
|
updateImportState({
|
|
156
162
|
selectedFile: file,
|
|
157
163
|
importType
|
|
@@ -172,12 +178,18 @@ export const CaseImport = ({
|
|
|
172
178
|
clearMessages();
|
|
173
179
|
|
|
174
180
|
if (!isValidImportFile(file)) {
|
|
175
|
-
setError('Only ZIP files
|
|
181
|
+
setError('Only Striae case ZIP files, confirmation ZIP files, or confirmation JSON files are allowed.');
|
|
182
|
+
clearImportData();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const importType = await resolveImportType(file);
|
|
187
|
+
if (!importType) {
|
|
188
|
+
setError('The selected file is not a supported Striae case or confirmation import package.');
|
|
176
189
|
clearImportData();
|
|
177
190
|
return;
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
const importType = getImportType(file);
|
|
181
193
|
updateImportState({
|
|
182
194
|
selectedFile: file,
|
|
183
195
|
importType
|
|
@@ -404,7 +416,7 @@ export const CaseImport = ({
|
|
|
404
416
|
<br />
|
|
405
417
|
<h3 className={styles.instructionsTitle}>Confirmation Import Instructions:</h3>
|
|
406
418
|
<ul className={styles.instructionsList}>
|
|
407
|
-
<li>
|
|
419
|
+
<li>Confirmation imports accept either confirmation JSON files or confirmation ZIP packages exported from Striae</li>
|
|
408
420
|
<li>Only one confirmation file can be imported at a time</li>
|
|
409
421
|
<li>Confirmed images will become read-only and cannot be modified</li>
|
|
410
422
|
<li>If an image has a pre-existing confirmation, it will be skipped</li>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react';
|
|
2
2
|
import type { User } from 'firebase/auth';
|
|
3
|
-
import { previewCaseImport } from '~/components/actions/case-review';
|
|
3
|
+
import { previewCaseImport, extractConfirmationImportPackage } from '~/components/actions/case-review';
|
|
4
4
|
import { type CaseImportPreview } from '~/types';
|
|
5
5
|
import { type ConfirmationPreview } from '../components/ConfirmationPreviewSection';
|
|
6
6
|
|
|
@@ -56,8 +56,8 @@ export const useFilePreview = (
|
|
|
56
56
|
|
|
57
57
|
setIsLoadingPreview(true);
|
|
58
58
|
try {
|
|
59
|
-
const
|
|
60
|
-
const parsed =
|
|
59
|
+
const { confirmationData } = await extractConfirmationImportPackage(file);
|
|
60
|
+
const parsed = confirmationData as unknown;
|
|
61
61
|
|
|
62
62
|
if (!isRecord(parsed)) {
|
|
63
63
|
throw new Error('Invalid confirmation data format');
|
|
@@ -104,7 +104,9 @@ export const useFilePreview = (
|
|
|
104
104
|
setConfirmationPreview(preview);
|
|
105
105
|
} catch (error) {
|
|
106
106
|
console.error('Error loading confirmation preview:', error);
|
|
107
|
-
setError(
|
|
107
|
+
setError(
|
|
108
|
+
`Failed to read confirmation data: ${error instanceof Error ? error.message : 'Invalid confirmation package format'}`
|
|
109
|
+
);
|
|
108
110
|
clearImportData();
|
|
109
111
|
} finally {
|
|
110
112
|
setIsLoadingPreview(false);
|