@striae-org/striae 5.2.0 → 5.3.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 (105) hide show
  1. package/.env.example +36 -33
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +2 -174
  4. package/app/components/actions/case-export/download-handlers.ts +83 -750
  5. package/app/components/actions/case-export/index.ts +6 -30
  6. package/app/components/actions/case-export/metadata-helpers.ts +0 -78
  7. package/app/components/actions/case-export/types-constants.ts +0 -43
  8. package/app/components/actions/case-import/confirmation-import.ts +13 -14
  9. package/app/components/actions/case-import/zip-processing.ts +92 -12
  10. package/app/components/actions/generate-pdf.ts +3 -2
  11. package/app/components/audit/user-audit-viewer.tsx +0 -19
  12. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  13. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  14. package/app/components/navbar/navbar.tsx +1 -1
  15. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
  19. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  20. package/app/components/toast/toast.module.css +36 -0
  21. package/app/components/toast/toast.tsx +6 -2
  22. package/app/components/user/manage-profile.tsx +4 -3
  23. package/app/config-example/config.json +1 -2
  24. package/app/root.tsx +0 -7
  25. package/app/routes/_index.tsx +1 -1
  26. package/app/routes/auth/login.example.tsx +22 -103
  27. package/app/routes/auth/route.ts +1 -1
  28. package/app/routes/striae/striae.tsx +53 -59
  29. package/app/services/firebase/index.ts +0 -3
  30. package/app/types/export.ts +1 -2
  31. package/app/utils/auth/index.ts +0 -1
  32. package/app/utils/data/permissions.ts +3 -2
  33. package/package.json +10 -17
  34. package/public/_headers +0 -4
  35. package/public/_routes.json +0 -1
  36. package/worker-configuration.d.ts +20 -17
  37. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  38. package/workers/audit-worker/src/config.ts +7 -0
  39. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  40. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  41. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  42. package/workers/audit-worker/src/types.ts +56 -0
  43. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  45. package/workers/data-worker/src/config.ts +11 -0
  46. package/workers/data-worker/src/data-worker.example.ts +21 -942
  47. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  48. package/workers/data-worker/src/handlers/signing.ts +174 -0
  49. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  50. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  51. package/workers/data-worker/src/types.ts +46 -0
  52. package/workers/data-worker/worker-configuration.d.ts +1 -1
  53. package/workers/data-worker/wrangler.jsonc.example +1 -1
  54. package/workers/image-worker/worker-configuration.d.ts +1 -1
  55. package/workers/image-worker/wrangler.jsonc.example +1 -1
  56. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  57. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  58. package/workers/user-worker/src/auth.ts +30 -0
  59. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  60. package/workers/user-worker/src/config.ts +4 -0
  61. package/workers/user-worker/src/encryption-utils.ts +25 -0
  62. package/workers/user-worker/src/firebase/admin.ts +152 -0
  63. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  64. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  65. package/workers/user-worker/src/storage/user-records.ts +34 -0
  66. package/workers/user-worker/src/types.ts +106 -0
  67. package/workers/user-worker/src/user-worker.example.ts +18 -964
  68. package/workers/user-worker/worker-configuration.d.ts +4 -2
  69. package/workers/user-worker/wrangler.jsonc.example +12 -1
  70. package/wrangler.toml.example +1 -1
  71. package/app/components/actions/case-export/data-processing.ts +0 -223
  72. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  73. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  74. package/app/types/exceljs-bare.d.ts +0 -9
  75. package/app/utils/auth/auth.ts +0 -11
  76. package/public/.well-known/security.txt +0 -6
  77. package/public/favicon.ico +0 -0
  78. package/public/icon-256.png +0 -0
  79. package/public/icon-512.png +0 -0
  80. package/public/manifest.json +0 -39
  81. package/public/shortcut.png +0 -0
  82. package/public/social-image.png +0 -0
  83. package/public/vendor/exceljs.LICENSE +0 -22
  84. package/public/vendor/exceljs.bare.min.js +0 -45
  85. package/scripts/deploy-all.sh +0 -166
  86. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  87. package/scripts/deploy-config/modules/keys.sh +0 -404
  88. package/scripts/deploy-config/modules/prompt.sh +0 -372
  89. package/scripts/deploy-config/modules/scaffolding.sh +0 -336
  90. package/scripts/deploy-config/modules/validation.sh +0 -365
  91. package/scripts/deploy-config.sh +0 -236
  92. package/scripts/deploy-pages-secrets.sh +0 -231
  93. package/scripts/deploy-pages.sh +0 -34
  94. package/scripts/deploy-primershear-emails.sh +0 -167
  95. package/scripts/deploy-worker-secrets.sh +0 -374
  96. package/scripts/dev.cjs +0 -23
  97. package/scripts/install-workers.sh +0 -88
  98. package/scripts/run-eslint.cjs +0 -43
  99. package/scripts/update-compatibility-dates.cjs +0 -124
  100. package/scripts/update-markdown-versions.cjs +0 -43
  101. package/workers/keys-worker/package.json +0 -18
  102. package/workers/keys-worker/src/keys.example.ts +0 -67
  103. package/workers/keys-worker/src/keys.ts +0 -67
  104. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  105. package/workers/keys-worker/wrangler.jsonc.example +0 -15
