@striae-org/striae 3.2.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -1
- package/app/components/actions/case-export/core-export.ts +5 -2
- package/app/components/actions/case-export/download-handlers.ts +51 -3
- package/app/components/actions/case-import/confirmation-import.ts +65 -40
- package/app/components/actions/case-import/confirmation-package.ts +86 -0
- package/app/components/actions/case-import/image-operations.ts +20 -49
- package/app/components/actions/case-import/index.ts +1 -0
- package/app/components/actions/case-import/orchestrator.ts +13 -3
- package/app/components/actions/case-import/storage-operations.ts +54 -89
- package/app/components/actions/case-import/validation.ts +7 -111
- package/app/components/actions/case-import/zip-processing.ts +44 -2
- package/app/components/actions/case-manage.ts +15 -27
- package/app/components/actions/confirm-export.ts +44 -13
- package/app/components/actions/generate-pdf.ts +3 -7
- package/app/components/actions/image-manage.ts +63 -129
- package/app/components/button/button.module.css +12 -8
- 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 +13 -60
- 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 +122 -52
- package/app/components/sidebar/cases/cases.module.css +101 -18
- package/app/components/sidebar/notes/notes.module.css +33 -13
- package/app/components/sidebar/sidebar.module.css +0 -2
- package/app/components/user/delete-account.tsx +7 -7
- package/app/components/user/manage-profile.tsx +1 -1
- package/app/components/user/mfa-phone-update.tsx +15 -12
- package/app/config-example/config.json +2 -8
- package/app/hooks/useInactivityTimeout.ts +2 -5
- package/app/root.tsx +96 -65
- package/app/routes/auth/login.tsx +132 -11
- package/app/routes/auth/route.ts +4 -3
- package/app/routes/striae/striae.tsx +4 -8
- package/app/services/audit/audit-api-client.ts +40 -0
- package/app/services/audit/audit-worker-client.ts +14 -17
- package/app/styles/root.module.css +13 -101
- package/app/tailwind.css +9 -2
- package/app/utils/SHA256.ts +5 -1
- package/app/utils/auth.ts +5 -32
- package/app/utils/confirmation-signature.ts +5 -1
- package/app/utils/data-api-client.ts +43 -0
- package/app/utils/data-operations.ts +59 -75
- package/app/utils/export-verification.ts +353 -0
- package/app/utils/image-api-client.ts +130 -0
- package/app/utils/pdf-api-client.ts +43 -0
- package/app/utils/permissions.ts +10 -23
- package/app/utils/signature-utils.ts +74 -4
- package/app/utils/user-api-client.ts +90 -0
- package/functions/api/_shared/firebase-auth.ts +255 -0
- package/functions/api/audit/[[path]].ts +150 -0
- package/functions/api/data/[[path]].ts +141 -0
- package/functions/api/image/[[path]].ts +127 -0
- package/functions/api/pdf/[[path]].ts +110 -0
- package/functions/api/user/[[path]].ts +196 -0
- package/package.json +8 -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/scripts/deploy-all.sh +22 -8
- package/scripts/deploy-config.sh +143 -148
- package/scripts/deploy-pages-secrets.sh +231 -0
- package/scripts/deploy-worker-secrets.sh +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -8
- package/workers/data-worker/wrangler.jsonc.example +1 -8
- package/workers/image-worker/wrangler.jsonc.example +1 -8
- package/workers/keys-worker/wrangler.jsonc.example +2 -9
- package/workers/pdf-worker/scripts/generate-assets.js +94 -0
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -8
- package/workers/user-worker/src/user-worker.example.ts +121 -41
- package/workers/user-worker/wrangler.jsonc.example +1 -8
- package/wrangler.toml.example +1 -1
- package/app/styles/legal-pages.module.css +0 -113
- 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,69 +1,17 @@
|
|
|
1
1
|
import { useState, useEffect, useContext } from 'react';
|
|
2
2
|
import styles from './case-export.module.css';
|
|
3
|
-
import config from '~/config/config.json';
|
|
4
3
|
import { AuthContext } from '~/contexts/auth.context';
|
|
5
4
|
import { PublicSigningKeyModal } from '~/components/public-signing-key-modal/public-signing-key-modal';
|
|
6
|
-
import {
|
|
5
|
+
import { getCurrentPublicSigningKeyDetails } from '~/utils/signature-utils';
|
|
7
6
|
import { getCaseConfirmations, exportConfirmationData } from '../../actions/confirm-export';
|
|
8
7
|
|
|
9
8
|
export type ExportFormat = 'json' | 'csv';
|
|
10
9
|
|
|
11
|
-
type ManifestSigningConfig = {
|
|
12
|
-
manifest_signing_key_id?: string;
|
|
13
|
-
manifest_signing_public_key?: string;
|
|
14
|
-
manifest_signing_public_keys?: Record<string, string>;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
function getPublicSigningKeyDetails(): { keyId: string | null; publicKeyPem: string | null } {
|
|
18
|
-
const signingConfig = config as unknown as ManifestSigningConfig;
|
|
19
|
-
const configuredKeyId =
|
|
20
|
-
typeof signingConfig.manifest_signing_key_id === 'string' &&
|
|
21
|
-
signingConfig.manifest_signing_key_id.trim().length > 0
|
|
22
|
-
? signingConfig.manifest_signing_key_id
|
|
23
|
-
: null;
|
|
24
|
-
|
|
25
|
-
if (configuredKeyId) {
|
|
26
|
-
return {
|
|
27
|
-
keyId: configuredKeyId,
|
|
28
|
-
publicKeyPem: getVerificationPublicKey(configuredKeyId)
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const keyMap = signingConfig.manifest_signing_public_keys;
|
|
33
|
-
if (keyMap && typeof keyMap === 'object') {
|
|
34
|
-
const firstConfiguredEntry = Object.entries(keyMap).find(
|
|
35
|
-
([, value]) => typeof value === 'string' && value.trim().length > 0
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
if (firstConfiguredEntry) {
|
|
39
|
-
return {
|
|
40
|
-
keyId: firstConfiguredEntry[0],
|
|
41
|
-
publicKeyPem: firstConfiguredEntry[1]
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
typeof signingConfig.manifest_signing_public_key === 'string' &&
|
|
48
|
-
signingConfig.manifest_signing_public_key.trim().length > 0
|
|
49
|
-
) {
|
|
50
|
-
return {
|
|
51
|
-
keyId: null,
|
|
52
|
-
publicKeyPem: signingConfig.manifest_signing_public_key
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
keyId: null,
|
|
58
|
-
publicKeyPem: null
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
10
|
interface CaseExportProps {
|
|
63
11
|
isOpen: boolean;
|
|
64
12
|
onClose: () => void;
|
|
65
|
-
onExport: (caseNumber: string, format: ExportFormat, includeImages?: boolean) => void
|
|
66
|
-
onExportAll: (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => void
|
|
13
|
+
onExport: (caseNumber: string, format: ExportFormat, includeImages?: boolean, onProgress?: (progress: number, label: string) => void) => Promise<void>;
|
|
14
|
+
onExportAll: (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => Promise<void>;
|
|
67
15
|
currentCaseNumber?: string;
|
|
68
16
|
isReadOnly?: boolean;
|
|
69
17
|
}
|
|
@@ -82,12 +30,12 @@ export const CaseExport = ({
|
|
|
82
30
|
const [isExportingAll, setIsExportingAll] = useState(false);
|
|
83
31
|
const [isExportingConfirmations, setIsExportingConfirmations] = useState(false);
|
|
84
32
|
const [error, setError] = useState<string>('');
|
|
85
|
-
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; caseName: string } | null>(null);
|
|
33
|
+
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; caseName: string; mode?: 'single' | 'all' } | null>(null);
|
|
86
34
|
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('json');
|
|
87
35
|
const [includeImages, setIncludeImages] = useState(false);
|
|
88
36
|
const [hasConfirmationData, setHasConfirmationData] = useState(false);
|
|
89
37
|
const [isPublicKeyModalOpen, setIsPublicKeyModalOpen] = useState(false);
|
|
90
|
-
const { keyId: publicSigningKeyId, publicKeyPem } =
|
|
38
|
+
const { keyId: publicSigningKeyId, publicKeyPem } = getCurrentPublicSigningKeyDetails();
|
|
91
39
|
|
|
92
40
|
// Update caseNumber when currentCaseNumber prop changes
|
|
93
41
|
useEffect(() => {
|
|
@@ -182,13 +130,16 @@ export const CaseExport = ({
|
|
|
182
130
|
setExportProgress(null);
|
|
183
131
|
|
|
184
132
|
try {
|
|
185
|
-
await onExport(caseNumber.trim(), selectedFormat, includeImages)
|
|
133
|
+
await onExport(caseNumber.trim(), selectedFormat, includeImages, (progress, label) => {
|
|
134
|
+
setExportProgress({ current: progress, total: 100, caseName: label, mode: 'single' });
|
|
135
|
+
});
|
|
186
136
|
onClose();
|
|
187
137
|
} catch (error) {
|
|
188
138
|
console.error('Export failed:', error);
|
|
189
139
|
setError(error instanceof Error ? error.message : 'Export failed. Please try again.');
|
|
190
140
|
} finally {
|
|
191
141
|
setIsExporting(false);
|
|
142
|
+
setExportProgress(null);
|
|
192
143
|
}
|
|
193
144
|
};
|
|
194
145
|
|
|
@@ -367,7 +318,9 @@ export const CaseExport = ({
|
|
|
367
318
|
{exportProgress && exportProgress.total > 0 && (
|
|
368
319
|
<div className={styles.progressSection}>
|
|
369
320
|
<div className={styles.progressText}>
|
|
370
|
-
|
|
321
|
+
{exportProgress.mode === 'single'
|
|
322
|
+
? `${exportProgress.caseName} (${exportProgress.current}%)`
|
|
323
|
+
: `Exporting case ${exportProgress.current} of ${exportProgress.total}: ${exportProgress.caseName}`}
|
|
371
324
|
</div>
|
|
372
325
|
<div className={styles.progressBar}>
|
|
373
326
|
<div
|
|
@@ -378,7 +331,7 @@ export const CaseExport = ({
|
|
|
378
331
|
</div>
|
|
379
332
|
)}
|
|
380
333
|
|
|
381
|
-
{isExportingAll && !exportProgress && (
|
|
334
|
+
{(isExporting || isExportingAll) && !exportProgress && (
|
|
382
335
|
<div className={styles.progressSection}>
|
|
383
336
|
<div className={styles.progressText}>
|
|
384
337
|
Preparing export...
|