@striae-org/striae 3.2.0 → 3.2.1

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 (39) hide show
  1. package/README.md +3 -32
  2. package/app/components/actions/case-export/data-processing.ts +49 -9
  3. package/app/components/actions/case-export/download-handlers.ts +125 -40
  4. package/app/components/actions/case-export/metadata-helpers.ts +31 -13
  5. package/app/components/form/base-form.tsx +1 -1
  6. package/app/components/sidebar/case-export/case-export.tsx +15 -15
  7. package/app/components/sidebar/cases/case-sidebar.tsx +23 -16
  8. package/app/components/sidebar/sidebar-container.tsx +1 -1
  9. package/app/entry.client.tsx +12 -12
  10. package/app/entry.server.tsx +4 -4
  11. package/app/hooks/useInactivityTimeout.ts +1 -1
  12. package/app/root.tsx +3 -3
  13. package/app/routes/auth/emailActionHandler.tsx +1 -1
  14. package/app/routes/auth/emailVerification.tsx +1 -1
  15. package/app/routes/auth/login.tsx +1 -1
  16. package/app/routes/auth/passwordReset.tsx +1 -1
  17. package/app/routes/auth/route.ts +1 -1
  18. package/app/types/exceljs-bare.d.ts +7 -0
  19. package/functions/[[path]].ts +2 -2
  20. package/package.json +34 -19
  21. package/public/vendor/exceljs.LICENSE +22 -0
  22. package/public/vendor/exceljs.bare.min.js +45 -0
  23. package/scripts/deploy-all.sh +52 -0
  24. package/scripts/deploy-config.sh +282 -1
  25. package/tsconfig.json +18 -8
  26. package/vite.config.ts +6 -22
  27. package/workers/audit-worker/package.json +8 -4
  28. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  29. package/workers/data-worker/package.json +8 -4
  30. package/workers/data-worker/wrangler.jsonc.example +1 -1
  31. package/workers/image-worker/package.json +8 -4
  32. package/workers/image-worker/wrangler.jsonc.example +1 -1
  33. package/workers/keys-worker/package.json +8 -4
  34. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  35. package/workers/pdf-worker/package.json +8 -4
  36. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  37. package/workers/user-worker/package.json +8 -4
  38. package/workers/user-worker/wrangler.jsonc.example +1 -1
  39. package/wrangler.toml.example +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @striae-org/striae
2
2
 
3
- Striae is a cloud-native forensic annotation application for firearms identification, built with Remix and Cloudflare Workers.
3
+ Striae is a cloud-native forensic annotation application for firearms identification, built with React Router and Cloudflare Workers.
4
4
 
5
5
  This npm package publishes the Striae application source and deployment scaffolding for teams that run their own Striae environment.
6
6
 
@@ -40,7 +40,7 @@ npm i @striae-org/striae
40
40
  cp -R node_modules/@striae-org/striae/. .
41
41
  ```
42
42
 
43
- 3) Reinstall using Striae's own package.json (includes dev deps like wrangler/remix)
43
+ 3) Reinstall using Striae's own package.json (includes dev deps like wrangler/react-router)
44
44
 
45
45
  ```bash
46
46
  rm -rf node_modules package-lock.json
@@ -62,41 +62,12 @@ cp -f app/config-example/admin-service.json app/config/admin-service.json
62
62
  npx wrangler login
63
63
  ```
64
64
 
65
- 6) Run guided config + full deployment
65
+ 7) Run guided config + full deployment
66
66
 
67
67
  ```bash
68
68
  npm run deploy:all
