@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.
- package/.env.example +36 -33
- package/README.md +5 -46
- package/app/components/actions/case-export/core-export.ts +2 -174
- package/app/components/actions/case-export/download-handlers.ts +83 -750
- package/app/components/actions/case-export/index.ts +6 -30
- package/app/components/actions/case-export/metadata-helpers.ts +0 -78
- package/app/components/actions/case-export/types-constants.ts +0 -43
- package/app/components/actions/case-import/confirmation-import.ts +13 -14
- package/app/components/actions/case-import/zip-processing.ts +92 -12
- package/app/components/actions/generate-pdf.ts +3 -2
- package/app/components/audit/user-audit-viewer.tsx +0 -19
- package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
- package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
- package/app/components/navbar/navbar.tsx +1 -1
- package/app/components/sidebar/case-import/case-import.module.css +35 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
- package/app/components/sidebar/notes/class-details-shared.ts +2 -2
- package/app/components/toast/toast.module.css +36 -0
- package/app/components/toast/toast.tsx +6 -2
- package/app/components/user/manage-profile.tsx +4 -3
- package/app/config-example/config.json +1 -2
- package/app/root.tsx +0 -7
- package/app/routes/_index.tsx +1 -1
- package/app/routes/auth/login.example.tsx +22 -103
- package/app/routes/auth/route.ts +1 -1
- package/app/routes/striae/striae.tsx +53 -59
- package/app/services/firebase/index.ts +0 -3
- package/app/types/export.ts +1 -2
- package/app/utils/auth/index.ts +0 -1
- package/app/utils/data/permissions.ts +3 -2
- package/package.json +10 -17
- package/public/_headers +0 -4
- package/public/_routes.json +0 -1
- package/worker-configuration.d.ts +20 -17
- package/workers/audit-worker/src/audit-worker.example.ts +9 -806
- package/workers/audit-worker/src/config.ts +7 -0
- package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
- package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
- package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
- package/workers/audit-worker/src/types.ts +56 -0
- package/workers/audit-worker/worker-configuration.d.ts +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/config.ts +11 -0
- package/workers/data-worker/src/data-worker.example.ts +21 -942
- package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
- package/workers/data-worker/src/handlers/signing.ts +174 -0
- package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
- package/workers/data-worker/src/registry/key-registry.ts +368 -0
- package/workers/data-worker/src/types.ts +46 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/worker-configuration.d.ts +2 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/auth.ts +30 -0
- package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
- package/workers/user-worker/src/config.ts +4 -0
- package/workers/user-worker/src/encryption-utils.ts +25 -0
- package/workers/user-worker/src/firebase/admin.ts +152 -0
- package/workers/user-worker/src/handlers/user-routes.ts +242 -0
- package/workers/user-worker/src/registry/user-kv.ts +172 -0
- package/workers/user-worker/src/storage/user-records.ts +34 -0
- package/workers/user-worker/src/types.ts +106 -0
- package/workers/user-worker/src/user-worker.example.ts +18 -964
- package/workers/user-worker/worker-configuration.d.ts +4 -2
- package/workers/user-worker/wrangler.jsonc.example +12 -1
- package/wrangler.toml.example +1 -1
- package/app/components/actions/case-export/data-processing.ts +0 -223
- package/app/components/sidebar/case-export/case-export.module.css +0 -418
- package/app/components/sidebar/case-export/case-export.tsx +0 -310
- package/app/types/exceljs-bare.d.ts +0 -9
- package/app/utils/auth/auth.ts +0 -11
- package/public/.well-known/security.txt +0 -6
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +0 -39
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/vendor/exceljs.LICENSE +0 -22
- package/public/vendor/exceljs.bare.min.js +0 -45
- package/scripts/deploy-all.sh +0 -166
- package/scripts/deploy-config/modules/env-utils.sh +0 -322
- package/scripts/deploy-config/modules/keys.sh +0 -404
- package/scripts/deploy-config/modules/prompt.sh +0 -372
- package/scripts/deploy-config/modules/scaffolding.sh +0 -336
- package/scripts/deploy-config/modules/validation.sh +0 -365
- package/scripts/deploy-config.sh +0 -236
- package/scripts/deploy-pages-secrets.sh +0 -231
- package/scripts/deploy-pages.sh +0 -34
- package/scripts/deploy-primershear-emails.sh +0 -167
- package/scripts/deploy-worker-secrets.sh +0 -374
- package/scripts/dev.cjs +0 -23
- package/scripts/install-workers.sh +0 -88
- package/scripts/run-eslint.cjs +0 -43
- package/scripts/update-compatibility-dates.cjs +0 -124
- package/scripts/update-markdown-versions.cjs +0 -43
- package/workers/keys-worker/package.json +0 -18
- package/workers/keys-worker/src/keys.example.ts +0 -67
- package/workers/keys-worker/src/keys.ts +0 -67
- package/workers/keys-worker/worker-configuration.d.ts +0 -7447
- 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
|
|
6
|
-
export { CSV_HEADERS, formatDateForFilename } from './types-constants';
|
|
5
|
+
export { formatDateForFilename } from './types-constants';
|
|
7
6
|
|
|
8
|
-
// Metadata
|
|
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.
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
317
|
+
await auditService.logConfirmationImport(
|
|
319
318
|
user,
|
|
320
|
-
`${result.caseNumber}-${currentImageId}`,
|
|
321
|
-
annotationData, // Previous state
|
|
322
|
-
null, // Failed save
|
|
323
319
|
result.caseNumber,
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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:
|
|
141
|
-
exportedBy:
|
|
142
|
-
exportedByName:
|
|
143
|
-
exportedByCompany:
|
|
144
|
-
exportedByBadgeId:
|
|
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:
|
|
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('
|
|
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
|
|
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's current public key and forensic signatures.
|
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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;
|