@striae-org/striae 3.3.0 → 4.0.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 (53) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-import/confirmation-import.ts +24 -23
  4. package/app/components/actions/case-import/image-operations.ts +20 -49
  5. package/app/components/actions/case-import/orchestrator.ts +1 -1
  6. package/app/components/actions/case-import/storage-operations.ts +54 -89
  7. package/app/components/actions/case-import/validation.ts +2 -13
  8. package/app/components/actions/case-manage.ts +15 -27
  9. package/app/components/actions/generate-pdf.ts +3 -7
  10. package/app/components/actions/image-manage.ts +63 -129
  11. package/app/components/button/button.module.css +12 -8
  12. package/app/components/sidebar/case-export/case-export.tsx +11 -6
  13. package/app/components/sidebar/cases/case-sidebar.tsx +21 -6
  14. package/app/components/sidebar/sidebar.module.css +0 -2
  15. package/app/components/user/delete-account.tsx +7 -7
  16. package/app/config-example/config.json +2 -8
  17. package/app/hooks/useInactivityTimeout.ts +2 -5
  18. package/app/root.tsx +94 -63
  19. package/app/routes/auth/login.tsx +3 -5
  20. package/app/routes/auth/route.ts +4 -3
  21. package/app/routes/striae/striae.tsx +4 -8
  22. package/app/services/audit/audit-api-client.ts +40 -0
  23. package/app/services/audit/audit-worker-client.ts +14 -17
  24. package/app/styles/root.module.css +13 -101
  25. package/app/tailwind.css +9 -2
  26. package/app/utils/auth.ts +5 -32
  27. package/app/utils/data-api-client.ts +43 -0
  28. package/app/utils/data-operations.ts +59 -75
  29. package/app/utils/image-api-client.ts +130 -0
  30. package/app/utils/pdf-api-client.ts +43 -0
  31. package/app/utils/permissions.ts +10 -23
  32. package/app/utils/user-api-client.ts +90 -0
  33. package/functions/api/_shared/firebase-auth.ts +255 -0
  34. package/functions/api/audit/[[path]].ts +150 -0
  35. package/functions/api/data/[[path]].ts +141 -0
  36. package/functions/api/image/[[path]].ts +127 -0
  37. package/functions/api/pdf/[[path]].ts +110 -0
  38. package/functions/api/user/[[path]].ts +196 -0
  39. package/package.json +2 -1
  40. package/scripts/deploy-all.sh +22 -8
  41. package/scripts/deploy-config.sh +143 -148
  42. package/scripts/deploy-pages-secrets.sh +231 -0
  43. package/scripts/deploy-worker-secrets.sh +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  45. package/workers/data-worker/wrangler.jsonc.example +1 -8
  46. package/workers/image-worker/wrangler.jsonc.example +1 -8
  47. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  48. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  49. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  50. package/workers/user-worker/src/user-worker.example.ts +121 -41
  51. package/workers/user-worker/wrangler.jsonc.example +1 -8
  52. package/wrangler.toml.example +1 -1
  53. package/app/styles/legal-pages.module.css +0 -113
@@ -1,16 +1,13 @@
1
1
  import type { User } from 'firebase/auth';
2
- import paths from '~/config/config.json';
3
2
  import {
4
- getImageApiKey,
5
3
  getAccountHash
6
4
  } from '~/utils/auth';
5
+ import { fetchImageApi, uploadImageApi } from '~/utils/image-api-client';
7
6
  import { canUploadFile } from '~/utils/permissions';
8
7
  import { getCaseData, updateCaseData, deleteFileAnnotations } from '~/utils/data-operations';
9
8
  import type { CaseData, FileData, ImageUploadResponse } from '~/types';
10
9
  import { auditService } from '~/services/audit';
11
10
 