69
69
  ```
70
70
 
71
- ## Publish To npmjs And GitHub Packages
72
-
73
- 1) Verify auth for both registries
74
-
75
- ```bash
76
- npm whoami --registry=https://registry.npmjs.org/
77
- npm whoami --registry=https://npm.pkg.github.com/
78
- ```
79
-
80
- 2) Publish current version to npmjs (public)
81
-
82
- ```bash
83
- npm run publish:npm
84
- ```
85
-
86
- 3) Publish the same version to GitHub Packages
87
-
88
- ```bash
89
- npm run publish:github
90
- ```
91
-
92
- 4) Optional dry-runs before a real release
93
-
94
- ```bash
95
- npm run publish:npm:dry-run
96
- npm run publish:github:dry-run
97
- npm run publish:all:dry-run
98
- ```
99
-
100
71
  ## NPM Package Content Policy
101
72
 
102
73
  This package intentionally includes only non-sensitive defaults and runtime source needed for setup.
@@ -3,11 +3,51 @@ import { calculateSHA256Secure } from '~/utils/SHA256';
3
3
  import { CSV_HEADERS } from './types-constants';
4
4
  import { addForensicDataWarning } from './metadata-helpers';
5
5
 
6
+ export type TabularCell = string | number | boolean | null;
7
+
8
+ const MAX_SPREADSHEET_CELL_LENGTH = 32767;
9
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
10
+ const DANGEROUS_FORMULA_PREFIX_PATTERN = /^[\s\u0000-\u001F]*[=+\-@]/;
11
+
12
+ /**
13
+ * Sanitize cell values before CSV/XLSX export.
14
+ * - Strips non-printable control characters (except tab/newline/carriage return)
15
+ * - Prevents formula injection when files are opened in spreadsheet tools
16
+ * - Caps content length to Excel's per-cell limit
17
+ */
18
+ export function sanitizeTabularCell(value: unknown): TabularCell {
19
+ if (value === null || value === undefined) {
20
+ return '';
21
+ }
22
+
23
+ if (typeof value === 'number' || typeof value === 'boolean') {
24
+ return value;
25
+ }
26
+
27
+ let normalized = String(value).replace(CONTROL_CHAR_PATTERN, '');
28
+
29
+ if (normalized.length > MAX_SPREADSHEET_CELL_LENGTH) {
30
+ normalized = normalized.slice(0, MAX_SPREADSHEET_CELL_LENGTH);
31
+ }
32
+
33
+ if (DANGEROUS_FORMULA_PREFIX_PATTERN.test(normalized)) {
34
+ return `'${normalized}`;
35
+ }
36
+
37
+ return normalized;
38
+ }
39
+
40
+ export function sanitizeTabularMatrix(
41
+ rows: Array<Array<string | number | boolean | null | undefined>>
42
+ ): TabularCell[][] {
43
+ return rows.map((row) => row.map((cell) => sanitizeTabularCell(cell)));
44
+ }
45
+
6
46
  /**
7
47
  * Generate metadata rows for tabular format
8
48
  */
