@striae-org/striae 4.3.0 → 4.3.2
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-import/confirmation-import.ts +20 -1
- package/app/components/actions/case-import/orchestrator.ts +3 -0
- package/app/components/actions/case-manage.ts +5 -1
- package/app/components/actions/confirm-export.ts +12 -3
- package/app/components/audit/viewer/audit-entries-list.tsx +20 -2
- package/app/components/audit/viewer/use-audit-viewer-export.ts +2 -2
- package/app/components/audit/viewer/use-audit-viewer-filters.ts +15 -2
- package/app/components/canvas/canvas.tsx +2 -1
- package/app/components/navbar/navbar.module.css +11 -0
- package/app/components/navbar/navbar.tsx +38 -19
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +10 -32
- package/app/components/sidebar/cases/cases.module.css +3 -3
- package/app/components/sidebar/sidebar-container.tsx +1 -0
- package/app/components/sidebar/sidebar.tsx +3 -7
- package/app/routes/striae/striae.tsx +47 -1
- package/app/services/audit/audit-export-csv.ts +4 -2
- package/app/services/audit/audit-export-report.ts +35 -4
- package/app/services/audit/audit.service.ts +2 -0
- package/app/services/audit/builders/audit-entry-builder.ts +1 -0
- package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -2
- package/app/types/audit.ts +1 -0
- package/app/utils/forensics/confirmation-signature.ts +20 -5
- package/package.json +1 -1
- package/workers/data-worker/src/signing-payload-utils.ts +5 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
2
|
import { fetchDataApi } from '~/utils/api';
|
|
3
|
-
import {
|
|
3
|
+
import { upsertFileConfirmationSummary } from '~/utils/data';
|
|
4
|
+
import { type AnnotationData, type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
|
|
4
5
|
import { checkExistingCase } from '../case-manage';
|
|
5
6
|
import { extractConfirmationImportPackage } from './confirmation-package';
|
|
6
7
|
import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
|
|
@@ -37,6 +38,7 @@ export async function importConfirmationData(
|
|
|
37
38
|
let signatureKeyId: string | undefined;
|
|
38
39
|
let confirmationDataForAudit: ConfirmationImportData | null = null;
|
|
39
40
|
let confirmationJsonFileNameForAudit = confirmationFile.name;
|
|
41
|
+
const confirmedFileNames = new Set<string>();
|
|
40
42
|
|
|
41
43
|
const result: ConfirmationImportResult = {
|
|
42
44
|
success: false,
|
|
@@ -234,6 +236,21 @@ export async function importConfirmationData(
|
|
|
234
236
|
if (saveResponse.ok) {
|
|
235
237
|
result.imagesUpdated++;
|
|
236
238
|
result.confirmationsImported += confirmations.length;
|
|
239
|
+
confirmedFileNames.add(displayFilename);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
await upsertFileConfirmationSummary(
|
|
243
|
+
user,
|
|
244
|
+
result.caseNumber,
|
|
245
|
+
currentImageId,
|
|
246
|
+
updatedAnnotationData as AnnotationData
|
|
247
|
+
);
|
|
248
|
+
} catch (summaryError) {
|
|
249
|
+
console.warn(
|
|
250
|
+
`Failed to update confirmation summary for imported confirmation ${result.caseNumber}/${currentImageId}:`,
|
|
251
|
+
summaryError
|
|
252
|
+
);
|
|
253
|
+
}
|
|
237
254
|
|
|
238
255
|
// Audit log successful confirmation import
|
|
239
256
|
try {
|
|
@@ -298,6 +315,7 @@ export async function importConfirmationData(
|
|
|
298
315
|
result.success ? (result.errors && result.errors.length > 0 ? 'warning' : 'success') : 'failure',
|
|
299
316
|
hashValid,
|
|
300
317
|
result.confirmationsImported, // Successfully imported confirmations
|
|
318
|
+
Array.from(confirmedFileNames).sort((left, right) => left.localeCompare(right)),
|
|
301
319
|
result.errors || [],
|
|
302
320
|
confirmationData.metadata.exportedByUid,
|
|
303
321
|
{
|
|
@@ -390,6 +408,7 @@ export async function importConfirmationData(
|
|
|
390
408
|
'failure',
|
|
391
409
|
hashValidForAudit,
|
|
392
410
|
0, // No confirmations successfully imported for failures
|
|
411
|
+
[],
|
|
393
412
|
result.errors || [],
|
|
394
413
|
reviewingExaminerUidForAudit,
|
|
395
414
|
{
|
|
@@ -286,6 +286,9 @@ export async function importCaseForReview(
|
|
|
286
286
|
if (existingRegularCase && !isArchivedExport) {
|
|
287
287
|
throw new Error(`Case "${result.caseNumber}" already exists in your case list. You cannot import a case for review if you were the original analyst.`);
|
|
288
288
|
}
|
|
289
|
+
if (existingRegularCase && isArchivedExport) {
|
|
290
|
+
throw new Error(`Cannot import this archive because case "${result.caseNumber}" already exists in your case list (active or archived). To import this archive, the existing case must first be deleted.`);
|
|
291
|
+
}
|
|
289
292
|
|
|
290
293
|
// Step 2b: Check if read-only case already exists
|
|
291
294
|
const existingCase = await checkReadOnlyCaseExists(user, result.caseNumber);
|
|
@@ -807,7 +807,11 @@ export const archiveCase = async (
|
|
|
807
807
|
)
|
|
808
808
|
);
|
|
809
809
|
|
|
810
|
-
const auditEntries = await auditService.getAuditEntriesForUser(user.uid, {
|
|
810
|
+
const auditEntries = await auditService.getAuditEntriesForUser(user.uid, {
|
|
811
|
+
caseNumber,
|
|
812
|
+
startDate: caseData.createdAt,
|
|
813
|
+
endDate: archivedAt,
|
|
814
|
+
});
|
|
811
815
|
const auditTrail: AuditTrail = {
|
|
812
816
|
caseNumber,
|
|
813
817
|
workflowId: `${caseNumber}-archive-${Date.now()}`,
|
|
@@ -5,8 +5,8 @@ import {
|
|
|
5
5
|
getCurrentPublicSigningKeyDetails,
|
|
6
6
|
getVerificationPublicKey
|
|
7
7
|
} from '~/utils/forensics';
|
|
8
|
-
import { getUserData, getCaseData, updateCaseData, signConfirmationData } from '~/utils/data';
|
|
9
|
-
import { type ConfirmationData, type CaseConfirmations, type CaseDataWithConfirmations, type ConfirmationImportData } from '~/types';
|
|
8
|
+
import { getUserData, getCaseData, updateCaseData, signConfirmationData, upsertFileConfirmationSummary } from '~/utils/data';
|
|
9
|
+
import { type AnnotationData, type ConfirmationData, type CaseConfirmations, type CaseDataWithConfirmations, type ConfirmationImportData } from '~/types';
|
|
10
10
|
import { auditService } from '~/services/audit';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -17,7 +17,8 @@ export async function storeConfirmation(
|
|
|
17
17
|
caseNumber: string,
|
|
18
18
|
currentImageId: string,
|
|
19
19
|
confirmationData: ConfirmationData,
|
|
20
|
-
originalImageFileName?: string
|
|
20
|
+
originalImageFileName?: string,
|
|
21
|
+
annotationDataForSummary?: AnnotationData
|
|
21
22
|
): Promise<boolean> {
|
|
22
23
|
const startTime = Date.now();
|
|
23
24
|
let originalImageId: string | undefined; // Declare at function level for error handling
|
|
@@ -63,6 +64,14 @@ export async function storeConfirmation(
|
|
|
63
64
|
// Store the updated case data using centralized function
|
|
64
65
|
await updateCaseData(user, caseNumber, caseData);
|
|
65
66
|
|
|
67
|
+
if (annotationDataForSummary) {
|
|
68
|
+
try {
|
|
69
|
+
await upsertFileConfirmationSummary(user, caseNumber, currentImageId, annotationDataForSummary);
|
|
70
|
+
} catch (summaryError) {
|
|
71
|
+
console.warn(`Failed to update confirmation summary for ${caseNumber}/${currentImageId}:`, summaryError);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
console.log(`Confirmation stored for original image ${originalImageId}:`, confirmationData);
|
|
67
76
|
|
|
68
77
|
// Log successful confirmation creation
|
|
@@ -6,6 +6,13 @@ interface AuditEntriesListProps {
|
|
|
6
6
|
entries: ValidationAuditEntry[];
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
const isConfirmationImportEntry = (entry: ValidationAuditEntry): boolean => {
|
|
10
|
+
return (
|
|
11
|
+
entry.action === 'confirmation-import' ||
|
|
12
|
+
(entry.action === 'import' && entry.details.workflowPhase === 'confirmation')
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
9
16
|
export const AuditEntriesList = ({ entries }: AuditEntriesListProps) => {
|
|
10
17
|
return (
|
|
11
18
|
<div className={styles.entriesList}>
|
|
@@ -47,13 +54,24 @@ export const AuditEntriesList = ({ entries }: AuditEntriesListProps) => {
|
|
|
47
54
|
</div>
|
|
48
55
|
)}
|
|
49
56
|
|
|
50
|
-
{entry
|
|
57
|
+
{isConfirmationImportEntry(entry) && entry.details.reviewerBadgeId && (
|
|
51
58
|
<div className={styles.detailRow}>
|
|
52
|
-
<span className={styles.detailLabel}>
|
|
59
|
+
<span className={styles.detailLabel}>Confirming Examiner Badge/ID:</span>
|
|
53
60
|
<span className={styles.badgeTag}>{entry.details.reviewerBadgeId}</span>
|
|
54
61
|
</div>
|
|
55
62
|
)}
|
|
56
63
|
|
|
64
|
+
{isConfirmationImportEntry(entry) &&
|
|
65
|
+
entry.details.caseDetails?.confirmedFileNames &&
|
|
66
|
+
entry.details.caseDetails.confirmedFileNames.length > 0 && (
|
|
67
|
+
<div className={styles.detailRow}>
|
|
68
|
+
<span className={styles.detailLabel}>Confirmed Files:</span>
|
|
69
|
+
<span className={styles.detailValue}>
|
|
70
|
+
{entry.details.caseDetails.confirmedFileNames.join(', ')}
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
|
|
57
75
|
{entry.result === 'failure' && entry.details.validationErrors.length > 0 && (
|
|
58
76
|
<div className={styles.detailRow}>
|
|
59
77
|
<span className={styles.detailLabel}>Error:</span>
|
|
@@ -48,7 +48,7 @@ export const useAuditViewerExport = ({
|
|
|
48
48
|
const filename = auditExportService.generateFilename(
|
|
49
49
|
exportContextData.scopeType,
|
|
50
50
|
exportContextData.identifier,
|
|
51
|
-
'
|
|
51
|
+
'csv'
|
|
52
52
|
);
|
|
53
53
|
|
|
54
54
|
try {
|
|
@@ -72,7 +72,7 @@ export const useAuditViewerExport = ({
|
|
|
72
72
|
const filename = auditExportService.generateFilename(
|
|
73
73
|
exportContextData.scopeType,
|
|
74
74
|
exportContextData.identifier,
|
|
75
|
-
'
|
|
75
|
+
'json'
|
|
76
76
|
);
|
|
77
77
|
|
|
78
78
|
try {
|
|
@@ -2,6 +2,13 @@ import { useCallback, useMemo, useState } from 'react';
|
|
|
2
2
|
import type { AuditAction, AuditResult, ValidationAuditEntry } from '~/types';
|
|
3
3
|
import type { DateRangeFilter } from './types';
|
|
4
4
|
|
|
5
|
+
const isConfirmationImportEntry = (entry: ValidationAuditEntry): boolean => {
|
|
6
|
+
return (
|
|
7
|
+
entry.action === 'confirmation-import' ||
|
|
8
|
+
(entry.action === 'import' && entry.details.workflowPhase === 'confirmation')
|
|
9
|
+
);
|
|
10
|
+
};
|
|
11
|
+
|
|
5
12
|
export const useAuditViewerFilters = (caseNumber?: string) => {
|
|
6
13
|
const [filterAction, setFilterAction] = useState<AuditAction | 'all'>('all');
|
|
7
14
|
const [filterResult, setFilterResult] = useState<AuditResult | 'all'>('all');
|
|
@@ -69,14 +76,20 @@ export const useAuditViewerFilters = (caseNumber?: string) => {
|
|
|
69
76
|
} else if (filterAction === 'confirmation-export') {
|
|
70
77
|
actionMatch = entry.action === 'export' && entry.details.workflowPhase === 'confirmation';
|
|
71
78
|
} else if (filterAction === 'confirmation-import') {
|
|
72
|
-
actionMatch = entry
|
|
79
|
+
actionMatch = isConfirmationImportEntry(entry);
|
|
73
80
|
} else {
|
|
74
81
|
actionMatch = entry.action === filterAction;
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
const resultMatch = filterResult === 'all' || entry.result === filterResult;
|
|
78
85
|
const entryBadgeId = entry.details.userProfileDetails?.badgeId?.trim().toLowerCase() || '';
|
|
79
|
-
const
|
|
86
|
+
const reviewerBadgeId = isConfirmationImportEntry(entry)
|
|
87
|
+
? entry.details.reviewerBadgeId?.trim().toLowerCase() || ''
|
|
88
|
+
: '';
|
|
89
|
+
const badgeMatch =
|
|
90
|
+
normalizedBadgeFilter === '' ||
|
|
91
|
+
entryBadgeId.includes(normalizedBadgeFilter) ||
|
|
92
|
+
reviewerBadgeId.includes(normalizedBadgeFilter);
|
|
80
93
|
|
|
81
94
|
return actionMatch && resultMatch && badgeMatch;
|
|
82
95
|
});
|
|
@@ -384,6 +384,17 @@
|
|
|
384
384
|
background: color-mix(in lab, #0d9488 20%, #ffffff);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
.caseMenuItemClearRO {
|
|
388
|
+
background: color-mix(in lab, #fd7e14 16%, #ffffff);
|
|
389
|
+
color: #7c3f00;
|
|
390
|
+
border-color: color-mix(in lab, #fd7e14 30%, transparent);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.caseMenuItemClearRO:hover {
|
|
394
|
+
background: color-mix(in lab, #fd7e14 22%, #ffffff);
|
|
395
|
+
border-color: color-mix(in lab, #fd7e14 36%, transparent);
|
|
396
|
+
}
|
|
397
|
+
|
|
387
398
|
.caseMenuCaption {
|
|
388
399
|
margin-top: 0.25rem;
|
|
389
400
|
padding: 0.3rem 0.45rem 0.1rem;
|
|
@@ -26,6 +26,7 @@ interface NavbarProps {
|
|
|
26
26
|
onOpenRenameCase?: () => void;
|
|
27
27
|
onDeleteCase?: () => void;
|
|
28
28
|
onArchiveCase?: () => void;
|
|
29
|
+
onClearROCase?: () => void;
|
|
29
30
|
onOpenViewAllFiles?: () => void;
|
|
30
31
|
onDeleteCurrentFile?: () => void;
|
|
31
32
|
onOpenImageNotes?: () => void;
|
|
@@ -55,6 +56,7 @@ export const Navbar = ({
|
|
|
55
56
|
onOpenRenameCase,
|
|
56
57
|
onDeleteCase,
|
|
57
58
|
onArchiveCase,
|
|
59
|
+
onClearROCase,
|
|
58
60
|
onOpenViewAllFiles,
|
|
59
61
|
onDeleteCurrentFile,
|
|
60
62
|
onOpenImageNotes,
|
|
@@ -151,6 +153,8 @@ export const Navbar = ({
|
|
|
151
153
|
type="button"
|
|
152
154
|
role="menuitem"
|
|
153
155
|
className={`${styles.caseMenuItem} ${styles.caseMenuItemOpen}`}
|
|
156
|
+
disabled={isReadOnly}
|
|
157
|
+
title={isReadOnly ? 'Clear the read-only case first to open or switch cases' : undefined}
|
|
154
158
|
onClick={() => {
|
|
155
159
|
onOpenCase?.();
|
|
156
160
|
setIsCaseMenuOpen(false);
|
|
@@ -162,6 +166,8 @@ export const Navbar = ({
|
|
|
162
166
|
type="button"
|
|
163
167
|
role="menuitem"
|
|
164
168
|
className={`${styles.caseMenuItem} ${styles.caseMenuItemList}`}
|
|
169
|
+
disabled={isReadOnly}
|
|
170
|
+
title={isReadOnly ? 'Clear the read-only case first to list all cases' : undefined}
|
|
165
171
|
onClick={() => {
|
|
166
172
|
onOpenListAllCases?.();
|
|
167
173
|
setIsCaseMenuOpen(false);
|
|
@@ -202,8 +208,21 @@ export const Navbar = ({
|
|
|
202
208
|
>
|
|
203
209
|
Case Audit Trail
|
|
204
210
|
</button>
|
|
205
|
-
{
|
|
206
|
-
|
|
211
|
+
<div className={styles.caseMenuSectionLabel}>Maintenance</div>
|
|
212
|
+
{isReadOnly && (
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
role="menuitem"
|
|
216
|
+
className={`${styles.caseMenuItem} ${styles.caseMenuItemClearRO}`}
|
|
217
|
+
disabled={!hasLoadedCase}
|
|
218
|
+
title={!hasLoadedCase ? 'No read-only case is loaded' : undefined}
|
|
219
|
+
onClick={() => {
|
|
220
|
+
onClearROCase?.();
|
|
221
|
+
setIsCaseMenuOpen(false);
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
Clear RO Case
|
|
225
|
+
</button>
|
|
207
226
|
)}
|
|
208
227
|
{!isReadOnly && (
|
|
209
228
|
<button
|
|
@@ -226,27 +245,27 @@ export const Navbar = ({
|
|
|
226
245
|
Rename Case
|
|
227
246
|
</button>
|
|
228
247
|
)}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
role="menuitem"
|
|
251
|
+
className={`${styles.caseMenuItem} ${styles.caseMenuItemDelete}`}
|
|
252
|
+
disabled={!hasLoadedCase || disableLongRunningCaseActions || isReadOnly}
|
|
253
|
+
title={
|
|
254
|
+
isReadOnly
|
|
255
|
+
? 'Clear the read-only case first before deleting'
|
|
256
|
+
: !hasLoadedCase
|
|
237
257
|
? 'Load a case to delete it'
|
|
238
258
|
: disableLongRunningCaseActions
|
|
239
259
|
? 'Delete is unavailable while files are uploading'
|
|
240
260
|
: undefined
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
)}
|
|
261
|
+
}
|
|
262
|
+
onClick={() => {
|
|
263
|
+
onDeleteCase?.();
|
|
264
|
+
setIsCaseMenuOpen(false);
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
Delete Case
|
|
268
|
+
</button>
|
|
250
269
|
{!isReadOnly && (
|
|
251
270
|
<button
|
|
252
271
|
type="button"
|
|
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
|
4
4
|
import styles from './cases.module.css';
|
|
5
5
|
import { FilesModal } from '../files/files-modal';
|
|
6
6
|
import { ImageUploadZone } from '../upload/image-upload-zone';
|
|
7
|
-
import { exportConfirmationData } from '../../actions/confirm-export';
|
|
8
7
|
import {
|
|
9
8
|
fetchFiles,
|
|
10
9
|
deleteFile,
|
|
@@ -34,7 +33,7 @@ interface CaseSidebarProps {
|
|
|
34
33
|
isUploading?: boolean;
|
|
35
34
|
onUploadStatusChange?: (isUploading: boolean) => void;
|
|
36
35
|
onUploadComplete?: (result: { successCount: number; failedFiles: string[] }) => void;
|
|
37
|
-
|
|
36
|
+
onOpenCaseExport?: () => void;
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
export const CaseSidebar = ({
|
|
@@ -55,14 +54,13 @@ export const CaseSidebar = ({
|
|
|
55
54
|
isUploading = false,
|
|
56
55
|
onUploadStatusChange,
|
|
57
56
|
onUploadComplete,
|
|
58
|
-
|
|
57
|
+
onOpenCaseExport
|
|
59
58
|
}: CaseSidebarProps) => {
|
|
60
59
|
|
|
61
60
|
const [, setFileError] = useState('');
|
|
62
61
|
const [canUploadNewFile, setCanUploadNewFile] = useState(true);
|
|
63
62
|
const [uploadFileError, setUploadFileError] = useState('');
|
|
64
63
|
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
|
65
|
-
const [isExportingConfirmations, setIsExportingConfirmations] = useState(false);
|
|
66
64
|
const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
|
|
67
65
|
const [fileConfirmationStatus, setFileConfirmationStatus] = useState<{
|
|
68
66
|
[fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean }
|
|
@@ -230,26 +228,6 @@ const handleImageSelect = (file: FileData) => {
|
|
|
230
228
|
setImageLoaded(false);
|
|
231
229
|
};
|
|
232
230
|
|
|
233
|
-
const handleExportConfirmations = useCallback(async () => {
|
|
234
|
-
if (!currentCase || !isReadOnly || !isArchivedCase) {
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
setIsExportingConfirmations(true);
|
|
240
|
-
await exportConfirmationData(user, currentCase);
|
|
241
|
-
onExportNotification?.(`Confirmation export for case ${currentCase} downloaded successfully.`, 'success');
|
|
242
|
-
} catch (error) {
|
|
243
|
-
console.error('Failed to export confirmations:', error);
|
|
244
|
-
onExportNotification?.(
|
|
245
|
-
error instanceof Error ? error.message : 'Failed to export confirmation data.',
|
|
246
|
-
'error'
|
|
247
|
-
);
|
|
248
|
-
} finally {
|
|
249
|
-
setIsExportingConfirmations(false);
|
|
250
|
-
}
|
|
251
|
-
}, [currentCase, isArchivedCase, isReadOnly, onExportNotification, user]);
|
|
252
|
-
|
|
253
231
|
const selectedFileConfirmationState = selectedFileId
|
|
254
232
|
? fileConfirmationStatus[selectedFileId]
|
|
255
233
|
: undefined;
|
|
@@ -280,10 +258,10 @@ const handleImageSelect = (file: FileData) => {
|
|
|
280
258
|
? 'Select an image first'
|
|
281
259
|
: undefined;
|
|
282
260
|
|
|
283
|
-
const
|
|
261
|
+
const showCaseExportButton = Boolean(currentCase && isReadOnly && !isArchivedCase);
|
|
284
262
|
|
|
285
|
-
const
|
|
286
|
-
? 'Cannot export
|
|
263
|
+
const exportCaseTitle = isUploading
|
|
264
|
+
? 'Cannot export while uploading'
|
|
287
265
|
: !currentCase
|
|
288
266
|
? 'Load a case first'
|
|
289
267
|
: undefined;
|
|
@@ -406,14 +384,14 @@ return (
|
|
|
406
384
|
)}
|
|
407
385
|
</div>
|
|
408
386
|
<div className={styles.sidebarToggle}>
|
|
409
|
-
{
|
|
387
|
+
{showCaseExportButton ? (
|
|
410
388
|
<button
|
|
411
389
|
className={styles.confirmationExportButton}
|
|
412
|
-
onClick={
|
|
413
|
-
disabled={isUploading || !currentCase
|
|
414
|
-
title={
|
|
390
|
+
onClick={onOpenCaseExport}
|
|
391
|
+
disabled={isUploading || !currentCase}
|
|
392
|
+
title={exportCaseTitle}
|
|
415
393
|
>
|
|
416
|
-
|
|
394
|
+
Export Confirmations
|
|
417
395
|
</button>
|
|
418
396
|
) : (
|
|
419
397
|
<button
|
|
@@ -489,7 +489,7 @@
|
|
|
489
489
|
cursor: not-allowed;
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
-
.confirmationExportButton {
|
|
492
|
+
.sidebarToggle .confirmationExportButton {
|
|
493
493
|
width: 100%;
|
|
494
494
|
padding: 0.625rem 0.75rem;
|
|
495
495
|
background-color: #198754;
|
|
@@ -502,11 +502,11 @@
|
|
|
502
502
|
transition: all 0.2s;
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
-
.confirmationExportButton:hover:not(:disabled) {
|
|
505
|
+
.sidebarToggle .confirmationExportButton:hover:not(:disabled) {
|
|
506
506
|
background-color: #146c43;
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
.confirmationExportButton:disabled {
|
|
509
|
+
.sidebarToggle .confirmationExportButton:disabled {
|
|
510
510
|
background-color: var(--backgroundLight);
|
|
511
511
|
color: var(--textLight);
|
|
512
512
|
cursor: not-allowed;
|
|
@@ -29,6 +29,7 @@ interface SidebarContainerProps {
|
|
|
29
29
|
confirmationSaveVersion?: number;
|
|
30
30
|
isUploading?: boolean;
|
|
31
31
|
onUploadStatusChange?: (isUploading: boolean) => void;
|
|
32
|
+
onOpenCaseExport?: () => void;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export const SidebarContainer: React.FC<SidebarContainerProps> = (props) => {
|
|
@@ -25,6 +25,7 @@ interface SidebarProps {
|
|
|
25
25
|
confirmationSaveVersion?: number;
|
|
26
26
|
isUploading?: boolean;
|
|
27
27
|
onUploadStatusChange?: (isUploading: boolean) => void;
|
|
28
|
+
onOpenCaseExport?: () => void;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export const Sidebar = ({
|
|
@@ -44,6 +45,7 @@ export const Sidebar = ({
|
|
|
44
45
|
confirmationSaveVersion = 0,
|
|
45
46
|
isUploading: initialIsUploading = false,
|
|
46
47
|
onUploadStatusChange,
|
|
48
|
+
onOpenCaseExport,
|
|
47
49
|
}: SidebarProps) => {
|
|
48
50
|
const [isUploading, setIsUploading] = useState(initialIsUploading);
|
|
49
51
|
const [toastMessage, setToastMessage] = useState('');
|
|
@@ -74,12 +76,6 @@ export const Sidebar = ({
|
|
|
74
76
|
setIsToastVisible(true);
|
|
75
77
|
}, []);
|
|
76
78
|
|
|
77
|
-
const handleExportNotification = useCallback((message: string, type: 'success' | 'error') => {
|
|
78
|
-
setToastType(type);
|
|
79
|
-
setToastMessage(message);
|
|
80
|
-
setIsToastVisible(true);
|
|
81
|
-
}, []);
|
|
82
|
-
|
|
83
79
|
return (
|
|
84
80
|
<div className={styles.sidebar}>
|
|
85
81
|
<CaseSidebar
|
|
@@ -100,7 +96,7 @@ export const Sidebar = ({
|
|
|
100
96
|
isUploading={isUploading}
|
|
101
97
|
onUploadStatusChange={handleUploadStatusChange}
|
|
102
98
|
onUploadComplete={handleUploadComplete}
|
|
103
|
-
|
|
99
|
+
onOpenCaseExport={onOpenCaseExport}
|
|
104
100
|
/>
|
|
105
101
|
<Toast
|
|
106
102
|
message={toastMessage}
|
|
@@ -21,7 +21,7 @@ import { resolveEarliestAnnotationTimestamp } from '~/utils/ui';
|
|
|
21
21
|
import { type AnnotationData, type FileData } from '~/types';
|
|
22
22
|
import type * as CaseExportActions from '~/components/actions/case-export';
|
|
23
23
|
import { checkCaseIsReadOnly, validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
|
|
24
|
-
import { checkReadOnlyCaseExists } from '~/components/actions/case-review';
|
|
24
|
+
import { checkReadOnlyCaseExists, deleteReadOnlyCase } from '~/components/actions/case-review';
|
|
25
25
|
import { canCreateCase, getLimitsDescription, getUserData } from '~/utils/data';
|
|
26
26
|
import styles from './striae.module.css';
|
|
27
27
|
|
|
@@ -413,6 +413,39 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
413
413
|
}
|
|
414
414
|
};
|
|
415
415
|
|
|
416
|
+
const handleClearROCase = async () => {
|
|
417
|
+
if (!currentCase) {
|
|
418
|
+
showNotification('No read-only case is currently loaded.', 'error');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const caseToRemove = currentCase;
|
|
423
|
+
const confirmed = window.confirm(
|
|
424
|
+
`Clear the read-only case "${caseToRemove}" from the workspace? This will remove the imported review data. The original exported case is not affected.`
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
if (!confirmed) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const success = await deleteReadOnlyCase(user, caseToRemove);
|
|
433
|
+
if (!success) {
|
|
434
|
+
showNotification(`Failed to fully clear read-only case "${caseToRemove}". Please try again.`, 'error');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
setCurrentCase('');
|
|
438
|
+
setFiles([]);
|
|
439
|
+
handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
|
|
440
|
+
setShowNotes(false);
|
|
441
|
+
setIsAuditTrailOpen(false);
|
|
442
|
+
setIsRenameCaseModalOpen(false);
|
|
443
|
+
showNotification(`Read-only case "${caseToRemove}" cleared.`, 'success');
|
|
444
|
+
} catch (clearError) {
|
|
445
|
+
showNotification(clearError instanceof Error ? clearError.message : 'Failed to clear read-only case.', 'error');
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
416
449
|
const handleArchiveCaseSubmit = async (archiveReason: string) => {
|
|
417
450
|
if (!currentCase) {
|
|
418
451
|
showNotification('Select a case before archiving.', 'error');
|
|
@@ -514,6 +547,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
514
547
|
// Function to refresh annotation data (called when notes are saved)
|
|
515
548
|
const refreshAnnotationData = () => {
|
|
516
549
|
setAnnotationRefreshTrigger(prev => prev + 1);
|
|
550
|
+
setConfirmationSaveVersion(prev => prev + 1);
|
|
517
551
|
};
|
|
518
552
|
|
|
519
553
|
// Handle import/clear read-only case
|
|
@@ -522,6 +556,14 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
522
556
|
if (result.caseNumber && result.isReadOnly) {
|
|
523
557
|
// Successful read-only case import - load the case
|
|
524
558
|
handleCaseChange(result.caseNumber);
|
|
559
|
+
} else if (result.caseNumber) {
|
|
560
|
+
if (result.caseNumber === currentCase) {
|
|
561
|
+
// Current case updated - refresh annotations (also bumps confirmationSaveVersion)
|
|
562
|
+
refreshAnnotationData();
|
|
563
|
+
} else {
|
|
564
|
+
// Different case's confirmations updated - bump confirmation version only
|
|
565
|
+
setConfirmationSaveVersion(prev => prev + 1);
|
|
566
|
+
}
|
|
525
567
|
} else if (!result.caseNumber && !result.isReadOnly) {
|
|
526
568
|
// Read-only case cleared - reset all UI state
|
|
527
569
|
setCurrentCase('');
|
|
@@ -722,6 +764,9 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
722
764
|
void handleDeleteCaseAction();
|
|
723
765
|
}}
|
|
724
766
|
onArchiveCase={() => setIsArchiveCaseModalOpen(true)}
|
|
767
|
+
onClearROCase={() => {
|
|
768
|
+
void handleClearROCase();
|
|
769
|
+
}}
|
|
725
770
|
onOpenViewAllFiles={() => setIsFilesModalOpen(true)}
|
|
726
771
|
onDeleteCurrentFile={() => {
|
|
727
772
|
void handleDeleteCurrentFileAction();
|
|
@@ -735,6 +780,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
735
780
|
onOpenCase={() => {
|
|
736
781
|
void handleOpenCaseModal();
|
|
737
782
|
}}
|
|
783
|
+
onOpenCaseExport={() => setIsCaseExportModalOpen(true)}
|
|
738
784
|
imageId={imageId}
|
|
739
785
|
currentCase={currentCase}
|
|
740
786
|
imageLoaded={imageLoaded}
|
|
@@ -38,7 +38,8 @@ export const AUDIT_CSV_ENTRY_HEADERS = [
|
|
|
38
38
|
'Total Files',
|
|
39
39
|
'MFA Method',
|
|
40
40
|
'Security Incident Type',
|
|
41
|
-
'Security Severity'
|
|
41
|
+
'Security Severity',
|
|
42
|
+
'Confirmed Files'
|
|
42
43
|
];
|
|
43
44
|
|
|
44
45
|
export const formatForCSV = (value?: string | number | null): string => {
|
|
@@ -121,7 +122,8 @@ export const entryToCSVRow = (entry: ValidationAuditEntry): string => {
|
|
|
121
122
|
caseDetails?.totalFiles?.toString() || '',
|
|
122
123
|
formatForCSV(securityDetails?.mfaMethod),
|
|
123
124
|
formatForCSV(securityDetails?.incidentType),
|
|
124
|
-
formatForCSV(securityDetails?.severity)
|
|
125
|
+
formatForCSV(securityDetails?.severity),
|
|
126
|
+
formatForCSV(caseDetails?.confirmedFileNames?.join('; '))
|
|
125
127
|
];
|
|
126
128
|
|
|
127
129
|
return values.join(',');
|
|
@@ -84,7 +84,8 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
|
|
|
84
84
|
|
|
85
85
|
let totalConfirmationsImported = 0;
|
|
86
86
|
let totalConfirmationsInFiles = 0;
|
|
87
|
-
const reviewingExaminers = new Set<string>();
|
|
87
|
+
const reviewingExaminers = new Map<string, { uid: string; badgeId?: string; confirmedFiles: Set<string> }>();
|
|
88
|
+
const allConfirmedFiles = new Set<string>();
|
|
88
89
|
|
|
89
90
|
imports.forEach(entry => {
|
|
90
91
|
const metrics = entry.details.performanceMetrics;
|
|
@@ -97,10 +98,36 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
|
|
|
97
98
|
totalConfirmationsInFiles += caseDetails.totalAnnotations;
|
|
98
99
|
}
|
|
99
100
|
if (entry.details.reviewingExaminerUid) {
|
|
100
|
-
|
|
101
|
+
const uid = entry.details.reviewingExaminerUid;
|
|
102
|
+
const badgeId = entry.details.reviewerBadgeId;
|
|
103
|
+
const confirmedFileNames = caseDetails?.confirmedFileNames || [];
|
|
104
|
+
|
|
105
|
+
if (!reviewingExaminers.has(uid)) {
|
|
106
|
+
reviewingExaminers.set(uid, {
|
|
107
|
+
uid,
|
|
108
|
+
badgeId,
|
|
109
|
+
confirmedFiles: new Set()
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const examiner = reviewingExaminers.get(uid)!;
|
|
114
|
+
confirmedFileNames.forEach(file => {
|
|
115
|
+
examiner.confirmedFiles.add(file);
|
|
116
|
+
allConfirmedFiles.add(file);
|
|
117
|
+
});
|
|
101
118
|
}
|
|
102
119
|
});
|
|
103
120
|
|
|
121
|
+
const examinersDetail = Array.from(reviewingExaminers.values())
|
|
122
|
+
.map(examiner => {
|
|
123
|
+
const badgeInfo = examiner.badgeId ? ` (Badge: ${examiner.badgeId})` : '';
|
|
124
|
+
const filesInfo = examiner.confirmedFiles.size > 0
|
|
125
|
+
? `\n Confirmed Files: ${Array.from(examiner.confirmedFiles).sort().join(', ')}`
|
|
126
|
+
: '';
|
|
127
|
+
return `- UID: ${examiner.uid}${badgeInfo}${filesInfo}`;
|
|
128
|
+
})
|
|
129
|
+
.join('\n');
|
|
130
|
+
|
|
104
131
|
return [
|
|
105
132
|
`Confirmation Operations: ${confirmationEntries.length}`,
|
|
106
133
|
`- Imports: ${imports.length}`,
|
|
@@ -112,8 +139,12 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
|
|
|
112
139
|
`Reviewing Examiners Involved: ${reviewingExaminers.size}`,
|
|
113
140
|
'',
|
|
114
141
|
reviewingExaminers.size > 0
|
|
115
|
-
? `External Reviewers
|
|
116
|
-
: 'No external reviewers detected'
|
|
142
|
+
? `External Reviewers:\n${examinersDetail}`
|
|
143
|
+
: 'No external reviewers detected',
|
|
144
|
+
'',
|
|
145
|
+
allConfirmedFiles.size > 0
|
|
146
|
+
? `Successfully Confirmed Files (Total: ${allConfirmedFiles.size}):\n${Array.from(allConfirmedFiles).sort().map(file => ` - ${file}`).join('\n')}`
|
|
147
|
+
: 'No files confirmed'
|
|
117
148
|
].join('\n');
|
|
118
149
|
};
|
|
119
150
|
|
|
@@ -340,6 +340,7 @@ export class AuditService {
|
|
|
340
340
|
result: AuditResult,
|
|
341
341
|
hashValid: boolean,
|
|
342
342
|
confirmationsImported: number,
|
|
343
|
+
confirmedFileNames: string[] = [],
|
|
343
344
|
errors: string[] = [],
|
|
344
345
|
reviewingExaminerUid?: string,
|
|
345
346
|
performanceMetrics?: PerformanceMetrics,
|
|
@@ -360,6 +361,7 @@ export class AuditService {
|
|
|
360
361
|
result,
|
|
361
362
|
hashValid,
|
|
362
363
|
confirmationsImported,
|
|
364
|
+
confirmedFileNames,
|
|
363
365
|
errors,
|
|
364
366
|
reviewingExaminerUid,
|
|
365
367
|
reviewerBadgeId,
|
|
@@ -19,6 +19,7 @@ export const buildValidationAuditEntry = (
|
|
|
19
19
|
confirmationId: params.confirmationId,
|
|
20
20
|
originalExaminerUid: params.originalExaminerUid,
|
|
21
21
|
reviewingExaminerUid: params.reviewingExaminerUid,
|
|
22
|
+
reviewerBadgeId: params.reviewerBadgeId,
|
|
22
23
|
workflowPhase: params.workflowPhase,
|
|
23
24
|
securityChecks: params.securityChecks,
|
|
24
25
|
performanceMetrics: params.performanceMetrics,
|
|
@@ -218,6 +218,7 @@ interface BuildConfirmationImportAuditParamsInput {
|
|
|
218
218
|
result: AuditResult;
|
|
219
219
|
hashValid: boolean;
|
|
220
220
|
confirmationsImported: number;
|
|
221
|
+
confirmedFileNames?: string[];
|
|
221
222
|
errors?: string[];
|
|
222
223
|
reviewingExaminerUid?: string;
|
|
223
224
|
reviewerBadgeId?: string;
|
|
@@ -273,8 +274,13 @@ export const buildConfirmationImportAuditParams = (
|
|
|
273
274
|
reviewerBadgeId: input.reviewerBadgeId,
|
|
274
275
|
caseDetails: input.totalConfirmationsInFile !== undefined
|
|
275
276
|
? {
|
|
276
|
-
totalAnnotations: input.totalConfirmationsInFile
|
|
277
|
+
totalAnnotations: input.totalConfirmationsInFile,
|
|
278
|
+
confirmedFileNames: input.confirmedFileNames
|
|
277
279
|
}
|
|
278
|
-
:
|
|
280
|
+
: input.confirmedFileNames
|
|
281
|
+
? {
|
|
282
|
+
confirmedFileNames: input.confirmedFileNames
|
|
283
|
+
}
|
|
284
|
+
: undefined
|
|
279
285
|
};
|
|
280
286
|
};
|
package/app/types/audit.ts
CHANGED
|
@@ -123,8 +123,12 @@ function normalizeConfirmations(confirmations: ConfirmationMap): ConfirmationMap
|
|
|
123
123
|
|
|
124
124
|
export function createConfirmationSigningPayload(
|
|
125
125
|
confirmationData: ConfirmationImportData,
|
|
126
|
-
signatureVersion: string = CONFIRMATION_SIGNATURE_VERSION
|
|
126
|
+
signatureVersion: string = CONFIRMATION_SIGNATURE_VERSION,
|
|
127
|
+
options: {
|
|
128
|
+
includeExportedByBadgeId?: boolean;
|
|
129
|
+
} = {}
|
|
127
130
|
): string {
|
|
131
|
+
const includeExportedByBadgeId = options.includeExportedByBadgeId !== false;
|
|
128
132
|
const canonicalPayload = {
|
|
129
133
|
signatureVersion,
|
|
130
134
|
metadata: {
|
|
@@ -134,7 +138,7 @@ export function createConfirmationSigningPayload(
|
|
|
134
138
|
exportedByUid: confirmationData.metadata.exportedByUid,
|
|
135
139
|
exportedByName: confirmationData.metadata.exportedByName,
|
|
136
140
|
exportedByCompany: confirmationData.metadata.exportedByCompany,
|
|
137
|
-
...(confirmationData.metadata.exportedByBadgeId
|
|
141
|
+
...(includeExportedByBadgeId && confirmationData.metadata.exportedByBadgeId
|
|
138
142
|
? { exportedByBadgeId: confirmationData.metadata.exportedByBadgeId }
|
|
139
143
|
: {}),
|
|
140
144
|
totalConfirmations: confirmationData.metadata.totalConfirmations,
|
|
@@ -180,9 +184,7 @@ export async function verifyConfirmationSignature(
|
|
|
180
184
|
};
|
|
181
185
|
}
|
|
182
186
|
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
return verifySignaturePayload(
|
|
187
|
+
const verifyPayload = (payload: string) => verifySignaturePayload(
|
|
186
188
|
payload,
|
|
187
189
|
signature,
|
|
188
190
|
FORENSIC_MANIFEST_SIGNATURE_ALGORITHM,
|
|
@@ -197,4 +199,17 @@ export async function verifyConfirmationSignature(
|
|
|
197
199
|
verificationPublicKeyPem
|
|
198
200
|
}
|
|
199
201
|
);
|
|
202
|
+
|
|
203
|
+
const primaryPayload = createConfirmationSigningPayload(confirmationData, signatureVersion);
|
|
204
|
+
const primaryResult = await verifyPayload(primaryPayload);
|
|
205
|
+
|
|
206
|
+
if (primaryResult.isValid || !confirmationData.metadata.exportedByBadgeId) {
|
|
207
|
+
return primaryResult;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const legacyPayload = createConfirmationSigningPayload(confirmationData, signatureVersion, {
|
|
211
|
+
includeExportedByBadgeId: false
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return verifyPayload(legacyPayload);
|
|
200
215
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@striae-org/striae",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -13,6 +13,7 @@ export interface ConfirmationSignatureMetadata {
|
|
|
13
13
|
exportedByUid: string;
|
|
14
14
|
exportedByName: string;
|
|
15
15
|
exportedByCompany: string;
|
|
16
|
+
exportedByBadgeId?: string;
|
|
16
17
|
totalConfirmations: number;
|
|
17
18
|
version: string;
|
|
18
19
|
hash: string;
|
|
@@ -131,6 +132,7 @@ export function isValidConfirmationPayload(
|
|
|
131
132
|
typeof metadata.exportedByUid !== 'string' ||
|
|
132
133
|
typeof metadata.exportedByName !== 'string' ||
|
|
133
134
|
typeof metadata.exportedByCompany !== 'string' ||
|
|
135
|
+
(typeof metadata.exportedByBadgeId !== 'undefined' && typeof metadata.exportedByBadgeId !== 'string') ||
|
|
134
136
|
typeof metadata.totalConfirmations !== 'number' ||
|
|
135
137
|
metadata.totalConfirmations < 0 ||
|
|
136
138
|
typeof metadata.version !== 'string' ||
|
|
@@ -261,6 +263,9 @@ export function createConfirmationSigningPayload(confirmationData: ConfirmationS
|
|
|
261
263
|
exportedByUid: confirmationData.metadata.exportedByUid,
|
|
262
264
|
exportedByName: confirmationData.metadata.exportedByName,
|
|
263
265
|
exportedByCompany: confirmationData.metadata.exportedByCompany,
|
|
266
|
+
...(confirmationData.metadata.exportedByBadgeId
|
|
267
|
+
? { exportedByBadgeId: confirmationData.metadata.exportedByBadgeId }
|
|
268
|
+
: {}),
|
|
264
269
|
totalConfirmations: confirmationData.metadata.totalConfirmations,
|
|
265
270
|
version: confirmationData.metadata.version,
|
|
266
271
|
hash: confirmationData.metadata.hash.toUpperCase(),
|