@striae-org/striae 4.3.2 → 4.3.4

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.
Files changed (34) hide show
  1. package/app/components/actions/case-import/orchestrator.ts +1 -1
  2. package/app/components/actions/case-manage.ts +50 -14
  3. package/app/components/audit/user-audit.module.css +49 -0
  4. package/app/components/audit/viewer/audit-entries-list.tsx +130 -48
  5. package/app/components/navbar/navbar.tsx +25 -12
  6. package/app/components/sidebar/case-import/case-import.tsx +56 -14
  7. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +7 -6
  8. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +9 -5
  9. package/app/components/sidebar/cases/cases-modal.module.css +19 -0
  10. package/app/components/sidebar/cases/cases-modal.tsx +23 -8
  11. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +102 -0
  12. package/app/routes/striae/striae.tsx +72 -74
  13. package/app/routes/striae/utils/case-export.ts +37 -0
  14. package/app/routes/striae/utils/open-case-helper.ts +18 -0
  15. package/app/services/audit/audit-console-logger.ts +1 -1
  16. package/app/services/audit/audit-export-csv.ts +1 -1
  17. package/app/services/audit/audit-export-signing.ts +2 -2
  18. package/app/services/audit/audit-export.service.ts +1 -1
  19. package/app/services/audit/audit-worker-client.ts +1 -1
  20. package/app/services/audit/audit.service.ts +5 -75
  21. package/app/services/audit/builders/audit-event-builders-case-file.ts +3 -0
  22. package/app/services/audit/index.ts +2 -2
  23. package/app/types/audit.ts +8 -7
  24. package/app/utils/data/case-filters.ts +1 -1
  25. package/app/utils/ui/case-messages.ts +69 -0
  26. package/app/utils/ui/index.ts +1 -0
  27. package/package.json +5 -5
  28. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  29. package/workers/data-worker/wrangler.jsonc.example +1 -1
  30. package/workers/image-worker/wrangler.jsonc.example +1 -1
  31. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  32. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  33. package/workers/user-worker/wrangler.jsonc.example +1 -1
  34. package/wrangler.toml.example +1 -1
@@ -1,6 +1,12 @@
1
1
  import { useState, useEffect, useRef, useContext, useCallback } from 'react';
2
2
  import { AuthContext } from '~/contexts/auth.context';
