@striae-org/striae 4.3.4 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.env.example +9 -2
  2. package/app/components/actions/case-export/download-handlers.ts +66 -11
  3. package/app/components/actions/case-import/confirmation-import.ts +50 -7
  4. package/app/components/actions/case-import/confirmation-package.ts +99 -22
  5. package/app/components/actions/case-import/orchestrator.ts +116 -13
  6. package/app/components/actions/case-import/validation.ts +171 -7
  7. package/app/components/actions/case-import/zip-processing.ts +224 -127
  8. package/app/components/actions/case-manage.ts +74 -15
  9. package/app/components/actions/confirm-export.ts +32 -3
  10. package/app/components/actions/generate-pdf.ts +43 -1
  11. package/app/components/actions/image-manage.ts +13 -45
  12. package/app/components/navbar/navbar.module.css +0 -10
  13. package/app/components/navbar/navbar.tsx +0 -22
  14. package/app/components/sidebar/case-import/case-import.module.css +7 -131
  15. package/app/components/sidebar/case-import/case-import.tsx +7 -14
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
  19. package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
  20. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
  21. package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
  22. package/app/config-example/config.json +5 -0
  23. package/app/routes/auth/login.tsx +1 -1
  24. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  25. package/app/routes/striae/striae.tsx +15 -4
  26. package/app/utils/data/operations/case-operations.ts +13 -1
  27. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  28. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  29. package/app/utils/data/operations/signing-operations.ts +93 -0
  30. package/app/utils/data/operations/types.ts +6 -0
  31. package/app/utils/forensics/export-encryption.ts +316 -0
  32. package/app/utils/forensics/export-verification.ts +1 -409
  33. package/app/utils/forensics/index.ts +1 -0
  34. package/app/utils/ui/case-messages.ts +5 -2
  35. package/package.json +2 -2
  36. package/scripts/deploy-config.sh +244 -7
  37. package/scripts/deploy-pages-secrets.sh +0 -6
  38. package/scripts/deploy-worker-secrets.sh +66 -5
  39. package/scripts/encrypt-r2-backfill.mjs +376 -0
  40. package/worker-configuration.d.ts +13 -7
  41. package/workers/audit-worker/package.json +1 -4
  42. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  43. package/workers/audit-worker/wrangler.jsonc.example +6 -1
  44. package/workers/data-worker/package.json +1 -4
  45. package/workers/data-worker/src/data-worker.example.ts +409 -1
  46. package/workers/data-worker/src/encryption-utils.ts +269 -0
  47. package/workers/data-worker/worker-configuration.d.ts +1 -1
  48. package/workers/data-worker/wrangler.jsonc.example +6 -2
  49. package/workers/image-worker/package.json +1 -4
  50. package/workers/image-worker/src/encryption-utils.ts +217 -0
  51. package/workers/image-worker/src/image-worker.example.ts +196 -127
  52. package/workers/image-worker/wrangler.jsonc.example +8 -1
  53. package/workers/keys-worker/package.json +1 -4
  54. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  55. package/workers/pdf-worker/package.json +1 -4
  56. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  57. package/workers/user-worker/package.json +1 -4
  58. package/workers/user-worker/wrangler.jsonc.example +1 -1
  59. package/wrangler.toml.example +1 -1
  60. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
  61. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
@@ -1,5 +1,8 @@
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
+ import {
3
+ ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE,
4
+ ARCHIVED_SELF_IMPORT_NOTE
5
+ } from '~/utils/ui';
3
6
  import styles from '../case-import.module.css';
4
7
 