9
- export function generateMetadataRows(exportData: CaseExportData): string[][] {
10
- return [
49
+ export function generateMetadataRows(exportData: CaseExportData): TabularCell[][] {
50
+ return sanitizeTabularMatrix([
11
51
  ['Case Export Report'],
12
52
  [''],
13
53
  ['Case Number', exportData.metadata.caseNumber],
@@ -31,13 +71,13 @@ export function generateMetadataRows(exportData: CaseExportData): string[][] {
31
71
  ['Latest Annotation Date', exportData.summary?.latestAnnotationDate || 'N/A'],
32
72
  [''],
33
73
  ['File Details']
34
- ];
74
+ ]);
35
75
  }
36
76
 
37
77
  /**
38
78
  * Process file data for tabular format (CSV/Excel)
39
79
  */
40
- export function processFileDataForTabular(fileEntry: CaseExportData['files'][0]): string[][] {
80
+ export function processFileDataForTabular(fileEntry: CaseExportData['files'][0]): TabularCell[][] {
41
81
  // Full file data for the first row (excluding Additional Notes and Last Updated)
42
82
  const fullFileData = [
43
83
  fileEntry.fileData.id,
@@ -83,7 +123,7 @@ export function processFileDataForTabular(fileEntry: CaseExportData['files'][0])
83
123
  const emptyFileData = Array(fileDataColumnCount).fill('');
84
124
  const emptyAdditionalData = Array(additionalDataColumnCount).fill('');
85
125
 
86
- const rows: string[][] = [];
126
+ const rows: Array<Array<string | number | boolean | null | undefined>> = [];
87
127
 
88
128
  // If there are box annotations, create a row for each one
89
129
  if (fileEntry.annotations?.boxAnnotations && fileEntry.annotations.boxAnnotations.length > 0) {
@@ -120,7 +160,7 @@ export function processFileDataForTabular(fileEntry: CaseExportData['files'][0])
120
160
  ]);
121
161
  }
122
162
 
123
- return rows;
163
+ return sanitizeTabularMatrix(rows);
124
164
  }
125
165
 
126
166
  /**
@@ -131,16 +171,16 @@ export async function generateCSVContent(exportData: CaseExportData, protectFore
131
171
  const metadataRows = generateMetadataRows(exportData);
132
172
 
133
173
  // File data rows
134
- const fileRows: string[][] = [];
174
+ const fileRows: TabularCell[][] = [];
135
175
  exportData.files.forEach(fileEntry => {
136
176
  const processedRows = processFileDataForTabular(fileEntry);
137
177
  fileRows.push(...processedRows);
138
178
  });
139
179
 
140
180
  // Combine data rows for hash calculation (excluding header comments)
141
- const dataRows = [
181
+ const dataRows: TabularCell[][] = [
142
182
  ...metadataRows,
143
- CSV_HEADERS,
183
+ ...sanitizeTabularMatrix([CSV_HEADERS]),
144
184
  ...fileRows
145
185
  ];
146
186
 
@@ -5,10 +5,96 @@ import { generateForensicManifestSecure, calculateSHA256Secure } from '~/utils/S
5
5
  import { signForensicManifest } from '~/utils/data-operations';
6
6
  import { ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
7
7
  import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
8
- import { generateMetadataRows, generateCSVContent, processFileDataForTabular } from './data-processing';
8
+ import { generateMetadataRows, generateCSVContent, processFileDataForTabular, sanitizeTabularMatrix } from './data-processing';
9
9
  import { exportCaseData } from './core-export';
10
10
  import { auditService } from '~/services/audit.service';
11
11
 
12
+ type TabularRow = Array<string | number | boolean | null | undefined>;
13
+ type ExcelJsBrowserBundle = typeof import('exceljs');
14
+
15
+ const EXCELJS_BROWSER_BUNDLE_SRC = '/vendor/exceljs.bare.min.js';
16
+ let excelJsBundlePromise: Promise<ExcelJsBrowserBundle> | null = null;
17
+
18
+ async function loadExcelJsBrowserBundle(): Promise<ExcelJsBrowserBundle> {
19
+ if (typeof window === 'undefined') {
20
+ throw new Error('Excel export is only available in a browser context.');
21
+ }
22
+
23
+ if (window.ExcelJS?.Workbook) {
24
+ return window.ExcelJS;
25
+ }
26
+
27
+ if (!excelJsBundlePromise) {
28
+ excelJsBundlePromise = new Promise((resolve, reject) => {
29
+ const resolveFromWindow = () => {
30
+ if (window.ExcelJS?.Workbook) {
31
+ resolve(window.ExcelJS);
32
+ return;
33
+ }
34
+
35
+ excelJsBundlePromise = null;
36
+ reject(new Error('ExcelJS bundle loaded but Workbook API is unavailable.'));
37
+ };
38
+
39
+ const failLoad = () => {
40
+ excelJsBundlePromise = null;
41
+ reject(new Error('Failed to load ExcelJS browser bundle.'));
42
+ };
43
+
44
+ const existingScript = document.querySelector<HTMLScriptElement>('script[data-exceljs-bundle="true"]');
45
+
46
+ if (existingScript) {
47
+ if (existingScript.dataset.loaded === 'true') {
48
+ resolveFromWindow();
49
+ return;
50
+ }
51
+
52
+ existingScript.addEventListener('load', resolveFromWindow, { once: true });
53
+ existingScript.addEventListener('error', failLoad, { once: true });
54
+ return;
55
+ }
56
+
57
+ const script = document.createElement('script');
58
+ script.src = EXCELJS_BROWSER_BUNDLE_SRC;
59
+ script.async = true;
60
+ script.dataset.exceljsBundle = 'true';
61
+ script.addEventListener(
62
+ 'load',
63
+ () => {
64
+ script.dataset.loaded = 'true';
65
+ resolveFromWindow();
66
+ },
67
+ { once: true }
68
+ );
69
+ script.addEventListener('error', failLoad, { once: true });
70
+ document.head.appendChild(script);
71
+ });
72
+ }
73
+
74
+ return excelJsBundlePromise;
75
+ }
76
+
77
+ function sanitizeWorksheetName(name: string): string {
78
+ const cleaned = name.replace(/[\\/?*:\x5B\x5D]/g, '_').trim();
79
+ const normalized = cleaned.length > 0 ? cleaned : 'Sheet';
80
+ return normalized.substring(0, 31);
81
+ }
82
+
83
+ function createUniqueWorksheetName(existingNames: Set<string>, desiredName: string): string {
84
+ const baseName = sanitizeWorksheetName(desiredName);
85
+ let candidate = baseName;
86
+ let suffix = 1;
87
+
88
+ while (existingNames.has(candidate)) {
89
+ const suffixText = `_${suffix}`;
90
+ candidate = `${baseName.substring(0, 31 - suffixText.length)}${suffixText}`;
91
+ suffix += 1;
92
+ }
93
+
94
+ existingNames.add(candidate);
95
+ return candidate;
96
+ }
97
+
12
98
  /**
13
99
  * Generate export filename with embedded ID to prevent collisions
14
100
  * Format: {originalFilename}-{id}.{extension}
@@ -122,10 +208,22 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
122
208
  // Start audit workflow
123
209
  auditService.startWorkflow('all-cases');
124
210
 
125
- // Dynamic import of XLSX to avoid bundle size issues
126
- const XLSX = await import('xlsx');
211
+ const ExcelJS = await loadExcelJsBrowserBundle();
127
212
 
128
- const workbook = XLSX.utils.book_new();
213
+ const workbook = new ExcelJS.Workbook();
214
+ workbook.creator = exportData.metadata.exportedBy || 'Striae';
215
+ workbook.lastModifiedBy = exportData.metadata.exportedBy || 'Striae';
216
+ workbook.created = new Date();
217
+ workbook.modified = new Date();
218
+
219
+ const existingWorksheetNames = new Set<string>();
220
+
221
+ const appendRowsToWorksheet = (worksheet: { addRow: (row: TabularRow) => unknown }, rows: TabularRow[]) => {
222
+ rows.forEach((row) => {
223
+ worksheet.addRow(row);
224
+ });
225
+ };
226
+
129
227
  let exportPassword: string | undefined;
130
228
 
131
229
  // Create summary worksheet
@@ -146,7 +244,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
146
244
  ];
147
245
 
148
246
  // XLSX files are inherently protected, no hash validation needed
149
- const summaryData = [
247
+ const summaryData = sanitizeTabularMatrix([
150
248
  protectForensicData ? ['CASE DATA - PROTECTED EXPORT'] : ['Striae - All Cases Export Summary'],
151
249
  protectForensicData ? ['WARNING: This workbook contains evidence data and is protected from editing.'] : [''],
152
250
  [''],
@@ -195,36 +293,37 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
195
293
  caseData.summary?.latestAnnotationDate || '',
196
294
  caseData.summary?.exportError || ''
197
295
  ])
198
- ];
296
+ ]);
199
297
 
200
- const summaryWorksheet = XLSX.utils.aoa_to_sheet(summaryData);
298
+ const summaryWorksheetName = createUniqueWorksheetName(existingWorksheetNames, 'Summary');
299
+ const summaryWorksheet = workbook.addWorksheet(summaryWorksheetName);
300
+ appendRowsToWorksheet(summaryWorksheet, summaryData);
201
301
 
202
302
  // Protect summary worksheet if forensic protection is enabled
203
303
  if (protectForensicData) {
204
- exportPassword = protectExcelWorksheet(summaryWorksheet);
304
+ exportPassword = await protectExcelWorksheet(summaryWorksheet);
205
305
  }
206
-
207
- XLSX.utils.book_append_sheet(workbook, summaryWorksheet, 'Summary');
208
306
 
209
307
  // Create a worksheet for each case
210
- exportData.cases.forEach((caseData) => {
308
+ for (const caseData of exportData.cases) {
211
309
  if (caseData.summary?.exportError) {
212
310
  // For failed cases, create a simple error sheet
213
- const errorData = [
311
+ const errorData = sanitizeTabularMatrix([
214
312
  [`Case ${caseData.metadata.caseNumber} - Export Failed`],
215
313
  [''],
216
314
  ['Error:', caseData.summary.exportError],
217
315
  ['Case Number:', caseData.metadata.caseNumber],
218
316
  ['Total Files:', caseData.metadata.totalFiles]
219
- ];
220
- const errorWorksheet = XLSX.utils.aoa_to_sheet(errorData);
317
+ ]);
318
+ const errorSheetName = createUniqueWorksheetName(existingWorksheetNames, `Case_${caseData.metadata.caseNumber}_Error`);
319
+ const errorWorksheet = workbook.addWorksheet(errorSheetName);
320
+ appendRowsToWorksheet(errorWorksheet, errorData);
221
321
 
222
322
  if (protectForensicData && exportPassword) {
223
- protectExcelWorksheet(errorWorksheet, exportPassword);
323
+ await protectExcelWorksheet(errorWorksheet, exportPassword);
224
324
  }
225
-
226
- XLSX.utils.book_append_sheet(workbook, errorWorksheet, `Case_${caseData.metadata.caseNumber}_Error`);
227
- return;
325
+
326
+ continue;
228
327
  }
229
328
 
230
329
  // For successful cases, create detailed worksheets
@@ -257,36 +356,22 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
257
356
  caseDetailsData.push(['No detailed file data available for this case']);
258
357
  }
259
358
 
260
- const caseWorksheet = XLSX.utils.aoa_to_sheet(caseDetailsData);
359
+ const sanitizedCaseDetailsData = sanitizeTabularMatrix(caseDetailsData);
360
+
361
+ // Clean sheet name for Excel compatibility and uniqueness
362
+ const sheetName = createUniqueWorksheetName(existingWorksheetNames, `Case_${caseData.metadata.caseNumber}`);
363
+ const caseWorksheet = workbook.addWorksheet(sheetName);
364
+ appendRowsToWorksheet(caseWorksheet, sanitizedCaseDetailsData);
261
365
 
262
366
  // Protect worksheet if forensic protection is enabled
263
367
  if (protectForensicData && exportPassword) {
264
- protectExcelWorksheet(caseWorksheet, exportPassword);
368
+ await protectExcelWorksheet(caseWorksheet, exportPassword);
265
369
  }
266
-
267
- // Clean sheet name for Excel compatibility
268
- const sheetName = `Case_${caseData.metadata.caseNumber}`.replace(/[\\/?*\x5B\x5D]/g, '_').substring(0, 31);
269
- XLSX.utils.book_append_sheet(workbook, caseWorksheet, sheetName);
270
- });
271
370
 
272
- // Set workbook protection if forensic protection is enabled
273
- if (protectForensicData && exportPassword) {
274
- workbook.Props = {
275
- Title: 'Striae Case Export - Protected',
276
- Subject: 'Case Data Export',
277
- Author: exportData.metadata.exportedBy || 'Striae',
278
- Comments: `This workbook contains protected case data. Modification may compromise evidence integrity. Worksheets are password protected.`,
279
- Company: 'Striae'
280
- };
281
371
  }
282
372
 
283
373
  // Generate Excel file
284
- const excelBuffer = XLSX.write(workbook, {
285
- bookType: 'xlsx',
286
- type: 'array',
287
- bookSST: true, // Shared string table for better compression
288
- cellStyles: true
289
- });
374
+ const excelBuffer = await workbook.xlsx.writeBuffer();
290
375
 
291
376
  // Create blob and download
292
377
  const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
@@ -68,16 +68,38 @@ export function generateRandomPassword(): string {
68
68
  return password.split('').sort(() => Math.random() - 0.5).join('');
69
69
  }
70
70
 
71
+ type WorksheetProtectionOptions = {
72
+ selectLockedCells: boolean;
73
+ selectUnlockedCells: boolean;
74
+ formatCells: boolean;
75
+ formatColumns: boolean;
76
+ formatRows: boolean;
77
+ insertColumns: boolean;
78
+ insertRows: boolean;
79
+ insertHyperlinks: boolean;
80
+ deleteColumns: boolean;
81
+ deleteRows: boolean;
82
+ sort: boolean;
83
+ autoFilter: boolean;
84
+ pivotTables: boolean;
85
+ objects: boolean;
86
+ scenarios: boolean;
87
+ spinCount: number;
88
+ };
89
+
90
+ type ProtectableWorksheet = {
91
+ protect: (password: string, options: Record<string, unknown>) => Promise<unknown> | unknown;
92
+ };
93
+
71
94
  /**
72
95
  * Protect Excel worksheet from editing
73
96
  */
74
- export function protectExcelWorksheet(worksheet: Record<string, unknown>, sheetPassword?: string): string {
97
+ export async function protectExcelWorksheet(worksheet: ProtectableWorksheet, sheetPassword?: string): Promise<string> {
75
98
  // Generate random password if none provided
76
99
  const password = sheetPassword || generateRandomPassword();
77
-
78
- // Set worksheet protection
79
- worksheet['!protect'] = {
80
- password: password,
100
+
101
+ const protectionOptions: WorksheetProtectionOptions = {
102
+ // Keep read-only defaults and prevent structural edits.
81
103
  selectLockedCells: true,
82
104
  selectUnlockedCells: true,
83
105
  formatCells: false,
@@ -92,15 +114,11 @@ export function protectExcelWorksheet(worksheet: Record<string, unknown>, sheetP
92
114
  autoFilter: false,
93
115
  pivotTables: false,
94
116
  objects: false,
95
- scenarios: false
117
+ scenarios: false,
118
+ spinCount: 100000
96
119
  };
97
-
98
- // Lock all cells by default
99
- if (!worksheet['!cols']) worksheet['!cols'] = [];
100
- if (!worksheet['!rows']) worksheet['!rows'] = [];
101
-
102
- // Add protection metadata
103
- worksheet['!margins'] = { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 };
120
+
121
+ await Promise.resolve(worksheet.protect(password, protectionOptions as Record<string, unknown>));
104
122
 
105
123
  // Return the password for inclusion in metadata
106
124
  return password;
@@ -1,4 +1,4 @@
1
- import { Form as RemixForm } from '@remix-run/react';
1
+ import { Form as RemixForm } from 'react-router';
2
2
  import styles from './form.module.css';
3
3
 
4
4
  interface BaseFormProps {
@@ -364,20 +364,6 @@ export const CaseExport = ({
364
364
  </>
365
365
  )}
366
366
 
367
- <div className={styles.divider}>
368
- <span>Verification</span>
369
- </div>
370
-
371
- <div className={styles.publicKeySection}>
372
- <button
373
- type="button"
374
- className={styles.publicKeyButton}
375
- onClick={() => setIsPublicKeyModalOpen(true)}
376
- >
377
- View Public Signing Key
378
- </button>
379
- </div>
380
-
381
367
  {exportProgress && exportProgress.total > 0 && (
382
368
  <div className={styles.progressSection}>
383
369
  <div className={styles.progressText}>
@@ -391,7 +377,7 @@ export const CaseExport = ({
391
377
  </div>
392
378
  </div>
393
379
  )}
394
-
380
+
395
381
  {isExportingAll && !exportProgress && (
396
382
  <div className={styles.progressSection}>
397
383
  <div className={styles.progressText}>
@@ -399,6 +385,20 @@ export const CaseExport = ({
399
385
  </div>
400
386
  </div>
401
387
  )}
388
+
389
+ <div className={styles.divider}>
390
+ <span>Verification</span>
391
+ </div>
392
+
393
+ <div className={styles.publicKeySection}>
394
+ <button
395
+ type="button"
396
+ className={styles.publicKeyButton}
397
+ onClick={() => setIsPublicKeyModalOpen(true)}
398
+ >
399
+ View Public Signing Key
400
+ </button>
401
+ </div>
402
402
 
403
403
  {error && (
404
404
  <div className={styles.error}>
@@ -1,13 +1,4 @@
1
1
  import { User } from 'firebase/auth';
2
- import {
3
- exportCaseData,
4
- exportAllCases,
5
- downloadCaseAsJSON,
6
- downloadCaseAsCSV,
7
- downloadAllCasesAsJSON,
8
- downloadAllCasesAsCSV,
9
- downloadCaseAsZip
10
- } from '../../actions/case-export';
11
2
  import { useState, useEffect, useMemo, useCallback } from 'react';
12
3
  import styles from './cases.module.css';
13
4
  import { CasesModal } from './cases-modal';
@@ -66,6 +57,18 @@ interface CaseSidebarProps {
66
57
 
67
58
  const SUCCESS_MESSAGE_TIMEOUT = 3000;
68
59
 
60
+ type CaseExportActionsModule = typeof import('../../actions/case-export');
61
+
62
+ let caseExportActionsPromise: Promise<CaseExportActionsModule> | null = null;
63
+
64
+ const loadCaseExportActions = (): Promise<CaseExportActionsModule> => {
65
+ if (!caseExportActionsPromise) {
66
+ caseExportActionsPromise = import('../../actions/case-export');
67
+ }
68
+
69
+ return caseExportActionsPromise;
70
+ };
71
+
69
72
  export const CaseSidebar = ({
70
73
  user,
71
74
  onImageSelect,
@@ -512,20 +515,22 @@ const handleImageSelect = (file: FileData) => {
512
515
 
513
516
  const handleExport = async (exportCaseNumber: string, format: ExportFormat, includeImages?: boolean) => {
514
517
  try {
518
+ const caseExportActions = await loadCaseExportActions();
519
+
515
520
  if (includeImages) {
516
521
  // ZIP export with images - only available for single case exports
517
- await downloadCaseAsZip(user, exportCaseNumber, format);
522
+ await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format);
518
523
  } else {
519
524
  // Standard data-only export
520
- const exportData = await exportCaseData(user, exportCaseNumber, {
525
+ const exportData = await caseExportActions.exportCaseData(user, exportCaseNumber, {
521
526
  includeMetadata: true
522
527
  });
523
528
 
524
529
  // Download the exported data in the selected format
525
530
  if (format === 'json') {
526
- await downloadCaseAsJSON(user, exportData);
531
+ await caseExportActions.downloadCaseAsJSON(user, exportData);
527
532
  } else {
528
- await downloadCaseAsCSV(user, exportData);
533
+ await caseExportActions.downloadCaseAsCSV(user, exportData);
529
534
  }
530
535
  }
531
536
 
@@ -537,16 +542,18 @@ const handleImageSelect = (file: FileData) => {
537
542
 
538
543
  const handleExportAll = async (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => {
539
544
  try {
545
+ const caseExportActions = await loadCaseExportActions();
546
+
540
547
  // Export all cases with progress callback
541
- const exportData = await exportAllCases(user, {
548
+ const exportData = await caseExportActions.exportAllCases(user, {
542
549
  includeMetadata: true
543
550
  }, onProgress);
544
551
 
545
552
  // Download the exported data in the selected format
546
553
  if (format === 'json') {
547
- await downloadAllCasesAsJSON(user, exportData);
554
+ await caseExportActions.downloadAllCasesAsJSON(user, exportData);
548
555
  } else {
549
- await downloadAllCasesAsCSV(user, exportData);
556
+ await caseExportActions.downloadAllCasesAsCSV(user, exportData);
550
557
  }
551
558
 
552
559
  } catch (error) {
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable jsx-a11y/no-static-element-interactions */
2
2
  /* eslint-disable jsx-a11y/click-events-have-key-events */
3
3
  import React, { useState, useEffect } from 'react';
4
- import { Link } from '@remix-run/react';
4
+ import { Link } from 'react-router';
5
5
  import { Sidebar } from './sidebar';
6
6
  import { User } from 'firebase/auth';
7
7
  import { FileData } from '~/types';
@@ -1,12 +1,12 @@
1
- import { RemixBrowser } from "@remix-run/react";
2
- import { startTransition, StrictMode } from "react";
3
- import { hydrateRoot } from "react-dom/client";
4
-
5
- startTransition(() => {
6
- hydrateRoot(
7
- document,
8
- <StrictMode>
9
- <RemixBrowser />
10
- </StrictMode>
11
- );
12
- });
1
+ import { HydratedRouter } from "react-router/dom";
2
+ import { startTransition, StrictMode } from "react";
3
+ import { hydrateRoot } from "react-dom/client";
4
+
5
+ startTransition(() => {
6
+ hydrateRoot(
7
+ document,
8
+ <StrictMode>
9
+ <HydratedRouter />
10
+ </StrictMode>
11
+ );
12
+ });
@@ -1,5 +1,5 @@
1
- import type { EntryContext } from "@remix-run/cloudflare";
2
- import { RemixServer } from "@remix-run/react";
1
+ import type { EntryContext } from "react-router";
2
+ import { ServerRouter } from "react-router";
3
3
  import { isbot } from "isbot";
4
4
  import { renderToReadableStream } from "react-dom/server";
5
5
 
@@ -7,10 +7,10 @@ export default async function handleRequest(
7
7
  request: Request,
8
8
  responseStatusCode: number,
9
9
  responseHeaders: Headers,
10
- remixContext: EntryContext
10
+ reactRouterContext: EntryContext
11
11
  ) {
12
12
  const body = await renderToReadableStream(
13
- <RemixServer context={remixContext} url={request.url} />,
13
+ <ServerRouter context={reactRouterContext} url={request.url} />,
14
14
  {
15
15
  // If you wish to abort the rendering process, you can pass a signal here.
16
16
  // Please refer to the templates for example son how to configure this.
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useCallback } from 'react';
2
- import { useLocation } from '@remix-run/react';
2
+ import { useLocation } from 'react-router';
3
3
  import { signOut } from 'firebase/auth';
4
4
  import { auth } from '~/services/firebase';
5
5
  import { INACTIVITY_CONFIG } from '~/config/inactivity';
package/app/root.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { LinksFunction } from "@remix-run/cloudflare";
1
+ import type { LinksFunction } from 'react-router';
2
2
  import {
3
3
  Links,
4
4
  Meta,
@@ -9,8 +9,8 @@ import {
9
9
  useRouteError,
10
10
  Link,
11
11
  useLocation,
12
- useMatches
13
- } from "@remix-run/react";
12
+ useMatches,
13
+ } from 'react-router';
14
14
  import {
15
15
  ThemeProvider,
16
16
  themeStyles