@striae-org/striae 5.4.1 → 5.4.3
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 +1 -1
- package/app/components/actions/case-export/metadata-helpers.ts +2 -4
- package/app/components/actions/case-import/confirmation-import.ts +11 -0
- package/app/components/actions/case-import/orchestrator.ts +1 -0
- package/app/components/actions/case-import/storage-operations.ts +2 -0
- package/app/components/actions/case-import/zip-processing.ts +3 -3
- package/app/components/actions/confirm-export.ts +6 -4
- package/app/components/canvas/confirmation/confirmation.tsx +4 -18
- package/app/components/mobile-warning/mobile-warning.module.css +80 -0
- package/app/components/mobile-warning/mobile-warning.tsx +108 -0
- package/app/components/navbar/case-import/utils/file-validation.ts +1 -1
- package/app/config-example/config.json +2 -2
- package/app/root.tsx +2 -0
- package/app/services/audit/audit-file-type.ts +0 -1
- package/app/services/audit/audit.service.ts +1 -1
- package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -6
- package/app/services/audit/index.ts +0 -1
- package/app/types/audit.ts +1 -1
- package/app/types/case.ts +1 -0
- package/app/types/import.ts +2 -0
- package/app/utils/data/permissions.ts +17 -15
- package/app/utils/forensics/audit-export-signature.ts +4 -4
- package/app/utils/forensics/confirmation-signature.ts +10 -0
- package/app/utils/forensics/export-verification.ts +3 -11
- package/package.json +2 -2
- package/worker-configuration.d.ts +4 -3
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/src/audit-worker.example.ts +1 -1
- package/workers/audit-worker/src/handlers/audit-routes.ts +1 -30
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +17 -17
- package/workers/data-worker/src/encryption-utils.ts +1 -1
- package/workers/data-worker/src/signing-payload-utils.ts +15 -4
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/audit/viewer/use-audit-viewer-export.ts +0 -176
- package/app/services/audit/audit-export-csv.ts +0 -130
- package/app/services/audit/audit-export-report.ts +0 -205
- package/app/services/audit/audit-export.service.ts +0 -333
|
@@ -438,7 +438,7 @@ async function generateJSONContent(
|
|
|
438
438
|
|
|
439
439
|
// Add forensic protection warning if enabled
|
|
440
440
|
if (protectForensicData) {
|
|
441
|
-
return addForensicDataWarning(finalJsonString
|
|
441
|
+
return addForensicDataWarning(finalJsonString);
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
return finalJsonString;
|
|
@@ -32,10 +32,8 @@ export async function getUserExportMetadata(user: User) {
|
|
|
32
32
|
/**
|
|
33
33
|
* Add data protection warning to content
|
|
34
34
|
*/
|
|
35
|
-
export function addForensicDataWarning(content: string
|
|
36
|
-
const warning =
|
|
37
|
-
? `"CASE DATA WARNING: This file contains evidence data for forensic examination. Any modification may compromise the integrity of the evidence. Handle according to your organization's chain of custody procedures."\n\n`
|
|
38
|
-
: `/* CASE DATA WARNING
|
|
35
|
+
export function addForensicDataWarning(content: string): string {
|
|
36
|
+
const warning = `/* CASE DATA WARNING
|
|
39
37
|
* This file contains evidence data for forensic examination.
|
|
40
38
|
* Any modification may compromise the integrity of the evidence.
|
|
41
39
|
* Handle according to your organization's chain of custody procedures.
|
|
@@ -185,6 +185,17 @@ export async function importConfirmationData(
|
|
|
185
185
|
throw new Error('You cannot import confirmation data that you exported yourself.');
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Validate that this confirmation package was intended for the current user.
|
|
189
|
+
// originalCaseOwnerUid is embedded at export time and covered by the package signature.
|
|
190
|
+
if (
|
|
191
|
+
confirmationData.metadata.originalCaseOwnerUid &&
|
|
192
|
+
confirmationData.metadata.originalCaseOwnerUid !== user.uid
|
|
193
|
+
) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
'This confirmation package was not exported for your case. It can only be imported by the original case owner.'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
188
199
|
onProgress?.('Validating case', 50, 'Checking case exists...');
|
|
189
200
|
|
|
190
201
|
// Check if case exists in user's regular cases
|
|
@@ -512,6 +512,7 @@ export async function importCaseForReview(
|
|
|
512
512
|
importedAt: new Date().toISOString(),
|
|
513
513
|
originalExportDate: caseData.metadata.exportDate,
|
|
514
514
|
originalExportedBy: caseData.metadata.exportedBy || 'Unknown',
|
|
515
|
+
originalExportedByUid: caseData.metadata.exportedByUid,
|
|
515
516
|
sourceHash: parsedForensicManifest?.manifestHash,
|
|
516
517
|
sourceManifestVersion: parsedForensicManifest?.manifestVersion,
|
|
517
518
|
sourceSignatureKeyId: parsedForensicManifest?.signature?.keyId,
|
|
@@ -118,6 +118,8 @@ export async function storeCaseDataInR2(
|
|
|
118
118
|
...(bundledAuditTrail && { bundledAuditTrail }),
|
|
119
119
|
// Add original image ID mapping for confirmation linking
|
|
120
120
|
originalImageIds: originalImageIds,
|
|
121
|
+
// Store original case owner UID so confirmation exports can embed the intended recipient
|
|
122
|
+
...(caseData.metadata.exportedByUid && { originalCaseOwnerUid: caseData.metadata.exportedByUid }),
|
|
121
123
|
// Add forensic manifest timestamp if available for confirmation exports
|
|
122
124
|
...(forensicManifest?.createdAt && { forensicManifestCreatedAt: forensicManifest.createdAt }),
|
|
123
125
|
// Store full forensic manifest metadata for chain-of-custody validation
|
|
@@ -281,13 +281,13 @@ export async function parseImportZip(zipFile: File): Promise<{
|
|
|
281
281
|
const zip = await JSZip.loadAsync(zipFile);
|
|
282
282
|
const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
|
|
283
283
|
|
|
284
|
-
// Find the main data file (JSON
|
|
284
|
+
// Find the main data file (JSON)
|
|
285
285
|
const dataFiles = Object.keys(zip.files).filter(name =>
|
|
286
|
-
name.endsWith('_data.json')
|
|
286
|
+
name.endsWith('_data.json')
|
|
287
287
|
);
|
|
288
288
|
|
|
289
289
|
if (dataFiles.length === 0) {
|
|
290
|
-
throw new Error('No valid data file found in ZIP archive');
|
|
290
|
+
throw new Error('No valid JSON data file found in ZIP archive');
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
if (dataFiles.length > 1) {
|
|
@@ -153,7 +153,7 @@ export async function getCaseConfirmations(
|
|
|
153
153
|
export async function getCaseDataWithManifest(
|
|
154
154
|
user: User,
|
|
155
155
|
caseNumber: string
|
|
156
|
-
): Promise<{ confirmations: CaseConfirmations | null; forensicManifestCreatedAt?: string }> {
|
|
156
|
+
): Promise<{ confirmations: CaseConfirmations | null; forensicManifestCreatedAt?: string; originalCaseOwnerUid?: string }> {
|
|
157
157
|
try {
|
|
158
158
|
const caseData = await getCaseData(user, caseNumber) as CaseDataWithConfirmations & { forensicManifestCreatedAt?: string };
|
|
159
159
|
if (!caseData) {
|
|
@@ -163,7 +163,8 @@ export async function getCaseDataWithManifest(
|
|
|
163
163
|
|
|
164
164
|
return {
|
|
165
165
|
confirmations: caseData.confirmations || null,
|
|
166
|
-
forensicManifestCreatedAt: caseData.forensicManifestCreatedAt
|
|
166
|
+
forensicManifestCreatedAt: caseData.forensicManifestCreatedAt,
|
|
167
|
+
originalCaseOwnerUid: caseData.originalCaseOwnerUid
|
|
167
168
|
};
|
|
168
169
|
|
|
169
170
|
} catch (error) {
|
|
@@ -206,7 +207,7 @@ export async function exportConfirmationData(
|
|
|
206
207
|
auditService.startWorkflow(caseNumber);
|
|
207
208
|
|
|
208
209
|
// Get all confirmation data and forensic manifest info for the case
|
|
209
|
-
const { confirmations: caseConfirmations, forensicManifestCreatedAt } = await getCaseDataWithManifest(user, caseNumber);
|
|
210
|
+
const { confirmations: caseConfirmations, forensicManifestCreatedAt, originalCaseOwnerUid } = await getCaseDataWithManifest(user, caseNumber);
|
|
210
211
|
|
|
211
212
|
if (!caseConfirmations || Object.keys(caseConfirmations).length === 0) {
|
|
212
213
|
throw new Error('No confirmation data found for this case');
|
|
@@ -256,7 +257,8 @@ export async function exportConfirmationData(
|
|
|
256
257
|
...userMetadata,
|
|
257
258
|
totalConfirmations: Object.keys(caseConfirmations).length,
|
|
258
259
|
version: '2.0',
|
|
259
|
-
...(originalExportCreatedAt && { originalExportCreatedAt })
|
|
260
|
+
...(originalExportCreatedAt && { originalExportCreatedAt }),
|
|
261
|
+
...(originalCaseOwnerUid && { originalCaseOwnerUid })
|
|
260
262
|
},
|
|
261
263
|
confirmations: caseConfirmations
|
|
262
264
|
};
|
|
@@ -72,11 +72,6 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
|
|
|
72
72
|
if (!isOpen) return null;
|
|
73
73
|
|
|
74
74
|
const handleConfirm = async () => {
|
|
75
|
-
if (!badgeId.trim()) {
|
|
76
|
-
setError('Badge/ID is required');
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
75
|
setIsConfirming(true);
|
|
81
76
|
setError('');
|
|
82
77
|
|
|
@@ -134,19 +129,10 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
|
|
|
134
129
|
</div>
|
|
135
130
|
|
|
136
131
|
<div className={styles.field}>
|
|
137
|
-
<
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
className={styles.input}
|
|
142
|
-
value={badgeId}
|
|
143
|
-
onChange={(e) => {
|
|
144
|
-
setBadgeId(e.target.value);
|
|
145
|
-
if (error) setError('');
|
|
146
|
-
}}
|
|
147
|
-
placeholder="Enter your badge or ID number"
|
|
148
|
-
disabled={isConfirming || hasExistingConfirmation}
|
|
149
|
-
/>
|
|
132
|
+
<span className={styles.label}>Badge/ID:</span>
|
|
133
|
+
<div className={styles.readOnlyValue}>
|
|
134
|
+
{badgeId || 'Not set'}
|
|
135
|
+
</div>
|
|
150
136
|
</div>
|
|
151
137
|
|
|
152
138
|
<div className={styles.field}>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
@layer layout {
|
|
2
|
+
.overlay {
|
|
3
|
+
display: none;
|
|
4
|
+
position: fixed;
|
|
5
|
+
inset: 0;
|
|
6
|
+
z-index: 10000;
|
|
7
|
+
background: var(--background);
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
padding: var(--spaceXL);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.backdrop {
|
|
14
|
+
appearance: none;
|
|
15
|
+
position: absolute;
|
|
16
|
+
inset: 0;
|
|
17
|
+
background: transparent;
|
|
18
|
+
border: none;
|
|
19
|
+
cursor: default;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.content {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
align-items: center;
|
|
26
|
+
text-align: center;
|
|
27
|
+
max-width: 420px;
|
|
28
|
+
gap: var(--spaceL);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.icon {
|
|
32
|
+
color: var(--primary);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.title {
|
|
36
|
+
font-size: var(--fontSizeH4);
|
|
37
|
+
font-weight: var(--fontWeightBold);
|
|
38
|
+
color: var(--textTitle);
|
|
39
|
+
line-height: var(--lineHeightTitle);
|
|
40
|
+
margin: 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.message {
|
|
44
|
+
font-size: var(--fontSizeBodyS);
|
|
45
|
+
color: var(--textBody);
|
|
46
|
+
line-height: var(--lineHeightBody);
|
|
47
|
+
margin: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.dismissButton {
|
|
51
|
+
appearance: none;
|
|
52
|
+
background-color: var(--primary);
|
|
53
|
+
border: 1px solid var(--primary);
|
|
54
|
+
border-radius: var(--radiusM);
|
|
55
|
+
color: var(--white);
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
font-size: var(--fontSizeBodyS);
|
|
58
|
+
font-weight: var(--fontWeightMedium);
|
|
59
|
+
padding: var(--spaceM) var(--spaceXL);
|
|
60
|
+
transition:
|
|
61
|
+
background-color var(--durationS) var(--bezierFastoutSlowin),
|
|
62
|
+
border-color var(--durationS) var(--bezierFastoutSlowin);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.dismissButton:hover {
|
|
66
|
+
background-color: color-mix(in lab, var(--primary) 82%, var(--black));
|
|
67
|
+
border-color: color-mix(in lab, var(--primary) 82%, var(--black));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.dismissButton:focus-visible {
|
|
71
|
+
outline: 3px solid color-mix(in lab, var(--white) 65%, var(--primary));
|
|
72
|
+
outline-offset: 3px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@media (max-width: 1024px) {
|
|
76
|
+
.overlay {
|
|
77
|
+
display: flex;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import styles from './mobile-warning.module.css';
|
|
3
|
+
|
|
4
|
+
const DISMISSED_KEY = 'striae-mobile-warning-dismissed';
|
|
5
|
+
|
|
6
|
+
const isBrowser =
|
|
7
|
+
typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
|
|
8
|
+
|
|
9
|
+
export function MobileWarning() {
|
|
10
|
+
const [dismissed, setDismissed] = useState<boolean>(() => {
|
|
11
|
+
if (!isBrowser) {
|
|
12
|
+
// Match previous server behavior: render null during SSR.
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return window.sessionStorage.getItem(DISMISSED_KEY) === '1';
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!dismissed && buttonRef.current) {
|
|
27
|
+
buttonRef.current.focus();
|
|
28
|
+
}
|
|
29
|
+
}, [dismissed]);
|
|
30
|
+
|
|
31
|
+
const handleDismiss = useCallback(() => {
|
|
32
|
+
if (isBrowser) {
|
|
33
|
+
try {
|
|
34
|
+
window.sessionStorage.setItem(DISMISSED_KEY, '1');
|
|
35
|
+
} catch {
|
|
36
|
+
// Ignore storage errors and still dismiss for this session.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
setDismissed(true);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (dismissed) return;
|
|
44
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
45
|
+
if (e.key === 'Escape') {
|
|
46
|
+
handleDismiss();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
document.addEventListener('keydown', onKeyDown);
|
|
50
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
51
|
+
}, [dismissed, handleDismiss]);
|
|
52
|
+
|
|
53
|
+
if (dismissed) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className={styles.overlay}
|
|
60
|
+
role="dialog"
|
|
61
|
+
aria-modal="true"
|
|
62
|
+
aria-labelledby="mobile-warning-title"
|
|
63
|
+
aria-describedby="mobile-warning-message"
|
|
64
|
+
>
|
|
65
|
+
{/* Backdrop dismiss button covers area outside dialog content */}
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className={styles.backdrop}
|
|
69
|
+
onClick={handleDismiss}
|
|
70
|
+
aria-label="Dismiss"
|
|
71
|
+
tabIndex={-1}
|
|
72
|
+
/>
|
|
73
|
+
<div className={styles.content}>
|
|
74
|
+
<div className={styles.icon}>
|
|
75
|
+
<svg
|
|
76
|
+
width="48"
|
|
77
|
+
height="48"
|
|
78
|
+
viewBox="0 0 24 24"
|
|
79
|
+
fill="none"
|
|
80
|
+
stroke="currentColor"
|
|
81
|
+
strokeWidth="1.5"
|
|
82
|
+
strokeLinecap="round"
|
|
83
|
+
strokeLinejoin="round"
|
|
84
|
+
>
|
|
85
|
+
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
|
|
86
|
+
<line x1="12" y1="18" x2="12.01" y2="18" />
|
|
87
|
+
</svg>
|
|
88
|
+
</div>
|
|
89
|
+
<h2 className={styles.title} id="mobile-warning-title">
|
|
90
|
+
Desktop Experience Only
|
|
91
|
+
</h2>
|
|
92
|
+
<p className={styles.message} id="mobile-warning-message">
|
|
93
|
+
Striae is designed for desktop browsers and is not optimized for
|
|
94
|
+
mobile devices or tablets. For the best experience, please use a
|
|
95
|
+
desktop computer.
|
|
96
|
+
</p>
|
|
97
|
+
<button
|
|
98
|
+
ref={buttonRef}
|
|
99
|
+
type="button"
|
|
100
|
+
className={styles.dismissButton}
|
|
101
|
+
onClick={handleDismiss}
|
|
102
|
+
>
|
|
103
|
+
Continue Anyway
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const CASE_EXPORT_DATA_FILE_REGEX = /_data\.
|
|
1
|
+
const CASE_EXPORT_DATA_FILE_REGEX = /_data\.json$/i;
|
|
2
2
|
const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
|
|
3
3
|
const FORENSIC_MANIFEST_FILE_NAME = 'forensic_manifest.json';
|
|
4
4
|
const ENCRYPTION_MANIFEST_FILE_NAME = 'encryption_manifest.json';
|
package/app/root.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
themeStyles
|
|
14
14
|
} from '~/components/theme-provider/theme-provider';
|
|
15
15
|
import { AuthProvider } from '~/components/auth/auth-provider';
|
|
16
|
+
import { MobileWarning } from '~/components/mobile-warning/mobile-warning';
|
|
16
17
|
import { auth } from '~/services/firebase';
|
|
17
18
|
import styles from '~/styles/root.module.css';
|
|
18
19
|
import './global.css';
|
|
@@ -68,6 +69,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|
|
68
69
|
</head>
|
|
69
70
|
<body className="flex flex-col h-screen w-full overflow-x-hidden">
|
|
70
71
|
<ThemeProvider theme={theme} className="">
|
|
72
|
+
<MobileWarning />
|
|
71
73
|
<main>
|
|
72
74
|
{children}
|
|
73
75
|
</main>
|
|
@@ -4,7 +4,6 @@ export const getAuditFileTypeFromMime = (mimeType: string): AuditFileType => {
|
|
|
4
4
|
if (mimeType.startsWith('image/')) return 'image-file';
|
|
5
5
|
if (mimeType === 'application/pdf') return 'pdf-document';
|
|
6
6
|
if (mimeType === 'application/json') return 'json-data';
|
|
7
|
-
if (mimeType === 'text/csv') return 'csv-export';
|
|
8
7
|
return 'unknown';
|
|
9
8
|
};
|
|
10
9
|
|
|
@@ -208,7 +208,7 @@ class AuditService {
|
|
|
208
208
|
result: AuditResult,
|
|
209
209
|
errors: string[] = [],
|
|
210
210
|
performanceMetrics?: PerformanceMetrics,
|
|
211
|
-
exportFormat?: 'json' | '
|
|
211
|
+
exportFormat?: 'json' | 'zip',
|
|
212
212
|
protectionEnabled?: boolean,
|
|
213
213
|
signatureDetails?: {
|
|
214
214
|
present?: boolean;
|
|
@@ -20,21 +20,18 @@ interface BuildCaseExportAuditParamsInput {
|
|
|
20
20
|
result: AuditResult;
|
|
21
21
|
errors?: string[];
|
|
22
22
|
performanceMetrics?: PerformanceMetrics;
|
|
23
|
-
exportFormat?: 'json' | '
|
|
23
|
+
exportFormat?: 'json' | 'zip';
|
|
24
24
|
signatureDetails?: SignatureDetailsInput;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const resolveCaseExportFileType = (
|
|
28
28
|
fileName: string,
|
|
29
|
-
exportFormat?: 'json' | '
|
|
29
|
+
exportFormat?: 'json' | 'zip'
|
|
30
30
|
): AuditFileType => {
|
|
31
31
|
if (exportFormat) {
|
|
32
32
|
switch (exportFormat) {
|
|
33
33
|
case 'json':
|
|
34
34
|
return 'json-data';
|
|
35
|
-
case 'csv':
|
|
36
|
-
case 'xlsx':
|
|
37
|
-
return 'csv-export';
|
|
38
35
|
case 'zip':
|
|
39
36
|
default:
|
|
40
37
|
return 'case-package';
|
|
@@ -42,7 +39,6 @@ const resolveCaseExportFileType = (
|
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
if (fileName.includes('.json')) return 'json-data';
|
|
45
|
-
if (fileName.includes('.csv') || fileName.includes('.xlsx')) return 'csv-export';
|
|
46
42
|
return 'case-package';
|
|
47
43
|
};
|
|
48
44
|
|
package/app/types/audit.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type AuditResult = 'success' | 'failure' | 'warning' | 'blocked' | 'pendi
|
|
|
25
25
|
|
|
26
26
|
export type AuditFileType =
|
|
27
27
|
| 'case-package' | 'confirmation-data' | 'image-file' | 'pdf-document'
|
|
28
|
-
| 'json-data' | '
|
|
28
|
+
| 'json-data' | 'log-file' | 'unknown';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Core audit entry structure for all validation events
|
package/app/types/case.ts
CHANGED
|
@@ -115,6 +115,7 @@ export interface CaseDataWithConfirmations {
|
|
|
115
115
|
archiveReason?: string;
|
|
116
116
|
importedAt?: string;
|
|
117
117
|
originalImageIds?: { [originalId: string]: string };
|
|
118
|
+
originalCaseOwnerUid?: string;
|
|
118
119
|
confirmations?: CaseConfirmations;
|
|
119
120
|
bundledAuditTrail?: BundledAuditTrailData;
|
|
120
121
|
}
|
package/app/types/import.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface ReadOnlyCaseMetadata {
|
|
|
21
21
|
importedAt: string;
|
|
22
22
|
originalExportDate: string;
|
|
23
23
|
originalExportedBy: string;
|
|
24
|
+
originalExportedByUid?: string;
|
|
24
25
|
sourceHash?: string;
|
|
25
26
|
sourceManifestVersion?: string;
|
|
26
27
|
sourceSignatureKeyId?: string;
|
|
@@ -63,6 +64,7 @@ export interface ConfirmationImportData {
|
|
|
63
64
|
value: string;
|
|
64
65
|
};
|
|
65
66
|
originalExportCreatedAt?: string;
|
|
67
|
+
originalCaseOwnerUid?: string;
|
|
66
68
|
};
|
|
67
69
|
confirmations: {
|
|
68
70
|
[originalImageId: string]: Array<{
|
|
@@ -3,8 +3,8 @@ import type { UserData, ExtendedUserData, UserLimits, ReadOnlyCaseMetadata } fro
|
|
|
3
3
|
import paths from '~/config/config.json';
|
|
4
4
|
import { fetchDataApi, fetchUserApi } from '../api';
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
6
|
+
const MAX_CASES_DEMO = paths.max_cases_demo;
|
|
7
|
+
const MAX_FILES_PER_CASE_DEMO = paths.max_files_per_case_demo;
|
|
8
8
|
|
|
9
9
|
export interface UserUsage {
|
|
10
10
|
currentCases: number;
|
|
@@ -63,8 +63,8 @@ export const getUserLimits = (userData: UserData): UserLimits => {
|
|
|
63
63
|
};
|
|
64
64
|
} else {
|
|
65
65
|
return {
|
|
66
|
-
maxCases:
|
|
67
|
-
maxFilesPerCase:
|
|
66
|
+
maxCases: MAX_CASES_DEMO, // Use config value for demo users
|
|
67
|
+
maxFilesPerCase: MAX_FILES_PER_CASE_DEMO // Use config value for demo users
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
};
|
|
@@ -155,7 +155,7 @@ export const canCreateCase = async (user: User): Promise<{ canCreate: boolean; r
|
|
|
155
155
|
if (usage.currentCases >= limits.maxCases) {
|
|
156
156
|
return {
|
|
157
157
|
canCreate: false,
|
|
158
|
-
reason: `
|
|
158
|
+
reason: `Demo account only: Maximum of ${limits.maxCases} case${limits.maxCases === 1 ? '' : 's'} reached`
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
161
|
|
|
@@ -181,7 +181,7 @@ export const canUploadFile = async (user: User, currentFileCount: number): Promi
|
|
|
181
181
|
if (currentFileCount >= limits.maxFilesPerCase) {
|
|
182
182
|
return {
|
|
183
183
|
canUpload: false,
|
|
184
|
-
reason: `
|
|
184
|
+
reason: `Demo account only: Maximum of ${limits.maxFilesPerCase} file${limits.maxFilesPerCase === 1 ? '' : 's'} per case reached`
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
187
|
|
|
@@ -199,13 +199,13 @@ export const getLimitsDescription = async (user: User): Promise<string> => {
|
|
|
199
199
|
try {
|
|
200
200
|
const userData = await getUserData(user);
|
|
201
201
|
if (!userData) {
|
|
202
|
-
return `Account limits: ${
|
|
202
|
+
return `Account limits: ${MAX_CASES_DEMO} case${MAX_CASES_DEMO === 1 ? '' : 's'}, ${MAX_FILES_PER_CASE_DEMO} file${MAX_FILES_PER_CASE_DEMO === 1 ? '' : 's'} per case`;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
if (userData.permitted) {
|
|
206
206
|
return '';
|
|
207
207
|
} else {
|
|
208
|
-
return `
|
|
208
|
+
return `Demo account only: ${MAX_CASES_DEMO} case${MAX_CASES_DEMO === 1 ? '' : 's'}, ${MAX_FILES_PER_CASE_DEMO} file${MAX_FILES_PER_CASE_DEMO === 1 ? '' : 's'} per case`;
|
|
209
209
|
}
|
|
210
210
|
} catch (error) {
|
|
211
211
|
console.error('Error getting limits description:', error);
|
|
@@ -391,8 +391,9 @@ export const canAccessCase = async (user: User, caseNumber: string): Promise<Per
|
|
|
391
391
|
/**
|
|
392
392
|
* Check if user can modify a specific case
|
|
393
393
|
* - Regular users (permitted=true) can modify their owned cases
|
|
394
|
-
* -
|
|
395
|
-
* -
|
|
394
|
+
* - Demo users (permitted=false) can modify their owned cases
|
|
395
|
+
* - Both permitted and demo users can modify read-only cases for review
|
|
396
|
+
* - Nobody can modify cases marked as archived in the case data itself
|
|
396
397
|
*/
|
|
397
398
|
export const canModifyCase = async (user: User, caseNumber: string): Promise<PermissionResult> => {
|
|
398
399
|
try {
|
|
@@ -419,20 +420,21 @@ export const canModifyCase = async (user: User, caseNumber: string): Promise<Per
|
|
|
419
420
|
if (caseData.archived) {
|
|
420
421
|
return { allowed: false, reason: 'Archived cases are immutable and read-only' };
|
|
421
422
|
}
|
|
423
|
+
} else if (archiveCheckResponse.status !== 404) {
|
|
424
|
+
// Fail closed: if archive status can't be verified (worker error/timeout),
|
|
425
|
+
// block modification rather than risk mutating an archived case
|
|
426
|
+
return { allowed: false, reason: 'Unable to verify case archive status' };
|
|
422
427
|
}
|
|
423
428
|
|
|
424
429
|
// Check if user owns the case (regular cases)
|
|
425
430
|
if (userData.cases && userData.cases.some(c => c.caseNumber === caseNumber)) {
|
|
426
|
-
//
|
|
427
|
-
if (!userData.permitted) {
|
|
428
|
-
return { allowed: false, reason: 'Read-Only Account: Cannot modify owned cases' };
|
|
429
|
-
}
|
|
431
|
+
// Both permitted and demo users can modify their owned cases
|
|
430
432
|
return { allowed: true };
|
|
431
433
|
}
|
|
432
434
|
|
|
433
435
|
// Check if it's a read-only case that user can review
|
|
434
436
|
if (userData.readOnlyCases && userData.readOnlyCases.some(c => c.caseNumber === caseNumber)) {
|
|
435
|
-
// For read-only cases, both permitted and
|
|
437
|
+
// For read-only cases, both permitted and demo users can modify for review
|
|
436
438
|
// The actual read-only restrictions should be enforced at the case data level, not user level
|
|
437
439
|
return { allowed: true };
|
|
438
440
|
}
|
|
@@ -9,8 +9,8 @@ export const AUDIT_EXPORT_SIGNATURE_VERSION = '1.0';
|
|
|
9
9
|
|
|
10
10
|
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/i;
|
|
11
11
|
|
|
12
|
-
export type AuditExportFormat = '
|
|
13
|
-
export type AuditExportType = '
|
|
12
|
+
export type AuditExportFormat = 'json';
|
|
13
|
+
export type AuditExportType = 'trail';
|
|
14
14
|
export type AuditExportScopeType = 'case' | 'user';
|
|
15
15
|
|
|
16
16
|
export interface AuditExportSigningPayload {
|
|
@@ -35,11 +35,11 @@ export function isValidAuditExportSigningPayload(
|
|
|
35
35
|
return false;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
if (payload.exportFormat !== '
|
|
38
|
+
if (payload.exportFormat !== 'json') {
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
if (payload.exportType !== '
|
|
42
|
+
if (payload.exportType !== 'trail') {
|
|
43
43
|
return false;
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -71,6 +71,13 @@ function isValidConfirmationData(candidate: Partial<ConfirmationImportData>): ca
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
if (
|
|
75
|
+
typeof metadata.originalCaseOwnerUid !== 'undefined' &&
|
|
76
|
+
(typeof metadata.originalCaseOwnerUid !== 'string' || metadata.originalCaseOwnerUid.trim().length === 0)
|
|
77
|
+
) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
const confirmations = candidate.confirmations as Record<string, unknown>;
|
|
75
82
|
for (const [imageId, confirmationList] of Object.entries(confirmations)) {
|
|
76
83
|
if (!imageId || !Array.isArray(confirmationList)) {
|
|
@@ -146,6 +153,9 @@ export function createConfirmationSigningPayload(
|
|
|
146
153
|
hash: confirmationData.metadata.hash.toUpperCase(),
|
|
147
154
|
...(confirmationData.metadata.originalExportCreatedAt
|
|
148
155
|
? { originalExportCreatedAt: confirmationData.metadata.originalExportCreatedAt }
|
|
156
|
+
: {}),
|
|
157
|
+
...(confirmationData.metadata.originalCaseOwnerUid
|
|
158
|
+
? { originalCaseOwnerUid: confirmationData.metadata.originalCaseOwnerUid }
|
|
149
159
|
: {})
|
|
150
160
|
},
|
|
151
161
|
confirmations: normalizeConfirmations(confirmationData.confirmations)
|
|
@@ -23,7 +23,7 @@ interface BundledAuditExportFile {
|
|
|
23
23
|
exportVersion?: string;
|
|
24
24
|
totalEntries?: number;
|
|
25
25
|
application?: string;
|
|
26
|
-
exportType?: '
|
|
26
|
+
exportType?: 'trail';
|
|
27
27
|
scopeType?: 'case' | 'user';
|
|
28
28
|
scopeIdentifier?: string;
|
|
29
29
|
hash?: string;
|
|
@@ -164,7 +164,7 @@ async function verifyBundledAuditExport(
|
|
|
164
164
|
const embeddedSignaturePayload: Partial<AuditExportSigningPayload> = metadata.signatureMetadata ?? {
|
|
165
165
|
signatureVersion: metadata.signatureVersion,
|
|
166
166
|
exportFormat: 'json',
|
|
167
|
-
exportType:
|
|
167
|
+
exportType: 'trail',
|
|
168
168
|
scopeType: metadata.scopeType,
|
|
169
169
|
scopeIdentifier: metadata.scopeIdentifier,
|
|
170
170
|
generatedAt: metadata.exportTimestamp,
|
|
@@ -209,23 +209,15 @@ async function verifyBundledAuditExport(
|
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
211
|
* Remove forensic warning from content for hash validation.
|
|
212
|
-
* Supports the warning
|
|
212
|
+
* Supports the warning format added to JSON case exports.
|
|
213
213
|
*/
|
|
214
214
|
export function removeForensicWarning(content: string): string {
|
|
215
215
|
const jsonForensicWarningRegex = /^\/\*\s*CASE\s+DATA\s+WARNING[\s\S]*?\*\/\s*\r?\n*/;
|
|
216
|
-
const csvForensicWarningRegex = /^"CASE DATA WARNING: This file contains evidence data for forensic examination\. Any modification may compromise the integrity of the evidence\. Handle according to your organization's chain of custody procedures\."(?:\r?\n){2}/;
|
|
217
216
|
|
|
218
217
|
let cleaned = content;
|
|
219
218
|
|
|
220
219
|
if (jsonForensicWarningRegex.test(content)) {
|
|
221
220
|
cleaned = content.replace(jsonForensicWarningRegex, '');
|
|
222
|
-
} else if (csvForensicWarningRegex.test(content)) {
|
|
223
|
-
cleaned = content.replace(csvForensicWarningRegex, '');
|
|
224
|
-
} else if (content.startsWith('"CASE DATA WARNING:')) {
|
|
225
|
-
const match = content.match(/^"[^"]*"(?:\r?\n)+/);
|
|
226
|
-
if (match) {
|
|
227
|
-
cleaned = content.substring(match[0].length);
|
|
228
|
-
}
|
|
229
221
|
}
|
|
230
222
|
|
|
231
223
|
return cleaned.replace(/^\s+/, '');
|