@@ -2,40 +2,16 @@
2
2
  // This maintains backward compatibility with existing imports
3
3
 
4
4
  // Types and constants
5
- export type { ExportFormat } from './types-constants';
6
- export { CSV_HEADERS, formatDateForFilename } from './types-constants';
5
+ export { formatDateForFilename } from './types-constants';
7
6
 
8
- // Metadata and protection helpers
9
- export {
10
- getUserExportMetadata,
11
- addForensicDataWarning,
12
- generateRandomPassword,
13
- protectExcelWorksheet
14
- } from './metadata-helpers';
15
-
16
- // Data processing functions
17
- export {
18
- generateMetadataRows,
19
- processFileDataForTabular,
20
- generateCSVContent
21
- } from './data-processing';
7
+ // Metadata helpers
8
+ export { getUserExportMetadata } from './metadata-helpers';
22
9
 
23
10
  // Core export functions
24
- export {
25
- exportAllCases,
26
- exportCaseData
27
- } from './core-export';
11
+ export { exportCaseData } from './core-export';
28
12
 
29
13
  // Download handlers
30
- export {
31
- downloadAllCasesAsJSON,
32
- downloadAllCasesAsCSV,
33
- downloadCaseAsJSON,
34
- downloadCaseAsCSV,
35
- downloadCaseAsZip
36
- } from './download-handlers';
14
+ export { downloadCaseAsZip } from './download-handlers';
37
15
 
38
16
  // Validation utilities
39
- export {
40
- validateCaseNumberForExport
41
- } from './validation-utils';
17
+ export { validateCaseNumberForExport } from './validation-utils';
@@ -46,81 +46,3 @@ export function addForensicDataWarning(content: string, format: 'csv' | 'json'):
46
46
  return warning + content;
47
47
  }
48
48
 