3
3
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
4
+ import {
5
+ ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE,
6
+ IMPORT_FILE_TYPE_NOT_ALLOWED,
7
+ IMPORT_FILE_TYPE_NOT_SUPPORTED,
8
+ DATA_INTEGRITY_BLOCKED_TAMPERING
9
+ } from '~/utils/ui';
4
10
  import {
5
11
  listReadOnlyCases,
6
12
  deleteReadOnlyCase
@@ -63,13 +69,13 @@ export const CaseImport = ({
63
69
  });
64
70
 
65
71
  const [existingReadOnlyCase, setExistingReadOnlyCase] = useState<string | null>(null);
66
- const [showArchivedRegularCaseRiskWarning, setShowArchivedRegularCaseRiskWarning] = useState(false);
72
+ const [isArchivedRegularCaseImportBlocked, setIsArchivedRegularCaseImportBlocked] = useState(false);
67
73
  const fileInputRef = useRef<HTMLInputElement>(null);
68
74
 
69
75
  // Clear import selection state (used by preview hook on validation failure)
70
76
  const clearImportSelection = useCallback(() => {
71
77
  updateImportState({ selectedFile: null, importType: null });
72
- setShowArchivedRegularCaseRiskWarning(false);
78
+ setIsArchivedRegularCaseImportBlocked(false);
73
79
  resetFileInput(fileInputRef);
74
80
  }, [updateImportState]);
75
81
 
@@ -203,14 +209,14 @@ export const CaseImport = ({
203
209
  clearMessages();
204
210
 
205
211
  if (!isValidImportFile(file)) {
206
- setError('Only Striae case ZIP files, confirmation ZIP files, or confirmation JSON files are allowed.');
212
+ setError(IMPORT_FILE_TYPE_NOT_ALLOWED);
207
213
  clearImportData();
208
214
  return;
209
215
  }
210
216
 
211
217
  const importType = await resolveImportType(file);
212
218
  if (!importType) {
213
- setError('The selected file is not a supported Striae case or confirmation import package.');
219
+ setError(IMPORT_FILE_TYPE_NOT_SUPPORTED);
214
220
  clearImportData();
215
221
  return;
216
222
  }
@@ -232,6 +238,11 @@ export const CaseImport = ({
232
238
  // Handle import action
233
239
  const handleImport = useCallback(() => {
234
240
  if (!user || !importState.selectedFile || !importState.importType) return;
241
+
242
+ if (importState.importType === 'case' && isArchivedRegularCaseImportBlocked) {
243
+ setError(ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE);
244
+ return;
245
+ }
235
246
 
236
247
  // For case imports, show confirmation dialog with preview
237
248
  // For confirmation imports, proceed directly to import
@@ -242,7 +253,16 @@ export const CaseImport = ({
242
253
  // Direct import for confirmations
243
254
  executeImport();
244
255
  }
245
- }, [user, importState.selectedFile, importState.importType, casePreview, updateImportState, executeImport]);
256
+ }, [
257
+ user,
258
+ importState.selectedFile,
259
+ importState.importType,
260
+ isArchivedRegularCaseImportBlocked,
261
+ casePreview,
262
+ updateImportState,
263
+ executeImport,
264
+ setError,
265
+ ]);
246
266
 
247
267
  const handleCancelImport = useCallback(() => {
248
268
  updateImportState({ showConfirmation: false });
@@ -273,7 +293,7 @@ export const CaseImport = ({
273
293
  !casePreview.caseNumber
274
294
  ) {
275
295
  if (isMounted) {
276
- setShowArchivedRegularCaseRiskWarning(false);
296
+ setIsArchivedRegularCaseImportBlocked(false);
277
297
  }
278
298
  return;
279
299
  }
@@ -281,11 +301,12 @@ export const CaseImport = ({
281
301
  try {
282
302
  const regularCases = await listCases(user);
283
303
  if (isMounted) {
284
- setShowArchivedRegularCaseRiskWarning(regularCases.includes(casePreview.caseNumber));
304
+ const hasConflict = regularCases.includes(casePreview.caseNumber);
305
+ setIsArchivedRegularCaseImportBlocked(hasConflict);
285
306
  }
286
307
  } catch {
287
308
  if (isMounted) {
288
- setShowArchivedRegularCaseRiskWarning(false);
309
+ setIsArchivedRegularCaseImportBlocked(false);
289
310
  }
290
311
  }
291
312
  };
@@ -295,7 +316,13 @@ export const CaseImport = ({
295
316
  return () => {
296
317
  isMounted = false;
297
318
  };
298
- }, [user, isOpen, importState.importType, casePreview?.archived, casePreview?.caseNumber]);
319
+ }, [
320
+ user,
321
+ isOpen,
322
+ importState.importType,
323
+ casePreview?.archived,
324
+ casePreview?.caseNumber,
325
+ ]);
299
326
 
300
327
  // Reset state when modal closes
301
328
  useEffect(() => {
@@ -306,9 +333,19 @@ export const CaseImport = ({
306
333
 
307
334
  // Handle confirmation import
308
335
  const handleConfirmImport = useCallback(() => {
336
+ if (isArchivedRegularCaseImportBlocked) {
337
+ setError(ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE);
338
+ return;
339
+ }
340
+
309
341
  executeImport();
310
342
  updateImportState({ showConfirmation: false });
311
- }, [executeImport, updateImportState]);
343
+ }, [
344
+ isArchivedRegularCaseImportBlocked,
345
+ executeImport,
346
+ updateImportState,
347
+ setError,
348
+ ]);
312
349
 
313
350
  if (!isOpen) return null;
314
351
 
@@ -362,7 +399,7 @@ export const CaseImport = ({
362
399
  <CasePreviewSection
363
400
  casePreview={casePreview}
364
401
  isLoadingPreview={importState.isLoadingPreview}
365
- showArchivedRegularCaseRiskWarning={showArchivedRegularCaseRiskWarning}
402
+ isArchivedRegularCaseImportBlocked={isArchivedRegularCaseImportBlocked}
366
403
  />
367
404
  )}
368
405
 
@@ -381,11 +418,14 @@ export const CaseImport = ({
381
418
  {/* Hash validation warning */}
382
419
  {casePreview?.hashValid === false && (
383
420
  <div className={styles.hashWarning}>
384
- <strong>⚠️ Import Blocked:</strong> Data hash validation failed.
385
- This file may have been tampered with or corrupted and cannot be imported.
421
+ <strong>⚠️ Import Blocked:</strong> {DATA_INTEGRITY_BLOCKED_TAMPERING}
386
422
  </div>
387
423
  )}
388
424
 
425
+ {isArchivedRegularCaseImportBlocked && (
426
+ <div className={styles.error}>{ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}</div>
427
+ )}
428
+
389
429
  {/* Success message */}
390
430
  {messages.success && (
391
431
  <div className={styles.success}>
@@ -411,6 +451,7 @@ export const CaseImport = ({
411
451
  importState.isImporting ||
412
452
  importState.isClearing ||
413
453
  importState.isLoadingPreview ||
454
+ (importState.importType === 'case' && isArchivedRegularCaseImportBlocked) ||
414
455
  (importState.importType === 'case' && (!casePreview || casePreview.hashValid !== true))
415
456
  }
416
457
  >
@@ -454,7 +495,8 @@ export const CaseImport = ({
454
495
  <ConfirmationDialog
455
496
  showConfirmation={importState.showConfirmation}
456
497
  casePreview={casePreview}
457
- showArchivedRegularCaseRiskWarning={showArchivedRegularCaseRiskWarning}
498
+ isArchivedRegularCaseImportBlocked={isArchivedRegularCaseImportBlocked}
499
+ archivedRegularCaseBlockMessage={ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}
458
500
  onConfirm={handleConfirmImport}
459
501
  onCancel={handleCancelImport}
460
502
  />
@@ -1,16 +1,17 @@
1
1
  import { type CaseImportPreview } from '~/types';
2
+ import { ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE, DATA_INTEGRITY_VALIDATION_PASSED, DATA_INTEGRITY_VALIDATION_FAILED } from '~/utils/ui';
2
3
  import styles from '../case-import.module.css';
3
4
 
4
5
  interface CasePreviewSectionProps {
5
6
  casePreview: CaseImportPreview | null;
6
7
  isLoadingPreview: boolean;
7
- showArchivedRegularCaseRiskWarning?: boolean;
8
+ isArchivedRegularCaseImportBlocked?: boolean;
8
9
  }
9
10
 
10
11
  export const CasePreviewSection = ({
11
12
  casePreview,
12
13
  isLoadingPreview,
13
- showArchivedRegularCaseRiskWarning = false
14
+ isArchivedRegularCaseImportBlocked = false
14
15
  }: CasePreviewSectionProps) => {
15
16
  if (isLoadingPreview) {
16
17
  return (
@@ -34,9 +35,9 @@ export const CasePreviewSection = ({
34
35
  Archived export detected. Original exporter imports are allowed for archived cases.
35
36
  </div>
36
37
  )}
37
- {showArchivedRegularCaseRiskWarning && (
38
+ {isArchivedRegularCaseImportBlocked && (
38
39
  <div className={styles.archivedRegularCaseRiskNote}>
39
- Warning: This archived import matches a case already in your regular case list. If you later clear the imported read-only case, the regular case images will be deleted and become inaccessible.
40
+ {ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}
40
41
  </div>
41
42
  )}
42
43
  <div className={styles.previewGrid}>
@@ -78,9 +79,9 @@ export const CasePreviewSection = ({
78
79
  <div className={styles.validationItem}>
79
80
  <span className={`${styles.validationValue} ${casePreview.hashValid ? styles.validationSuccess : styles.validationError}`}>
80
81
  {casePreview.hashValid ? (
81
- <>✓ Validation passed</>
82
+ <>{DATA_INTEGRITY_VALIDATION_PASSED}</>
82
83
  ) : (
83
- <>✗ Validation failed</>
84
+ <>{DATA_INTEGRITY_VALIDATION_FAILED}</>
84
85
  )}
85
86
  </span>
86
87
  </div>
@@ -1,10 +1,12 @@
1
1
  import { type CaseImportPreview } from '~/types';
2
+ import { ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE, DATA_INTEGRITY_VALIDATION_PASSED, DATA_INTEGRITY_VALIDATION_FAILED } from '~/utils/ui';
2
3
  import styles from '../case-import.module.css';
3
4
 
4
5
  interface ConfirmationDialogProps {
5
6
  showConfirmation: boolean;
6
7
  casePreview: CaseImportPreview | null;
7
- showArchivedRegularCaseRiskWarning?: boolean;
8
+ isArchivedRegularCaseImportBlocked?: boolean;
9
+ archivedRegularCaseBlockMessage?: string;
8
10
  onConfirm: () => void;
9
11
  onCancel: () => void;
10
12
  }
@@ -12,7 +14,8 @@ interface ConfirmationDialogProps {
12
14
  export const ConfirmationDialog = ({
13
15
  showConfirmation,
14
16
  casePreview,
15
- showArchivedRegularCaseRiskWarning = false,
17
+ isArchivedRegularCaseImportBlocked = false,
18
+ archivedRegularCaseBlockMessage = ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE,
16
19
  onConfirm,
17
20
  onCancel
18
21
  }: ConfirmationDialogProps) => {
@@ -51,16 +54,16 @@ export const ConfirmationDialog = ({
51
54
  Archived export detected. Original exporter imports are allowed for archived cases.
52
55
  </div>
53
56
  )}
54
- {showArchivedRegularCaseRiskWarning && (
57
+ {isArchivedRegularCaseImportBlocked && (
55
58
  <div className={styles.archivedRegularCaseRiskNote}>
56
- Warning: This archived import matches a case in your regular case list. If you clear the imported read-only case later, the regular case images will be deleted and inaccessible.
59
+ {archivedRegularCaseBlockMessage}
57
60
  </div>
58
61
  )}
59
62
  {casePreview.hashValid !== undefined && (
60
63
  <div className={`${styles.confirmationItem} ${casePreview.hashValid ? styles.confirmationItemValid : styles.confirmationItemInvalid}`}>
61
64
  <strong>Data Integrity:</strong>
62
65
  <span className={casePreview.hashValid ? styles.confirmationSuccess : styles.confirmationError}>
63
- {casePreview.hashValid ? '✓ Verified' : '✗ Failed'}
66
+ {casePreview.hashValid ? DATA_INTEGRITY_VALIDATION_PASSED : DATA_INTEGRITY_VALIDATION_FAILED}
64
67
  </span>
65
68
  </div>
66
69
  )}
@@ -70,6 +73,7 @@ export const ConfirmationDialog = ({
70
73
  <button
71
74
  className={styles.confirmButton}
72
75
  onClick={onConfirm}
76
+ disabled={isArchivedRegularCaseImportBlocked}
73
77
  >
74
78
  Confirm Import
75
79
  </button>
@@ -260,6 +260,19 @@
260
260
  white-space: nowrap;
261
261
  }
262
262
 
263
+ .badgeColumn {
264
+ justify-self: end;
265
+ display: flex;
266
+ flex-direction: column;
267
+ align-items: flex-end;
268
+ gap: var(--spaceXS);
269
+ }
270
+
271
+ .archivedBadge {
272
+ background-color: color-mix(in lab, #6c757d 18%, var(--backgroundLight));
273
+ border-color: color-mix(in lab, #6c757d 30%, transparent);
274
+ }
275
+
263
276
  .footerActions {
264
277
  display: flex;
265
278
  justify-content: space-between;
@@ -489,6 +502,12 @@
489
502
  justify-self: start;
490
503
  }
491
504
 
505
+ .badgeColumn {
506
+ grid-column: 1 / -1;
507
+ justify-self: start;
508
+ align-items: flex-start;
509
+ }
510
+
492
511
  .footerActions {
493
512
  flex-direction: column;
494
513
  align-items: stretch;
@@ -169,7 +169,7 @@ export const CasesModal = ({
169
169
  return allCases.filter((entry) => entry.archived && !entry.isReadOnly);
170
170
  }
171
171
 
172
- return allCases.filter((entry) => !entry.archived && !entry.isReadOnly);
172
+ return allCases.filter((entry) => !entry.isReadOnly);
173
173
  }, [allCases, preferences.showArchivedOnly]);
174
174
 
175
175
  const visibleCases = useMemo(() => {
@@ -185,6 +185,11 @@ export const CasesModal = ({
185
185
  );
186
186
  }, [allCases, preferences, caseConfirmationStatus, searchQuery]);
187
187
 
188
+ const totalRegularCases = useMemo(
189
+ () => allCases.filter((entry) => !entry.isReadOnly).length,
190
+ [allCases]
191
+ );
192
+
188
193
  const totalPages = Math.max(1, Math.ceil(visibleCases.length / CASES_PER_PAGE));
189
194
 
190
195
  useEffect(() => {
@@ -653,7 +658,7 @@ export const CasesModal = ({
653
658
  </div>
654
659
 
655
660
  <p className={styles.caseCount}>
656
- {visibleCases.length} shown of {allCases.length} total cases
661
+ {visibleCases.length} shown of {totalRegularCases} total cases
657
662
  </p>
658
663
 
659
664
  {actionNotice && (
@@ -720,12 +725,22 @@ export const CasesModal = ({
720
725
  </span>
721
726
  </div>
722
727
 
723
- <span
724
- className={`${styles.confirmationBadge} ${confirmationClass}`}
725
- aria-label={`Confirmation status: ${confirmationLabel}`}
726
- >
727
- {confirmationLabel}
728
- </span>
728
+ <div className={styles.badgeColumn}>
729
+ {caseEntry.archived && (
730
+ <span
731
+ className={`${styles.confirmationBadge} ${styles.archivedBadge}`}
732
+ aria-label="Archived case"
733
+ >
734
+ Archived
735
+ </span>
736
+ )}
737
+ <span
738
+ className={`${styles.confirmationBadge} ${confirmationClass}`}
739
+ aria-label={`Confirmation status: ${confirmationLabel}`}
740
+ >
741
+ {confirmationLabel}
742
+ </span>
743
+ </div>
729
744
  </div>
730
745
  </li>
731
746
  );
@@ -0,0 +1,102 @@
1
+ import { useCallback, type Dispatch, type SetStateAction } from 'react';
2
+ import { type AnnotationData, type FileData } from '~/types';
3
+
4
+ interface ArchiveDetailsState {
5
+ archived: boolean;
6
+ archivedAt?: string;
7
+ archivedBy?: string;
8
+ archivedByDisplay?: string;
9
+ archiveReason?: string;
10
+ }
11
+
12
+ interface UseStriaeResetHelpersProps {
13
+ setSelectedImage: Dispatch<SetStateAction<string | undefined>>;
14
+ setSelectedFilename: Dispatch<SetStateAction<string | undefined>>;
15
+ setImageId: Dispatch<SetStateAction<string | undefined>>;
16
+ setAnnotationData: Dispatch<SetStateAction<AnnotationData | null>>;
17
+ setError: Dispatch<SetStateAction<string | undefined>>;
18
+ setImageLoaded: Dispatch<SetStateAction<boolean>>;
19
+ setCurrentCase: Dispatch<SetStateAction<string>>;
20
+ setFiles: Dispatch<SetStateAction<FileData[]>>;
21
+ setActiveAnnotations: Dispatch<SetStateAction<Set<string>>>;
22
+ setIsBoxAnnotationMode: Dispatch<SetStateAction<boolean>>;
23
+ setIsReadOnlyCase: Dispatch<SetStateAction<boolean>>;
24
+ setIsReviewOnlyCase: Dispatch<SetStateAction<boolean>>;
25
+ setArchiveDetails: Dispatch<SetStateAction<ArchiveDetailsState>>;
26
+ setShowNotes: Dispatch<SetStateAction<boolean>>;
27
+ setIsAuditTrailOpen: Dispatch<SetStateAction<boolean>>;
28
+ setIsRenameCaseModalOpen: Dispatch<SetStateAction<boolean>>;
29
+ }
30
+
31
+ export const useStriaeResetHelpers = ({
32
+ setSelectedImage,
33
+ setSelectedFilename,
34
+ setImageId,
35
+ setAnnotationData,
36
+ setError,
37
+ setImageLoaded,
38
+ setCurrentCase,
39
+ setFiles,
40
+ setActiveAnnotations,
41
+ setIsBoxAnnotationMode,
42
+ setIsReadOnlyCase,
43
+ setIsReviewOnlyCase,
44
+ setArchiveDetails,
45
+ setShowNotes,
46
+ setIsAuditTrailOpen,
47
+ setIsRenameCaseModalOpen,
48
+ }: UseStriaeResetHelpersProps) => {
49
+ const clearSelectedImageState = useCallback(() => {
50
+ setSelectedImage('/clear.jpg');
51
+ setSelectedFilename(undefined);
52
+ setImageId(undefined);
53
+ setAnnotationData(null);
54
+ setError(undefined);
55
+ setImageLoaded(false);
56
+ }, [
57
+ setSelectedImage,
58
+ setSelectedFilename,
59
+ setImageId,
60
+ setAnnotationData,
61
+ setError,
62
+ setImageLoaded,
63
+ ]);
64
+
65
+ const clearCaseContextState = useCallback(() => {
66
+ setActiveAnnotations(new Set());
67
+ setIsBoxAnnotationMode(false);
68
+ setIsReadOnlyCase(false);
69
+ setIsReviewOnlyCase(false);
70
+ setArchiveDetails({ archived: false });
71
+ }, [
72
+ setActiveAnnotations,
73
+ setIsBoxAnnotationMode,
74
+ setIsReadOnlyCase,
75
+ setIsReviewOnlyCase,
76
+ setArchiveDetails,
77
+ ]);
78
+
79
+ const clearLoadedCaseState = useCallback(() => {
80
+ setCurrentCase('');
81
+ setFiles([]);
82
+ clearCaseContextState();
83
+ clearSelectedImageState();
84
+ setShowNotes(false);
85
+ setIsAuditTrailOpen(false);
86
+ setIsRenameCaseModalOpen(false);
87
+ }, [
88
+ setCurrentCase,
89
+ setFiles,
90
+ clearCaseContextState,
91
+ clearSelectedImageState,
92
+ setShowNotes,
93
+ setIsAuditTrailOpen,
94
+ setIsRenameCaseModalOpen,
95
+ ]);
96
+
97
+ return {
98
+ clearSelectedImageState,
99
+ clearCaseContextState,
100
+ clearLoadedCaseState,
101
+ };
102
+ };