5
8
  interface CasePreviewSectionProps {
@@ -26,67 +29,21 @@ export const CasePreviewSection = ({
26
29
  if (!casePreview) return null;
27
30
 
28
31
  return (
29
- <>
30
- {/* Case Information - Always Blue */}
31
- <div className={styles.previewSection}>
32
- <h3 className={styles.previewTitle}>Case Information</h3>
33
- {casePreview.archived && (
34
- <div className={styles.archivedImportNote}>
35
- Archived export detected. Original exporter imports are allowed for archived cases.
36
- </div>
37
- )}
38
- {isArchivedRegularCaseImportBlocked && (
39
- <div className={styles.archivedRegularCaseRiskNote}>
40
- {ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}
41
- </div>
42
- )}
43
- <div className={styles.previewGrid}>
44
- <div className={styles.previewItem}>
45
- <span className={styles.previewLabel}>Case Number:</span>
46
- <span className={styles.previewValue}>{casePreview.caseNumber}</span>
47
- </div>
48
- <div className={styles.previewItem}>
49
- <span className={styles.previewLabel}>Exported by:</span>
50
- <span className={styles.previewValue}>
51
- {casePreview.exportedByName || casePreview.exportedBy || 'N/A'}
52
- </span>
53
- </div>
54
- <div className={styles.previewItem}>
55
- <span className={styles.previewLabel}>Lab/Company:</span>
56
- <span className={styles.previewValue}>{casePreview.exportedByCompany || 'N/A'}</span>
57
- </div>
58
- <div className={styles.previewItem}>
59
- <span className={styles.previewLabel}>Export Date:</span>
60
- <span className={styles.previewValue}>
61
- {new Date(casePreview.exportDate).toLocaleDateString()}
62
- </span>
63
- </div>
64
- <div className={styles.previewItem}>
65
- <span className={styles.previewLabel}>Total Images:</span>
66
- <span className={styles.previewValue}>{casePreview.totalFiles}</span>
67
- </div>
68
- <div className={styles.previewItem}>
69
- <span className={styles.previewLabel}>Archived Export:</span>
70
- <span className={styles.previewValue}>{casePreview.archived ? 'Yes' : 'No'}</span>
71
- </div>
32
+ <div className={styles.previewSection}>
33
+ <h3 className={styles.previewTitle}>Case Import Preview</h3>
34
+ <p className={styles.previewMessage}>
35
+ Case package detected. Details are hidden until import verification completes.
36
+ </p>
37
+ {casePreview.archived && (
38
+ <div className={styles.archivedImportNote}>
39
+ {ARCHIVED_SELF_IMPORT_NOTE}
72
40
  </div>
73
- </div>
74
-
75
- {/* Data Integrity Checks - Green/Red Based on Validation */}
76
- {casePreview.hashValid !== undefined && (
77
- <div className={`${styles.validationSection} ${casePreview.hashValid ? styles.validationSectionValid : styles.validationSectionInvalid}`}>
78
- <h3 className={styles.validationTitle}>Data Integrity Validation</h3>
79
- <div className={styles.validationItem}>
80
- <span className={`${styles.validationValue} ${casePreview.hashValid ? styles.validationSuccess : styles.validationError}`}>
81
- {casePreview.hashValid ? (
82
- <>{DATA_INTEGRITY_VALIDATION_PASSED}</>
83
- ) : (
84
- <>{DATA_INTEGRITY_VALIDATION_FAILED}</>
85
- )}
86
- </span>
87
- </div>
41
+ )}
42
+ {isArchivedRegularCaseImportBlocked && (
43
+ <div className={styles.archivedRegularCaseRiskNote}>
44
+ {ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}
88
45
  </div>
89
46
  )}
90
- </>
47
+ </div>
91
48
  );
92
49
  };
@@ -1,5 +1,8 @@
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
+ import {
3
+ ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE,
4
+ ARCHIVED_SELF_IMPORT_NOTE
5
+ } from '~/utils/ui';
3
6
  import styles from '../case-import.module.css';
4
7
 
5
8
  interface ConfirmationDialogProps {
@@ -21,6 +24,8 @@ export const ConfirmationDialog = ({
21
24
  }: ConfirmationDialogProps) => {
22
25
  if (!showConfirmation || !casePreview) return null;
23
26
 
27
+ const hasDetails = casePreview.archived || isArchivedRegularCaseImportBlocked;
28
+
24
29
  return (
25
30
  <div className={styles.confirmationOverlay}>
26
31
  <div className={styles.confirmationModal}>
@@ -29,45 +34,24 @@ export const ConfirmationDialog = ({
29
34
  <p className={styles.confirmationText}>
30
35
  Are you sure you want to import this case for review?
31
36
  </p>
32
-
33
- <div className={styles.confirmationDetails}>
34
- <div className={styles.confirmationItem}>
35
- <strong>Case Number:</strong> {casePreview.caseNumber}
36
- </div>
37
- <div className={styles.confirmationItem}>
38
- <strong>Exported by:</strong> {casePreview.exportedByName || casePreview.exportedBy || 'N/A'}
39
- </div>
40
- <div className={styles.confirmationItem}>
41
- <strong>Lab/Company:</strong> {casePreview.exportedByCompany || 'N/A'}
42
- </div>
43
- <div className={styles.confirmationItem}>
44
- <strong>Export Date:</strong> {new Date(casePreview.exportDate).toLocaleDateString()}
45
- </div>
46
- <div className={styles.confirmationItem}>
47
- <strong>Total Images:</strong> {casePreview.totalFiles}
48
- </div>
49
- <div className={styles.confirmationItem}>
50
- <strong>Archived Export:</strong> {casePreview.archived ? 'Yes' : 'No'}
37
+ <p className={styles.confirmationText}>
38
+ Package details stay hidden until verification completes.
39
+ </p>
40
+
41
+ {hasDetails && (
42
+ <div className={styles.confirmationDetails}>
43
+ {casePreview.archived && (
44
+ <div className={styles.archivedImportNote}>
45
+ {ARCHIVED_SELF_IMPORT_NOTE}
46
+ </div>
47
+ )}
48
+ {isArchivedRegularCaseImportBlocked && (
49
+ <div className={styles.archivedRegularCaseRiskNote}>
50
+ {archivedRegularCaseBlockMessage}
51
+ </div>
52
+ )}
51
53
  </div>
52
- {casePreview.archived && (
53
- <div className={styles.archivedImportNote}>
54
- Archived export detected. Original exporter imports are allowed for archived cases.
55
- </div>
56
- )}
57
- {isArchivedRegularCaseImportBlocked && (
58
- <div className={styles.archivedRegularCaseRiskNote}>
59
- {archivedRegularCaseBlockMessage}
60
- </div>
61
- )}
62
- {casePreview.hashValid !== undefined && (
63
- <div className={`${styles.confirmationItem} ${casePreview.hashValid ? styles.confirmationItemValid : styles.confirmationItemInvalid}`}>
64
- <strong>Data Integrity:</strong>
65
- <span className={casePreview.hashValid ? styles.confirmationSuccess : styles.confirmationError}>
66
- {casePreview.hashValid ? DATA_INTEGRITY_VALIDATION_PASSED : DATA_INTEGRITY_VALIDATION_FAILED}
67
- </span>
68
- </div>
69
- )}
70
- </div>
54
+ )}
71
55
 
72
56
  <div className={styles.confirmationButtons}>
73
57
  <button
@@ -1,13 +1,6 @@
1
1
  import styles from '../case-import.module.css';
2
2
 
3
- // Confirmation preview interface
4
- export interface ConfirmationPreview {
5
- caseNumber: string;
6
- fullName: string;
7
- exportDate: string;
8
- totalConfirmations: number;
9
- confirmationIds: string[];
10
- }
3
+ export type ConfirmationPreview = Record<string, never>;
11
4
 
12
5
  interface ConfirmationPreviewSectionProps {
13
6
  confirmationPreview: ConfirmationPreview | null;
@@ -29,43 +22,10 @@ export const ConfirmationPreviewSection = ({ confirmationPreview, isLoadingPrevi
29
22
 
30
23
  return (
31
24
  <div className={styles.previewSection}>
32
- <h3 className={styles.previewTitle}>Confirmation Data Information</h3>
33
- <div className={styles.previewGrid}>
34
- <div className={styles.previewItem}>
35
- <span className={styles.previewLabel}>Case Number:</span>
36
- <span className={styles.previewValue}>{confirmationPreview.caseNumber}</span>
37
- </div>
38
- <div className={styles.previewItem}>
39
- <span className={styles.previewLabel}>Exported by:</span>
40
- <span className={styles.previewValue}>{confirmationPreview.fullName}</span>
41
- </div>
42
- <div className={styles.previewItem}>
43
- <span className={styles.previewLabel}>Export Date:</span>
44
- <span className={styles.previewValue}>
45
- {new Date(confirmationPreview.exportDate).toLocaleDateString(undefined, {
46
- year: 'numeric',
47
- month: 'long',
48
- day: 'numeric',
49
- hour: '2-digit',
50
- minute: '2-digit',
51
- timeZoneName: 'short'
52
- })}
53
- </span>
54
- </div>
55
- <div className={styles.previewItem}>
56
- <span className={styles.previewLabel}>Total Confirmations:</span>
57
- <span className={styles.previewValue}>{confirmationPreview.totalConfirmations}</span>
58
- </div>
59
- <div className={styles.previewItem}>
60
- <span className={styles.previewLabel}>Confirmation IDs:</span>
61
- <span className={styles.previewValue}>
62
- {confirmationPreview.confirmationIds.length > 0
63
- ? confirmationPreview.confirmationIds.join(', ')
64
- : 'None'
65
- }
66
- </span>
67
- </div>
68
- </div>
25
+ <h3 className={styles.previewTitle}>Confirmation Import Preview</h3>
26
+ <p className={styles.previewMessage}>
27
+ Confirmation package detected. Details are hidden until import verification completes.
28
+ </p>
69
29
  </div>
70
30
  );
71
31
  };
@@ -61,8 +61,7 @@ export const FileSelector = ({
61
61
  const file = files[0];
62
62
 
63
63
  // Check file type (same as input accept attribute)
64
- const isValidType = file.name.toLowerCase().endsWith('.zip') ||
65
- file.name.toLowerCase().endsWith('.json');
64
+ const isValidType = file.name.toLowerCase().endsWith('.zip');
66
65
 
67
66
  if (isValidType) {
68
67
  if (onFileSelectDirect) {
@@ -92,11 +91,11 @@ export const FileSelector = ({
92
91
  ref={fileInputRef}
93
92
  type="file"
94
93
  id="zipFile"
95
- accept=".zip,.json"
94
+ accept=".zip"
96
95
  onChange={onFileSelect}
97
96
  disabled={isDisabled}
98
97
  className={styles.fileInput}
99
- aria-label="File picker for ZIP or JSON files"
98
+ aria-label="File picker for ZIP packages"
100
99
  />
101
100
  <div
102
101
  className={`${styles.fileLabel} ${isDragOver ? styles.fileLabelDragOver : ''}`}
@@ -111,7 +110,7 @@ export const FileSelector = ({
111
110
  role="button"
112
111
  tabIndex={isDisabled ? -1 : 0}
113
112
  aria-disabled={isDisabled}
114
- aria-label="File selection area. Drag and drop a ZIP file for case import or JSON file for confirmation import."
113
+ aria-label="File selection area. Drag and drop a case ZIP or encrypted confirmation ZIP package for import."
115
114
  onKeyDown={(e) => {
116
115
  if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) {
117
116
  if (e.key === ' ') {
@@ -128,7 +127,7 @@ export const FileSelector = ({
128
127
  ? selectedFile.name
129
128
  : isDragOver
130
129
  ? 'Drop file here...'
131
- : 'Select ZIP or JSON file... or drag & drop'
130
+ : 'Select ZIP package... or drag & drop'
132
131
  }
133
132
  </span>
134
133
  </div>
@@ -4,11 +4,6 @@ import { previewCaseImport, extractConfirmationImportPackage } from '~/component
4
4
  import { type CaseImportPreview } from '~/types';
5
5
  import { type ConfirmationPreview } from '../components/ConfirmationPreviewSection';
6
6
 
7
- type UnknownRecord = Record<string, unknown>;
8
-
9
- const isRecord = (value: unknown): value is UnknownRecord =>
10
- typeof value === 'object' && value !== null && !Array.isArray(value);
11
-
12
7
  interface UseFilePreviewReturn {
13
8
  casePreview: CaseImportPreview | null;
14
9
  confirmationPreview: ConfirmationPreview | null;
@@ -56,50 +51,9 @@ export const useFilePreview = (
56
51
 
57
52
  setIsLoadingPreview(true);
58
53
  try {
59
- const { confirmationData } = await extractConfirmationImportPackage(file);
60
- const parsed = confirmationData as unknown;
61
-
62
- if (!isRecord(parsed)) {
63
- throw new Error('Invalid confirmation data format');
64
- }
65
-
66
- const metadata = isRecord(parsed.metadata) ? parsed.metadata : undefined;
67
- const confirmations = isRecord(parsed.confirmations) ? parsed.confirmations : undefined;
68
-
69
- // Extract confirmation IDs from the confirmations object
70
- const confirmationIds: string[] = [];
71
- if (confirmations) {
72
- Object.values(confirmations).forEach((imageConfirmations) => {
73
- if (Array.isArray(imageConfirmations)) {
74
- imageConfirmations.forEach((confirmation) => {
75
- if (isRecord(confirmation) && typeof confirmation.confirmationId === 'string') {
76
- confirmationIds.push(confirmation.confirmationId);
77
- }
78
- });
79
- }
80
- });
81
- }
82
-
83
- const caseNumber =
84
- metadata && typeof metadata.caseNumber === 'string' ? metadata.caseNumber : 'Unknown';
85
- const fullName =
86
- metadata && typeof metadata.exportedByName === 'string' ? metadata.exportedByName : 'Unknown';
87
- const exportDate =
88
- metadata && typeof metadata.exportDate === 'string'
89
- ? metadata.exportDate
90
- : new Date().toISOString();
91
- const totalConfirmations =
92
- metadata && typeof metadata.totalConfirmations === 'number'
93
- ? metadata.totalConfirmations
94
- : confirmationIds.length;
54
+ await extractConfirmationImportPackage(file);
95
55
 
96
- const preview: ConfirmationPreview = {
97
- caseNumber,
98
- fullName,
99
- exportDate,
100
- totalConfirmations,
101
- confirmationIds
102
- };
56
+ const preview: ConfirmationPreview = {};
103
57
 
104
58
  setConfirmationPreview(preview);
105
59
  } catch (error) {
@@ -1,8 +1,7 @@
1
- import { isConfirmationDataFile } from '~/components/actions/case-review';
2
-
3
1
  const CASE_EXPORT_DATA_FILE_REGEX = /_data\.(json|csv)$/i;
4
2
  const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
5
3
  const FORENSIC_MANIFEST_FILE_NAME = 'forensic_manifest.json';
4
+ const ENCRYPTION_MANIFEST_FILE_NAME = 'encryption_manifest.json';
6
5
 
7
6
  function getLeafFileName(path: string): string {
8
7
  const segments = path.split('/').filter(Boolean);
@@ -19,20 +18,10 @@ export const isValidZipFile = (file: File): boolean => {
19
18
  };
20
19
 
21
20
  /**
22
- * Check if a file is a valid confirmation JSON file
23
- */
24
- export const isValidConfirmationFile = (file: File): boolean => {
25
- const lowerName = file.name.toLowerCase();
26
- const jsonType = file.type === 'application/json' || file.type === '';
27
-
28
- return lowerName.endsWith('.json') && jsonType && isConfirmationDataFile(file.name);
29
- };
30
-
31
- /**
32
- * Check if a file is valid for import (either ZIP or confirmation JSON)
21
+ * Check if a file is valid for import (ZIP packages only)
33
22
  */
34
23
  export const isValidImportFile = (file: File): boolean => {
35
- return isValidZipFile(file) || isValidConfirmationFile(file);
24
+ return isValidZipFile(file);
36
25
  };
37
26
 
38
27
  /**
@@ -40,20 +29,15 @@ export const isValidImportFile = (file: File): boolean => {
40
29
  */
41
30
  export const getImportType = (file: File): 'case' | 'confirmation' | null => {
42
31
  if (isValidZipFile(file)) return 'case';
43
- if (isValidConfirmationFile(file)) return 'confirmation';
44
32
  return null;
45
33
  };
46
34
 
47
35
  /**
48
36
  * Resolve import type, including ZIP package inspection.
49
37
  * Case ZIPs are identified by case data files or FORENSIC_MANIFEST.json.
50
- * Confirmation ZIPs are identified by confirmation-data-*.json.
38
+ * Confirmation ZIPs are identified by confirmation-data-*.json plus ENCRYPTION_MANIFEST.json.
51
39
  */
52
40
  export const resolveImportType = async (file: File): Promise<'case' | 'confirmation' | null> => {
53
- if (isValidConfirmationFile(file)) {
54
- return 'confirmation';
55
- }
56
-
57
41
  if (!isValidZipFile(file)) {
58
42
  return null;
59
43
  }
@@ -78,7 +62,11 @@ export const resolveImportType = async (file: File): Promise<'case' | 'confirmat
78
62
  CONFIRMATION_EXPORT_FILE_REGEX.test(getLeafFileName(path))
79
63
  );
80
64
 
81
- if (hasConfirmationData) {
65
+ const hasEncryptionManifest = fileEntries.some(
66
+ (path) => getLeafFileName(path).toLowerCase() === ENCRYPTION_MANIFEST_FILE_NAME
67
+ );
68
+
69
+ if (hasConfirmationData && hasEncryptionManifest) {
82
70
  return 'confirmation';
83
71
  }
84
72
 
@@ -6,6 +6,11 @@
6
6
  "manifest_signing_public_keys": {
7
7
  "MANIFEST_SIGNING_KEY_ID": "MANIFEST_SIGNING_PUBLIC_KEY"
8
8
  },
9
+ "export_encryption_key_id": "EXPORT_ENCRYPTION_KEY_ID",
10
+ "export_encryption_public_key": "EXPORT_ENCRYPTION_PUBLIC_KEY",
11
+ "export_encryption_public_keys": {
12
+ "EXPORT_ENCRYPTION_KEY_ID": "EXPORT_ENCRYPTION_PUBLIC_KEY"
13
+ },
9
14
  "max_cases_review": 0,
10
15
  "max_files_per_case_review": 0
11
16
  }
@@ -28,7 +28,7 @@ import { generateUniqueId } from '~/utils/common';
28
28
  import { evaluatePasswordPolicy, buildActionCodeSettings, userHasMFA } from '~/utils/auth';
29
29
  import type { UserData } from '~/types';
30
30
 
31
- const APP_CANONICAL_ORIGIN = 'PAGES_CUSTOM_DOMAIN';
31
+ const APP_CANONICAL_ORIGIN = 'https://striae.app';
32
32
  const SOCIAL_IMAGE_PATH = '/social-image.png';
33
33
  const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
34
34
  const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
@@ -26,6 +26,7 @@ interface UseStriaeResetHelpersProps {
26
26
  setShowNotes: Dispatch<SetStateAction<boolean>>;
27
27
  setIsAuditTrailOpen: Dispatch<SetStateAction<boolean>>;
28
28
  setIsRenameCaseModalOpen: Dispatch<SetStateAction<boolean>>;
29
+ onRevokeImage?: () => void;
29
30
  }
30
31
 
31
32
  export const useStriaeResetHelpers = ({
@@ -45,8 +46,10 @@ export const useStriaeResetHelpers = ({
45
46
  setShowNotes,
46
47
  setIsAuditTrailOpen,
47
48
  setIsRenameCaseModalOpen,
49
+ onRevokeImage,
48
50
  }: UseStriaeResetHelpersProps) => {
49
51
  const clearSelectedImageState = useCallback(() => {
52
+ onRevokeImage?.();
50
53
  setSelectedImage('/clear.jpg');
51
54
  setSelectedFilename(undefined);
52
55
  setImageId(undefined);
@@ -54,6 +57,7 @@ export const useStriaeResetHelpers = ({
54
57
  setError(undefined);
55
58
  setImageLoaded(false);
56
59
  }, [
60
+ onRevokeImage,
57
61
  setSelectedImage,
58
62
  setSelectedFilename,
59
63
  setImageId,
@@ -1,5 +1,5 @@
1
1
  import type { User } from 'firebase/auth';
2
- import { useState, useEffect } from 'react';
2
+ import { useState, useEffect, useRef, useCallback } from 'react';
3
3
  import { SidebarContainer } from '~/components/sidebar/sidebar-container';
4
4
  import { Navbar } from '~/components/navbar/navbar';
5
5
  import { RenameCaseModal } from '~/components/navbar/case-modals/rename-case-modal';
@@ -47,6 +47,7 @@ export const Striae = ({ user }: StriaePage) => {
47
47
  const [imageId, setImageId] = useState<string>();
48
48
  const [error, setError] = useState<string>();
49
49
  const [imageLoaded, setImageLoaded] = useState(false);
50
+ const currentRevokeRef = useRef<(() => void) | null>(null);
50
51
 
51
52
  // User states
52
53
  const [userCompany, setUserCompany] = useState<string>('');
@@ -98,6 +99,11 @@ export const Striae = ({ user }: StriaePage) => {
98
99
  archiveReason?: string;
99
100
  }>({ archived: false });
100
101
 
102
+ const handleRevokeImage = useCallback(() => {
103
+ currentRevokeRef.current?.();
104
+ currentRevokeRef.current = null;
105
+ }, []);
106
+
101
107
  const {
102
108
  clearSelectedImageState,
103
109
  clearCaseContextState,
@@ -119,6 +125,7 @@ export const Striae = ({ user }: StriaePage) => {
119
125
  setShowNotes,
120
126
  setIsAuditTrailOpen,
121
127
  setIsRenameCaseModalOpen,
128
+ onRevokeImage: handleRevokeImage,
122
129
  });
123
130
 
124
131
 
@@ -574,6 +581,8 @@ export const Striae = ({ user }: StriaePage) => {
574
581
  useEffect(() => {
575
582
  // Cleanup function to clear image when component unmounts
576
583
  return () => {
584
+ currentRevokeRef.current?.();
585
+ currentRevokeRef.current = null;
577
586
  setSelectedImage(undefined);
578
587
  setSelectedFilename(undefined);
579
588
  setError(undefined);
@@ -645,14 +654,16 @@ export const Striae = ({ user }: StriaePage) => {
645
654
 
646
655
  try {
647
656
  setError(undefined);
657
+ currentRevokeRef.current?.();
658
+ currentRevokeRef.current = null;
648
659
  setSelectedImage(undefined);
649
660
  setSelectedFilename(undefined);
650
661
  setImageLoaded(false);
651
662
 
652
- const signedUrl = await getImageUrl(user, file, currentCase);
653
- if (!signedUrl) throw new Error('No URL returned');
663
+ const { url, revoke } = await getImageUrl(user, file, currentCase);
664
+ currentRevokeRef.current = revoke;
654
665
 
655
- setSelectedImage(signedUrl);
666
+ setSelectedImage(url);
656
667
  setSelectedFilename(file.originalFilename);
657
668
  setImageId(file.id);
658
669
  setImageLoaded(true);
@@ -43,7 +43,19 @@ export const getCaseData = async (
43
43
  }
44
44
 
45
45
  if (!response.ok) {
46
- throw new Error(`Failed to fetch case data: ${response.status} ${response.statusText}`);
46
+ let errorDetails = '';
47
+
48
+ try {
49
+ const errorPayload = await response.json() as { error?: unknown };
50
+ if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
51
+ errorDetails = errorPayload.error.trim();
52
+ }
53
+ } catch {
54
+ // Ignore parse errors and fall back to status text only.
55
+ }
56
+
57
+ const baseMessage = `Failed to fetch case data: ${response.status} ${response.statusText}`;
58
+ throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
47
59
  }
48
60
 
49
61
  const caseData = await response.json() as CaseData;
@@ -86,7 +86,19 @@ export const getConfirmationSummaryDocument = async (
86
86
  });
87
87
 
88
88
  if (!response.ok) {
89
- throw new Error(`Failed to fetch confirmation summary: ${response.status} ${response.statusText}`);
89
+ let errorDetails = '';
90
+
91
+ try {
92
+ const errorPayload = await response.json() as { error?: unknown };
93
+ if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
94
+ errorDetails = errorPayload.error.trim();
95
+ }
96
+ } catch {
97
+ // Ignore parse errors and fall back to status text only.
98
+ }
99
+
100
+ const baseMessage = `Failed to fetch confirmation summary: ${response.status} ${response.statusText}`;
101
+ throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
90
102
  }
91
103
 
92
104
  const payload = await response.json().catch(() => null) as unknown;
@@ -299,3 +311,28 @@ export const removeCaseConfirmationSummary = async (
299
311
 
300
312
  await saveConfirmationSummaryDocument(user, summary);
301
313
  };
314
+
315
+ export const moveCaseConfirmationSummary = async (
316
+ user: User,
317
+ fromCaseNumber: string,
318
+ toCaseNumber: string
319
+ ): Promise<void> => {
320
+ if (fromCaseNumber === toCaseNumber) {
321
+ return;
322
+ }
323
+
324
+ const summary = await getConfirmationSummaryDocument(user);
325
+ const existingCaseSummary = summary.cases[fromCaseNumber];
326
+ if (!existingCaseSummary) {
327
+ return;
328
+ }
329
+
330
+ delete summary.cases[fromCaseNumber];
331
+ summary.cases[toCaseNumber] = {
332
+ ...existingCaseSummary,
333
+ updatedAt: getIsoNow(),
334
+ };
335
+ summary.updatedAt = getIsoNow();
336
+
337
+ await saveConfirmationSummaryDocument(user, summary);
338
+ };
@@ -42,7 +42,19 @@ export const getFileAnnotations = async (
42
42
  }
43
43
 
44
44
  if (!response.ok) {
45
- throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
45
+ let errorDetails = '';
46
+
47
+ try {
48
+ const errorPayload = await response.json() as { error?: unknown };
49
+ if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
50
+ errorDetails = errorPayload.error.trim();
51
+ }
52
+ } catch {
53
+ // Ignore parse errors and fall back to status text only.
54
+ }
55
+
56
+ const baseMessage = `Failed to fetch file annotations: ${response.status} ${response.statusText}`;
57
+ throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
46
58
  }
47
59
 
48
60
  return await response.json() as AnnotationData;