49
- /**
50
- * Generate a secure random password for Excel protection
51
- */
52
- export function generateRandomPassword(): string {
53
- const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
54
- const length = 16;
55
- let password = '';
56
-
57
- // Ensure we have at least one of each type
58
- password += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)]; // Uppercase
59
- password += 'abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 26)]; // Lowercase
60
- password += '0123456789'[Math.floor(Math.random() * 10)]; // Number
61
- password += '!@#$%^&*'[Math.floor(Math.random() * 8)]; // Special char
62
-
63
- // Fill remaining length with random characters
64
- for (let i = password.length; i < length; i++) {
65
- password += charset[Math.floor(Math.random() * charset.length)];
66
- }
67
-
68
- // Shuffle the password to randomize character positions
69
- return password.split('').sort(() => Math.random() - 0.5).join('');
70
- }
71
-
72
- type WorksheetProtectionOptions = {
73
- selectLockedCells: boolean;
74
- selectUnlockedCells: boolean;
75
- formatCells: boolean;
76
- formatColumns: boolean;
77
- formatRows: boolean;
78
- insertColumns: boolean;
79
- insertRows: boolean;
80
- insertHyperlinks: boolean;
81
- deleteColumns: boolean;
82
- deleteRows: boolean;
83
- sort: boolean;
84
- autoFilter: boolean;
85
- pivotTables: boolean;
86
- objects: boolean;
87
- scenarios: boolean;
88
- spinCount: number;
89
- };
90
-
91
- type ProtectableWorksheet = {
92
- protect: (password: string, options: Record<string, unknown>) => Promise<unknown> | unknown;
93
- };
94
-
95
- /**
96
- * Protect Excel worksheet from editing
97
- */
98
- export async function protectExcelWorksheet(worksheet: ProtectableWorksheet, sheetPassword?: string): Promise<string> {
99
- // Generate random password if none provided
100
- const password = sheetPassword || generateRandomPassword();
101
-
102
- const protectionOptions: WorksheetProtectionOptions = {
103
- // Keep read-only defaults and prevent structural edits.
104
- selectLockedCells: true,
105
- selectUnlockedCells: true,
106
- formatCells: false,
107
- formatColumns: false,
108
- formatRows: false,
109
- insertColumns: false,
110
- insertRows: false,
111
- insertHyperlinks: false,
112
- deleteColumns: false,
113
- deleteRows: false,
114
- sort: false,
115
- autoFilter: false,
116
- pivotTables: false,
117
- objects: false,
118
- scenarios: false,
119
- spinCount: 100000
120
- };
121
-
122
- await Promise.resolve(worksheet.protect(password, protectionOptions as Record<string, unknown>));
123
-
124
- // Return the password for inclusion in metadata
125
- return password;
126
- }
@@ -1,46 +1,3 @@
1
- export type ExportFormat = 'json' | 'csv';
2
-
3
- // Shared CSV headers for all tabular exports
4
- export const CSV_HEADERS = [
5
- 'File ID',
6
- 'Original Filename',
7
- 'Upload Date',
8
- 'Has Annotations',
9
- 'Left Case',
10
- 'Right Case',
11
- 'Left Item',
12
- 'Right Item',
13
- 'Case Font Color',
14
- 'Class Type',
15
- 'Custom Class',
16
- 'Class Note',
17
- 'Index Type',
18
- 'Index Number',
19
- 'Index Color',
20
- 'Support Level',
21
- 'Has Subclass',
22
- 'Include Confirmation',
23
- 'Confirmation Status',
24
- 'Confirming Examiner Name',
25
- 'Confirming Examiner Badge ID',
26
- 'Confirming Examiner Email',
27
- 'Confirming Examiner Company',
28
- 'Confirmation ID',
29
- 'Confirmation Timestamp',
30
- 'Confirmation Date (ISO)',
31
- 'Total Box Annotations',
32
- 'Box ID',
33
- 'Box X',
34
- 'Box Y',
35
- 'Box Width',
36
- 'Box Height',
37
- 'Box Color',
38
- 'Box Label',
39
- 'Box Timestamp',
40
- 'Additional Notes',
41
- 'Last Updated'
42
- ];
43
-
44
1
  /**
45
2
  * Helper function to format timestamp for filename using user's local timezone
46
3
  */
