@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.
- 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/icon/icons.svg +4 -71
- package/app/components/icon/manifest.json +3 -75
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +171 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +223 -0
- package/app/components/sidebar/case-export/case-export.module.css +36 -5
- package/app/components/sidebar/case-export/case-export.tsx +115 -12
- 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-export.service.ts +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 +5 -3
- package/public/assets/striae.jpg +0 -0
- package/scripts/deploy-pages.sh +2 -2
- 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
|
@@ -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
|
+
×
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
327
|
+
<label htmlFor="includeImagesOption" className={styles.checkboxText}>
|
|
246
328
|
<span>Include Images (ZIP)</span>
|
|
247
|
-
<
|
|
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
|
-
</
|
|
250
|
-
</
|
|
251
|
-
</
|
|
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
|
};
|