12
- const IMAGE_URL = paths.image_worker_url;
13
-
14
11
  export const fetchFiles = async (
15
12
  user: User,
16
13
  caseNumber: string,
@@ -52,127 +49,70 @@ export const uploadFile = async (
52
49
  throw new Error(permission.reason || 'You cannot upload more files to this case.');
53
50
  }
54
51
 
55
- const imagesApiToken = await getImageApiKey();
56
-
57
- return new Promise((resolve, reject) => {
58
- const xhr = new XMLHttpRequest();
59
- const formData = new FormData();
60
- formData.append('file', file);
61
-
62
- xhr.upload.addEventListener('progress', (event) => {
63
- if (event.lengthComputable && onProgress) {
64
- const progress = Math.round((event.loaded / event.total) * 100);
65
- onProgress(progress);
66
- }
67
- });
68
-
69
- xhr.addEventListener('load', async () => {
70
- const endTime = Date.now();
71
-
72
- if (xhr.status === 200) {
73
- try {
74
- const imageData = JSON.parse(xhr.responseText) as ImageUploadResponse;
75
- if (!imageData.success) throw new Error('Upload failed');
76
-
77
- const newFile: FileData = {
78
- id: imageData.result.id,
79
- originalFilename: file.name,
80
- uploadedAt: new Date().toISOString()
81
- };
52
+ try {
53
+ const imageData: ImageUploadResponse = await uploadImageApi(user, file, onProgress);
54
+ const uploadedImageId = imageData.result?.id;
55
+ if (!uploadedImageId) {
56
+ throw new Error('Upload failed');
57
+ }
82
58
 
83
- // Update case data using centralized function
84
- const existingData = await getCaseData(user, caseNumber);
85
- if (!existingData) {
86
- throw new Error('Case not found');
87
- }
59
+ const newFile: FileData = {
60
+ id: uploadedImageId,
61
+ originalFilename: file.name,
62
+ uploadedAt: new Date().toISOString()
63
+ };
88
64
 
89
- const updatedData = {
90
- ...existingData,
91
- files: [...(existingData.files || []), newFile]
92
- };
65
+ // Update case data using centralized function
66
+ const existingData = await getCaseData(user, caseNumber);
67
+ if (!existingData) {
68
+ throw new Error('Case not found');
69
+ }
93
70
 
94
- await updateCaseData(user, caseNumber, updatedData);
71
+ const updatedData = {
72
+ ...existingData,
73
+ files: [...(existingData.files || []), newFile]
74
+ };
95
75
 
96
- // Log successful file upload
97
- try {
98
- await auditService.logFileUpload(
99
- user,
100
- file.name,
101
- file.size,
102
- file.type,
103
- 'file-picker',
104
- caseNumber,
105
- 'success',
106
- endTime - startTime,
107
- imageData.result.id
108
- );
109
- } catch (auditError) {
110
- console.error('Failed to log successful file upload:', auditError);
111
- }
76
+ await updateCaseData(user, caseNumber, updatedData);
112
77
 
113
- console.log(`✅ File uploaded: ${file.name} (${file.size} bytes) (${endTime - startTime}ms)`);
114
- resolve(newFile);
115
- } catch (error) {
116
- // Log failed file upload
117
- try {
118
- await auditService.logFileUpload(
119
- user,
120
- file.name,
121
- file.size,
122
- file.type,
123
- 'file-picker',
124
- caseNumber,
125
- 'failure',
126
- endTime - startTime
127
- );
128
- } catch (auditError) {
129
- console.error('Failed to log file upload failure:', auditError);
130
- }
131
- reject(error);
132
- }
133
- } else {
134
- // Log failed file upload
135
- try {
136
- await auditService.logFileUpload(
137
- user,
138
- file.name,
139
- file.size,
140
- file.type,
141
- 'file-picker',
142
- caseNumber,
143
- 'failure',
144
- endTime - startTime
145
- );
146
- } catch (auditError) {
147
- console.error('Failed to log file upload failure:', auditError);
148
- }
149
- reject(new Error('Upload failed'));
150
- }
151
- });
78
+ // Log successful file upload
79
+ try {
80
+ await auditService.logFileUpload(
81
+ user,
82
+ file.name,
83
+ file.size,
84
+ file.type,
85
+ 'file-picker',
86
+ caseNumber,
87
+ 'success',
88
+ Date.now() - startTime,
89
+ uploadedImageId
90
+ );
91
+ } catch (auditError) {
92
+ console.error('Failed to log successful file upload:', auditError);
93
+ }
152
94
 
153
- xhr.addEventListener('error', async () => {
154
- // Log upload error
155
- try {
156
- await auditService.logFileUpload(
157
- user,
158
- file.name,
159
- file.size,
160
- file.type,
161
- 'file-picker',
162
- caseNumber,
163
- 'failure',
164
- Date.now() - startTime
165
- );
166
- } catch (auditError) {
167
- console.error('Failed to log file upload error:', auditError);
168
- }
169
- reject(new Error('Upload failed'));
170
- });
95
+ console.log(`✅ File uploaded: ${file.name} (${file.size} bytes) (${Date.now() - startTime}ms)`);
96
+ return newFile;
97
+ } catch (error) {
98
+ // Log failed file upload
99
+ try {
100
+ await auditService.logFileUpload(
101
+ user,
102
+ file.name,
103
+ file.size,
104
+ file.type,
105
+ 'file-picker',
106
+ caseNumber,
107
+ 'failure',
108
+ Date.now() - startTime
109
+ );
110
+ } catch (auditError) {
111
+ console.error('Failed to log file upload failure:', auditError);
112
+ }
171
113
 
172
- xhr.open('POST', IMAGE_URL);
173
- xhr.setRequestHeader('Authorization', `Bearer ${imagesApiToken}`);
174
- xhr.send(formData);
175
- });
114
+ throw error;
115
+ }
176
116
  };