@@ -297,15 +297,14 @@ export async function importConfirmationData(
297
297
 
298
298
  // Audit log successful confirmation import
299
299
  try {
300
- await auditService.logAnnotationEdit(
300
+ await auditService.logConfirmationImport(
301
301
  user,
302
- `${result.caseNumber}-${currentImageId}`,
303
- annotationData, // Previous state (without confirmation)
304
- updatedAnnotationData, // New state (with confirmation)
305
302
  result.caseNumber,
306
- 'confirmation-import',
307
- currentImageId,
308
- displayFilename
303
+ displayFilename,
304
+ 'success',
305
+ true,
306
+ confirmations.length,
307
+ [displayFilename]
309
308
  );
310
309
  } catch (auditError) {
311
310
  console.error('Failed to log confirmation import audit:', auditError);
@@ -315,15 +314,15 @@ export async function importConfirmationData(
315
314
 
316
315
  // Audit log failed confirmation import
317
316
  try {
318
- await auditService.logAnnotationEdit(
317
+ await auditService.logConfirmationImport(
319
318
  user,
320
- `${result.caseNumber}-${currentImageId}`,
321
- annotationData, // Previous state
322
- null, // Failed save
323
319
  result.caseNumber,
324
- 'confirmation-import',
325
- currentImageId,
326
- displayFilename
320
+ displayFilename,
321
+ 'failure',
322
+ false,
323
+ 0,
324
+ [],
325
+ [`Failed to update image ${displayFilename}: ${saveResponse.status}`]
327
326
  );
328
327
  } catch (auditError) {
329
328
  console.error('Failed to log failed confirmation import audit:', auditError);
@@ -103,6 +103,82 @@ function extractImageIdFromFilename(exportFilename: string): string | null {
103
103
  return filenameWithoutExt.substring(lastHyphenIndex + 1);
104
104
  }
105
105
 
106
+ interface ReadmeCaseInfo {
107
+ caseNumber: string | null;
108
+ exportedBy: string | null;
109
+ exportedByName: string | null;
110
+ exportedByCompany: string | null;
111
+ exportedByBadgeId: string | null;
112
+ exportDate: string | null;
113
+ caseCreatedDate: string | null;
114
+ totalFiles: number;
115
+ isArchived: boolean;
116
+ }
117
+
118
+ /**
119
+ * Parse case metadata from a README.txt included in a case package ZIP.
120
+ * Handles both standard export format and archived package format.
121
+ * Falls back gracefully when fields are absent or README is missing.
122
+ */
123
+ function parseReadmeCaseInfo(readme: string | null, fallbackTotalFiles: number): ReadmeCaseInfo {
124
+ const result: ReadmeCaseInfo = {
125
+ caseNumber: null,
126
+ exportedBy: null,
127
+ exportedByName: null,
128
+ exportedByCompany: null,
129
+ exportedByBadgeId: null,
130
+ exportDate: null,
131
+ caseCreatedDate: null,
132
+ totalFiles: fallbackTotalFiles,
133
+ isArchived: false
134
+ };
135
+
136
+ if (!readme) return result;
137
+
138
+ const isArchived = readme.trimStart().startsWith('Striae Archived Case Package');
139
+ result.isArchived = isArchived;
140
+
141
+ const field = (key: string): string | null => {
142
+ const regex = new RegExp(`^${key}:\\s*(.+)$`, 'm');
143
+ const match = readme.match(regex);
144
+ if (!match) return null;
145
+ const value = match[1].trim();
146
+ return value === 'N/A' || value === '' ? null : value;
147
+ };
148
+
149
+ result.caseNumber = field('Case Number');
150
+
151
+ if (isArchived) {
152
+ result.exportDate = field('Archived At');
153
+ const archivedBy = field('Archived By');
154
+ if (archivedBy) {
155
+ // Format: "Name (email)" or just "Name" — extract name and optional email
156
+ const parenMatch = archivedBy.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
157
+ if (parenMatch) {
158
+ result.exportedByName = parenMatch[1].trim() || null;
159
+ result.exportedBy = parenMatch[2].trim() || null;
160
+ } else {
161
+ result.exportedByName = archivedBy;
162
+ }
163
+ }
164
+ } else {
165
+ result.exportDate = field('Export Date');
166
+ result.caseCreatedDate = field('Case Created Date');
167
+ result.exportedBy = field('Exported By \\(Email\\)');
168
+ result.exportedByName = field('Exported By \\(Name\\)');
169
+ result.exportedByCompany = field('Exported By \\(Company\\)');
170
+ result.exportedByBadgeId = field('Exported By \\(Badge\\/ID\\)');
171
+
172
+ const totalFilesStr = field('- Total Files');
173
+ if (totalFilesStr !== null) {
174
+ const parsed = parseInt(totalFilesStr, 10);
175
+ if (!isNaN(parsed)) result.totalFiles = parsed;
176
+ }
177
+ }
178
+
179
+ return result;
180
+ }
181
+
106
182
  /**
107
183
  * Preview case information from ZIP file without importing
108
184
  */
@@ -116,12 +192,10 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
116
192
  // Check if export is encrypted
117
193
  const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
118
194
  if (encryptionManifestFile) {
119
- // For encrypted exports, we can't read the plaintext data to extract case info
120
- // Return an encrypted preview that requires decryption during import
121
195
  try {
122
196
  const manifestContent = await encryptionManifestFile.async('text');
123
197
  JSON.parse(manifestContent); // Validate it's valid JSON
124
-
198
+
125
199
  // Count image files
126
200
  let totalFiles = 0;
127
201
  const imagesFolder = zip.folder('images');
@@ -132,18 +206,24 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
132
206
  }
133
207
  }
134
208
  }
135
-
209
+
136
210
  const hasForensicManifest = zip.file('FORENSIC_MANIFEST.json') !== null;
137
211
 
212
+ // Read README.txt to surface case metadata without decrypting
213
+ const readmeFile = zip.file('README.txt');
214
+ const readmeContent = readmeFile ? await readmeFile.async('text') : null;
215
+ const readmeMeta = parseReadmeCaseInfo(readmeContent, totalFiles);
216
+
138
217
  return {
139
- caseNumber: 'ENCRYPTED',
140
- archived: false,
141
- exportedBy: null,
142
- exportedByName: null,
143
- exportedByCompany: null,
144
- exportedByBadgeId: null,
145
- exportDate: new Date().toISOString(),
146
- totalFiles,
218
+ caseNumber: readmeMeta.caseNumber ?? 'ENCRYPTED',
219
+ archived: readmeMeta.isArchived,
220
+ exportedBy: readmeMeta.exportedBy,
221
+ exportedByName: readmeMeta.exportedByName,
222
+ exportedByCompany: readmeMeta.exportedByCompany,
223
+ exportedByBadgeId: readmeMeta.exportedByBadgeId,
224
+ exportDate: readmeMeta.exportDate ?? new Date().toISOString(),
225
+ totalFiles: readmeMeta.totalFiles,
226
+ caseCreatedDate: readmeMeta.caseCreatedDate ?? undefined,
147
227
  hasAnnotations: false,
148
228
  validationSummary: 'Export is encrypted. Integrity validation will occur during import.',
149
229
  hashValid: undefined,
@@ -2,6 +2,7 @@ import { type AnnotationData } from '~/types/annotations';
2
2
  import { auditService } from '~/services/audit';
3
3
  import type { User } from 'firebase/auth';
4
4
  import { fetchPdfApi } from '~/utils/api';
5
+ import type { ToastType } from '~/components/toast/toast';
5
6
 
6
7
  interface GeneratePDFParams {
7
8
  user: User;
@@ -16,7 +17,7 @@ interface GeneratePDFParams {
16
17
  annotationData: AnnotationData | null;
17
18
  activeAnnotations: Set<string>;
18
19
  setIsGeneratingPDF: (isGenerating: boolean) => void;
19
- setToastType: (type: 'success' | 'error') => void;
20
+ setToastType: (type: ToastType) => void;
20
21
  setToastMessage: (message: string) => void;
21
22
  setShowToast: (show: boolean) => void;
22
23
  setToastDuration?: (duration: number) => void;
@@ -90,7 +91,7 @@ export const generatePDF = async ({
90
91
  const startTime = Date.now();
91
92
 
92
93
  // Show generating toast immediately with duration 0 (stays until manually closed or completion)
93
- setToastType('success');
94
+ setToastType('loading');
94
95
  setToastMessage('Generating PDF report... This may take up to a minute.');
95
96
  if (setToastDuration) setToastDuration(0);
96
97
  setShowToast(true);
@@ -9,7 +9,6 @@ import { AuditEntriesList } from './viewer/audit-entries-list';
9
9
  import { summarizeAuditEntries } from './viewer/audit-viewer-utils';
10
10
  import { useAuditViewerData } from './viewer/use-audit-viewer-data';
11
11
  import { useAuditViewerFilters } from './viewer/use-audit-viewer-filters';
12
- import { useAuditViewerExport } from './viewer/use-audit-viewer-export';
13
12
  import styles from './user-audit.module.css';
14
13
 
15
14
  interface UserAuditViewerProps {
@@ -56,8 +55,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
56
55
  userData,
57
56
  loading,
58
57
  error,
59
- setError,
60
- auditTrail,
61
58
  isArchivedReadOnlyCase,
62
59
  bundledAuditWarning,
63
60
  loadAuditData
@@ -73,18 +70,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
73
70
  const filteredEntries = useMemo(() => getFilteredEntries(auditEntries), [auditEntries, getFilteredEntries]);
74
71
  const auditSummary = useMemo(() => summarizeAuditEntries(auditEntries), [auditEntries]);
75
72
 
76
- const {
77
- handleExportCSV,
78
- handleExportJSON,
79
- handleGenerateReport
80
- } = useAuditViewerExport({
81
- user,
82
- effectiveCaseNumber,
83
- filteredEntries,
84
- auditTrail,
85
- setError
86
- });
87
-
88
73
  const {
89
74
  requestClose,
90
75
  overlayProps
@@ -106,10 +91,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
106
91
  <div className={styles.modal}>
107
92
  <AuditViewerHeader
108
93
  title={title || (effectiveCaseNumber ? `Audit Trail - Case ${effectiveCaseNumber}` : 'My Audit Trail')}
109
- hasEntries={auditEntries.length > 0}
110
- onExportCSV={handleExportCSV}
111
- onExportJSON={handleExportJSON}
112
- onGenerateReport={handleGenerateReport}
113
94
  onClose={requestClose}
114
95
  />
115
96
 
@@ -2,50 +2,17 @@ import styles from '../user-audit.module.css';
2
2
 
3
3
  interface AuditViewerHeaderProps {
4
4
  title: string;
5
- hasEntries: boolean;
6
- onExportCSV: () => void;
7
- onExportJSON: () => void;
8
- onGenerateReport: () => void;
9
5
  onClose: () => void;
10
6
  }
11
7
 
12
8
  export const AuditViewerHeader = ({
13
9
  title,
14
- hasEntries,
15
- onExportCSV,
16
- onExportJSON,
17
- onGenerateReport,
18
10
  onClose,
19
11
  }: AuditViewerHeaderProps) => {
20
12
  return (
21
13
  <div className={styles.header}>
22
14
  <h2 className={styles.title}>{title}</h2>
23
15
  <div className={styles.headerActions}>
24
- {hasEntries && (
25
- <div className={styles.exportButtons}>
26
- <button
27
- onClick={onExportCSV}
28
- className={styles.exportButton}
29
- title="CSV - Individual entry log with summary data"
30
- >
31
- 📊 CSV
32
- </button>
33
- <button
34
- onClick={onExportJSON}
35
- className={styles.exportButton}
36
- title="JSON - Complete log data for version capture and auditing"
37
- >
38
- 📄 JSON
39
- </button>
40
- <button
41
- onClick={onGenerateReport}
42
- className={styles.exportButton}
43
- title="Summary report only"
44
- >
45
- 📋 Report
46
- </button>
47
- </div>
48
- )}
49
16
  <button className={styles.closeButton} onClick={onClose}>
50
17
  ×
51
18
  </button>
@@ -82,7 +82,7 @@ export const ArchiveCaseModal = ({
82
82
  Archiving a case permanently renders it read-only.
83
83
  </p>
84
84
  <p>
85
- The archive will be in JSON format and include all images.
85
+ The archive will be packaged as an encrypted case package and will always include all images.
86
86
  </p>
87
87
  <p>
88
88
  The full audit trail is packaged with Striae&apos;s current public key and forensic signatures.
@@ -194,7 +194,7 @@ export const Navbar = ({
194
194
  setIsCaseMenuOpen(false);
195
195
  }}
196
196
  >
197
- Export Case Data
197
+ {isReadOnly ? 'Export Confirmations' : 'Export Case Package'}
198
198
  </button>
199
199
  <button
200
200
  type="button"
@@ -461,6 +461,41 @@
461
461
  padding: var(--spaceM);
462
462
  }
463
463
 
464
+ .previewMeta {
465
+ display: flex;
466
+ flex-direction: column;
467
+ gap: var(--spaceS);
468
+ }
469
+
470
+ .previewMetaRow {
471
+ display: grid;
472
+ grid-template-columns: 100px 1fr;
473
+ gap: var(--spaceS);
474
+ font-size: var(--fontSizeBodyS);
475
+ }
476
+
477
+ .previewMetaLabel {
478
+ color: var(--textLight);
479
+ }
480
+
481
+ .previewMetaValue {
482
+ color: var(--textBody);
483
+ font-weight: var(--fontWeightMedium);
484
+ word-break: break-word;
485
+ }
486
+
487
+ .previewValidBadge {
488
+ color: var(--success);
489
+ font-size: var(--fontSizeBodyS);
490
+ font-weight: var(--fontWeightMedium);
491
+ }
492
+
493
+ .previewInvalidBadge {
494
+ color: var(--error);
495
+ font-size: var(--fontSizeBodyS);
496
+ font-weight: var(--fontWeightMedium);
497
+ }
498
+
464
499
  /* Confirmation Dialog */
465
500
  .confirmationOverlay {
466
501
  position: fixed;
@@ -11,6 +11,21 @@ interface CasePreviewSectionProps {
11
11
  isArchivedRegularCaseImportBlocked?: boolean;
12
12
  }
13
13
 
14
+ function formatDate(isoDate: string | undefined): string {
15
+ if (!isoDate) return 'Unknown';
16
+
17
+ const date = new Date(isoDate);
18
+ if (Number.isNaN(date.getTime())) {
19
+ return isoDate;
20
+ }
21
+
22
+ return date.toLocaleDateString(undefined, {
23
+ year: 'numeric',
24
+ month: 'short',
25
+ day: 'numeric'
26
+ });
27
+ }
28
+
14
29
  export const CasePreviewSection = ({
15
30
  casePreview,
16
31
  isLoadingPreview,
@@ -28,12 +43,53 @@ export const CasePreviewSection = ({
28
43
 
29
44
  if (!casePreview) return null;
30
45
 
46
+ const isEncrypted = casePreview.caseNumber === 'ENCRYPTED';
47
+
31
48
  return (
32
49
  <div className={styles.previewSection}>
33
50
  <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>
51
+ {isEncrypted ? (
52
+ <p className={styles.previewMessage}>
53
+ Encrypted package detected. Case details could not be read from the package.
54
+ </p>
55
+ ) : (
56
+ <div className={styles.previewMeta}>
57
+ <div className={styles.previewMetaRow}>
58
+ <span className={styles.previewMetaLabel}>Case</span>
59
+ <span className={styles.previewMetaValue}>{casePreview.caseNumber}</span>
60
+ </div>
61
+ {(casePreview.exportedByName ?? casePreview.exportedBy) && (
62
+ <div className={styles.previewMetaRow}>
63
+ <span className={styles.previewMetaLabel}>Exported by</span>
64
+ <span className={styles.previewMetaValue}>
65
+ {casePreview.exportedByName ?? casePreview.exportedBy}
66
+ </span>
67
+ </div>
68
+ )}
69
+ {casePreview.exportedByCompany && (
70
+ <div className={styles.previewMetaRow}>
71
+ <span className={styles.previewMetaLabel}>Organization</span>
72
+ <span className={styles.previewMetaValue}>{casePreview.exportedByCompany}</span>
73
+ </div>
74
+ )}
75
+ <div className={styles.previewMetaRow}>
76
+ <span className={styles.previewMetaLabel}>Exported</span>
77
+ <span className={styles.previewMetaValue}>{formatDate(casePreview.exportDate)}</span>
78
+ </div>
79
+ <div className={styles.previewMetaRow}>
80
+ <span className={styles.previewMetaLabel}>Files</span>
81
+ <span className={styles.previewMetaValue}>{casePreview.totalFiles}</span>
82
+ </div>
83
+ {casePreview.hashValid !== undefined && (
84
+ <div className={styles.previewMetaRow}>
85
+ <span className={styles.previewMetaLabel}>Integrity</span>
86
+ <span className={casePreview.hashValid ? styles.previewValidBadge : styles.previewInvalidBadge}>
87
+ {casePreview.hashValid ? 'Passed' : 'Failed'}
88
+ </span>
89
+ </div>
90
+ )}
91
+ </div>
92
+ )}
37
93
  {casePreview.archived && (
38
94
  <div className={styles.archivedImportNote}>
39
95
  {ARCHIVED_SELF_IMPORT_NOTE}
@@ -24,6 +24,7 @@ export const ConfirmationDialog = ({
24
24
  }: ConfirmationDialogProps) => {
25
25
  if (!showConfirmation || !casePreview) return null;
26
26
 
27
+ const isEncrypted = casePreview.caseNumber === 'ENCRYPTED';
27
28
  const hasDetails = casePreview.archived || isArchivedRegularCaseImportBlocked;
28
29
 
29
30
  return (
@@ -32,10 +33,7 @@ export const ConfirmationDialog = ({
32
33
  <div className={styles.confirmationContent}>
33
34
  <h3 className={styles.confirmationTitle}>Confirm Case Import</h3>
34
35
  <p className={styles.confirmationText}>
35
- Are you sure you want to import this case for review?
36
- </p>
37
- <p className={styles.confirmationText}>
38
- Package details stay hidden until verification completes.
36
+ Are you sure you want to import{isEncrypted ? ' this encrypted case' : ` case ${casePreview.caseNumber}`} for review?
39
37
  </p>
40
38
 
41
39
  {hasDetails && (
@@ -24,7 +24,7 @@ export const ConfirmationPreviewSection = ({ confirmationPreview, isLoadingPrevi
24
24
  <div className={styles.previewSection}>
25
25
  <h3 className={styles.previewTitle}>Confirmation Import Preview</h3>
26
26
  <p className={styles.previewMessage}>
27
- Confirmation package detected. Details are hidden until import verification completes.
27
+ Encrypted confirmation package detected.
28
28
  </p>
29
29
  </div>
30
30
  );
@@ -89,10 +89,10 @@ export const SHOTSHELL_BUCKSHOT_OPTIONS = [
89
89
 
90
90
  export const ALL_CALIBERS: string[] = [...PISTOL_CALIBERS, ...RIFLE_CALIBERS];
91
91
  export const BULLET_JACKET_METAL_OPTIONS = ['Cu', 'Brass', 'Ni-plated', 'Al', 'Steel', 'None'] as const;
92
- export const BULLET_CORE_METAL_OPTIONS = ['Pb', 'Steel'] as const;
92
+ export const BULLET_CORE_METAL_OPTIONS = ['Pb', 'Steel', 'Solid Cu', 'Frangible'] as const;
93
93
  export const BULLET_TYPE_OPTIONS = ['FMJ', 'TMJ', 'HP', 'WC'] as const;
94
94
  export const BULLET_BARREL_TYPE_OPTIONS = ['Conventional', 'Polygonal'] as const;
95
- export const CARTRIDGE_METAL_OPTIONS = ['Brass', 'Ni-plated', 'Al', 'Steel'] as const;
95
+ export const CARTRIDGE_METAL_OPTIONS = ['Brass', 'Ni-plated', 'Al', 'Steel', 'Bi-metal'] as const;
96
96
  export const CARTRIDGE_PRIMER_TYPE_OPTIONS = ['CF', 'RF'] as const;
97
97
  export const CARTRIDGE_FPI_SHAPE_OPTIONS = ['Circular', 'Elliptical', 'Rectangular/Square', 'Tear-drop'] as const;
98
98
  export const CARTRIDGE_APERTURE_SHAPE_OPTIONS = ['Circular', 'Rectangular'] as const;