177
117
 
178
118
  export const deleteFile = async (user: User, caseNumber: string, fileId: string, deleteReason: string = 'User-requested deletion via file list'): Promise<void> => {
@@ -197,12 +137,8 @@ export const deleteFile = async (user: User, caseNumber: string, fileId: string,
197
137
  let imageDeleteError = '';
198
138
 
199
139
  // Attempt to delete image file
200
- const imagesApiToken = await getImageApiKey();
201
- const imageResponse = await fetch(`${IMAGE_URL}/${fileId}`, {
202
- method: 'DELETE',
203
- headers: {
204
- 'Authorization': `Bearer ${imagesApiToken}`
205
- }
140
+ const imageResponse = await fetchImageApi(user, `/${encodeURIComponent(fileId)}`, {
141
+ method: 'DELETE'
206
142
  });
207
143
 
208
144
  // Handle image deletion response
@@ -306,14 +242,12 @@ export const getImageUrl = async (user: User, fileData: FileData, caseNumber: st
306
242
  const defaultAccessReason = accessReason || 'Image viewer access';
307
243
 
308
244
  try {
309
- const { accountHash } = await getImageConfig();
310
- const imagesApiToken = await getImageApiKey();
245
+ const { accountHash } = await getImageConfig();
311
246
  const imageDeliveryUrl = `https://imagedelivery.net/${accountHash}/${fileData.id}/${DEFAULT_VARIANT}`;
312
-
313
- const workerResponse = await fetch(`${IMAGE_URL}/${imageDeliveryUrl}`, {
247
+
248
+ const workerResponse = await fetchImageApi(user, `/${imageDeliveryUrl}`, {
314
249
  method: 'GET',
315
250
  headers: {
316
- 'Authorization': `Bearer ${imagesApiToken}`,
317
251
  'Accept': 'text/plain'
318
252
  }
319
253
  });
@@ -9,13 +9,14 @@
9
9
  background: var(--backgroundLight);
10
10
  cursor: pointer;
11
11
  transition: all var(--durationS) var(--bezierFastoutSlowin);
12
- box-shadow: 0 1px 3px color-mix(in lab, var(--backgroundLight) 30%, transparent);
12
+ box-shadow: 0 1px 3px
13
+ color-mix(in lab, var(--backgroundLight) 30%, transparent);
13
14
  }
14
15
 
15
16
  .button:hover {
16
17
  background: color-mix(in lab, var(--backgroundLight) 85%, var(--black));
17
- transform: translateY(-1px);
18
- box-shadow: 0 2px 6px color-mix(in lab, var(--backgroundLight) 40%, transparent);
18
+ box-shadow: 0 2px 6px
19
+ color-mix(in lab, var(--backgroundLight) 40%, transparent);
19
20
  }
20
21
 
21
22
  .button.active {
@@ -25,7 +26,6 @@
25
26
 
26
27
  .button.active:hover {
27
28
  background: color-mix(in lab, var(--success) 85%, var(--black));
28
- transform: translateY(-1px);
29
29
  box-shadow: 0 2px 6px color-mix(in lab, var(--success) 40%, transparent);
30
30
  }
31
31
 
@@ -43,7 +43,7 @@
43
43
  box-shadow: none;
44
44
  }
45
45
 
46
- .icon {
46
+ .icon {
47
47
  color: var(--text);
48
48
  transition: color var(--durationS) var(--bezierFastoutSlowin);
49
49
  }
@@ -58,6 +58,10 @@
58
58
  }
59
59
 
60
60
  @keyframes spin {
61
- 0% { transform: rotate(0deg); }
62
- 100% { transform: rotate(360deg); }
63
- }
61
+ 0% {
62
+ transform: rotate(0deg);
63
+ }
64
+ 100% {
65
+ transform: rotate(360deg);
66
+ }
67
+ }
@@ -10,8 +10,8 @@ export type ExportFormat = 'json' | 'csv';
10
10
  interface CaseExportProps {
11
11
  isOpen: boolean;
12
12
  onClose: () => void;
13
- onExport: (caseNumber: string, format: ExportFormat, includeImages?: boolean) => void;
14
- onExportAll: (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => void;
13
+ onExport: (caseNumber: string, format: ExportFormat, includeImages?: boolean, onProgress?: (progress: number, label: string) => void) => Promise<void>;
14
+ onExportAll: (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => Promise<void>;
15
15
  currentCaseNumber?: string;
16
16
  isReadOnly?: boolean;
17
17
  }
@@ -30,7 +30,7 @@ export const CaseExport = ({
30
30
  const [isExportingAll, setIsExportingAll] = useState(false);
31
31
  const [isExportingConfirmations, setIsExportingConfirmations] = useState(false);
32
32
  const [error, setError] = useState<string>('');
33
- const [exportProgress, setExportProgress] = useState<{ current: number; total: number; caseName: string } | null>(null);
33
+ const [exportProgress, setExportProgress] = useState<{ current: number; total: number; caseName: string; mode?: 'single' | 'all' } | null>(null);
34
34
  const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('json');
35
35
  const [includeImages, setIncludeImages] = useState(false);
36
36
  const [hasConfirmationData, setHasConfirmationData] = useState(false);
@@ -130,13 +130,16 @@ export const CaseExport = ({
130
130
  setExportProgress(null);
131
131
 
132
132
  try {
133
- await onExport(caseNumber.trim(), selectedFormat, includeImages);
133
+ await onExport(caseNumber.trim(), selectedFormat, includeImages, (progress, label) => {
134
+ setExportProgress({ current: progress, total: 100, caseName: label, mode: 'single' });
135
+ });
134
136
  onClose();
135
137
  } catch (error) {
136
138
  console.error('Export failed:', error);
137
139
  setError(error instanceof Error ? error.message : 'Export failed. Please try again.');
138
140
  } finally {
139
141
  setIsExporting(false);
142
+ setExportProgress(null);
140
143
  }
141
144
  };
142
145
 
@@ -315,7 +318,9 @@ export const CaseExport = ({
315
318
  {exportProgress && exportProgress.total > 0 && (
316
319
  <div className={styles.progressSection}>
317
320
  <div className={styles.progressText}>
318
- Exporting case {exportProgress.current} of {exportProgress.total}: {exportProgress.caseName}
321
+ {exportProgress.mode === 'single'
322
+ ? `${exportProgress.caseName} (${exportProgress.current}%)`
323
+ : `Exporting case ${exportProgress.current} of ${exportProgress.total}: ${exportProgress.caseName}`}
319
324
  </div>
320
325
  <div className={styles.progressBar}>
321
326
  <div
@@ -326,7 +331,7 @@ export const CaseExport = ({
326
331
  </div>
327
332
  )}
328
333
 
329
- {isExportingAll && !exportProgress && (
334
+ {(isExporting || isExportingAll) && !exportProgress && (
330
335
  <div className={styles.progressSection}>
331
336
  <div className={styles.progressText}>
332
337
  Preparing export...
@@ -539,25 +539,40 @@ const handleImageSelect = (file: FileData) => {
539
539
  ? 'Select an image first'
540
540
  : undefined;
541
541
 
542
- const handleExport = async (exportCaseNumber: string, format: ExportFormat, includeImages?: boolean) => {
542
+ const handleExport = async (exportCaseNumber: string, format: ExportFormat, includeImages?: boolean, onProgress?: (progress: number, label: string) => void) => {
543
543
  try {
544
544
  const caseExportActions = await loadCaseExportActions();
545
545
 
546
546
  if (includeImages) {
547
547
  // ZIP export with images - only available for single case exports
548
- await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format);
548
+ await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format, (progress) => {
549
+ const label = progress < 30 ? 'Loading case data' :
550
+ progress < 50 ? 'Preparing archive' :
551
+ progress < 80 ? 'Adding images' :
552
+ progress < 96 ? 'Finalizing' : 'Downloading';
553
+ onProgress?.(Math.round(progress), label);
554
+ });
549
555
  } else {
550
556
  // Standard data-only export
551
- const exportData = await caseExportActions.exportCaseData(user, exportCaseNumber, {
552
- includeMetadata: true
553
- });
554
-
557
+ onProgress?.(5, 'Loading case data');
558
+ const exportData = await caseExportActions.exportCaseData(
559
+ user,
560
+ exportCaseNumber,
561
+ { includeMetadata: true },
562
+ (current, total, label) => {
563
+ const p = total > 0 ? Math.round(10 + (current / total) * 60) : 10;
564
+ onProgress?.(p, label);
565
+ }
566
+ );
567
+ onProgress?.(75, 'Preparing download');
568
+
555
569
  // Download the exported data in the selected format
556
570
  if (format === 'json') {
557
571
  await caseExportActions.downloadCaseAsJSON(user, exportData);
558
572
  } else {
559
573
  await caseExportActions.downloadCaseAsCSV(user, exportData);
560
574
  }
575
+ onProgress?.(100, 'Complete');
561
576
  }
562
577
 
563
578
  } catch (error) {
@@ -109,7 +109,6 @@
109
109
  .footerSectionButton:hover {
110
110
  background-color: #5c636a;
111
111
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
112
- transform: translateY(-1px);
113
112
  }
114
113
 
115
114
  /* Footer Modal */
@@ -163,7 +162,6 @@
163
162
 
164
163
  .footerModalClose:hover {
165
164
  background-color: #f8f9fa;
166
- transform: translateY(-1px);
167
165
  }
168
166
 
169
167
  .footerModalContent {
@@ -1,8 +1,7 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import { signOut } from 'firebase/auth';
3
3
  import { auth } from '~/services/firebase';
4
- import paths from '~/config/config.json';
5
- import { getUserApiKey } from '~/utils/auth';
4
+ import { fetchUserApi } from '~/utils/user-api-client';
6
5
  import { auditService } from '~/services/audit';
7
6
  import styles from './delete-account.module.css';
8
7
 
@@ -220,14 +219,15 @@ export const DeleteAccount = ({ isOpen, onClose, user, company }: DeleteAccountP
220
219
  false // emailNotificationSent - deletion emails disabled
221
220
  );
222
221
 
223
- // Get API key for user-worker authentication
224
- const apiKey = await getUserApiKey();
222
+ const currentUser = auth.currentUser;
223
+ if (!currentUser || currentUser.uid !== user.uid) {
224
+ throw new Error('User session mismatch. Please sign in again.');
225
+ }
225
226
 
226
- // Delete the user account via user-worker
227
- const deleteResponse = await fetch(`${paths.user_worker_url}/${user.uid}?stream=true`, {
227
+ // Delete the user account via user proxy
228
+ const deleteResponse = await fetchUserApi(currentUser, `/${encodeURIComponent(user.uid)}?stream=true`, {
228
229
  method: 'DELETE',
229
230
  headers: {
230
- 'X-Custom-Auth-Key': apiKey,
231
231
  'Accept': 'text/event-stream'
232
232
  }
233
233
  });
@@ -1,12 +1,6 @@
1
1
  {
2
- "url": "PAGES_CUSTOM_DOMAIN",
3
- "data_worker_url": "DATA_WORKER_CUSTOM_DOMAIN",
4
- "keys_url": "KEYS_WORKER_CUSTOM_DOMAIN",
5
- "image_worker_url": "IMAGE_WORKER_CUSTOM_DOMAIN",
6
- "user_worker_url": "USER_WORKER_CUSTOM_DOMAIN",
7
- "pdf_worker_url": "PDF_WORKER_CUSTOM_DOMAIN",
8
- "audit_worker_url": "AUDIT_WORKER_CUSTOM_DOMAIN",
9
- "keys_auth": "YOUR_KEYS_AUTH_TOKEN",
2
+ "url": "PAGES_CUSTOM_DOMAIN",
3
+ "account_hash": "ACCOUNT_HASH",
10
4
  "manifest_signing_key_id": "MANIFEST_SIGNING_KEY_ID",
11
5
  "manifest_signing_public_key": "MANIFEST_SIGNING_PUBLIC_KEY",
12
6
  "manifest_signing_public_keys": {
@@ -1,5 +1,4 @@
1
1
  import { useEffect, useRef, useCallback } from 'react';
2
- import { useLocation } from 'react-router';
3
2
  import { signOut } from 'firebase/auth';
4
3
  import { auth } from '~/services/firebase';
5
4
  import { INACTIVITY_CONFIG } from '~/config/inactivity';
@@ -19,13 +18,11 @@ export const useInactivityTimeout = ({
19
18
  onTimeout,
20
19
  enabled = true
21
20
  }: UseInactivityTimeoutOptions = {}) => {
22
- const location = useLocation();
23
21
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
24
22
  const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
25
23
  const lastActivityRef = useRef<number>(0);
26
24
 
27
- const isAuthRoute = location.pathname.startsWith('/auth');
28
- const shouldEnable = enabled && isAuthRoute;
25
+ const shouldEnable = enabled;
29
26
 
30
27
  useEffect(() => {
31
28
  lastActivityRef.current = Date.now();
@@ -104,7 +101,7 @@ export const useInactivityTimeout = ({
104
101
  });
105
102
  clearTimeouts();
106
103
  };
107
- }, [shouldEnable, resetTimer, clearTimeouts, location.pathname]);
104
+ }, [shouldEnable, resetTimer, clearTimeouts]);
108
105
 
109
106
  return {
110
107
  extendSession,