@striae-org/striae 3.0.4

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 (223) hide show
  1. package/.env.example +100 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +18 -0
  4. package/README.md +133 -0
  5. package/app/components/actions/case-export/core-export.ts +328 -0
  6. package/app/components/actions/case-export/data-processing.ts +167 -0
  7. package/app/components/actions/case-export/download-handlers.ts +900 -0
  8. package/app/components/actions/case-export/index.ts +41 -0
  9. package/app/components/actions/case-export/metadata-helpers.ts +107 -0
  10. package/app/components/actions/case-export/types-constants.ts +56 -0
  11. package/app/components/actions/case-export/validation-utils.ts +25 -0
  12. package/app/components/actions/case-export.ts +4 -0
  13. package/app/components/actions/case-import/annotation-import.ts +35 -0
  14. package/app/components/actions/case-import/confirmation-import.ts +363 -0
  15. package/app/components/actions/case-import/image-operations.ts +61 -0
  16. package/app/components/actions/case-import/index.ts +39 -0
  17. package/app/components/actions/case-import/orchestrator.ts +420 -0
  18. package/app/components/actions/case-import/storage-operations.ts +270 -0
  19. package/app/components/actions/case-import/validation.ts +189 -0
  20. package/app/components/actions/case-import/zip-processing.ts +413 -0
  21. package/app/components/actions/case-manage.ts +524 -0
  22. package/app/components/actions/case-review.ts +4 -0
  23. package/app/components/actions/confirm-export.ts +351 -0
  24. package/app/components/actions/generate-pdf.ts +210 -0
  25. package/app/components/actions/image-manage.ts +385 -0
  26. package/app/components/actions/notes-manage.ts +33 -0
  27. package/app/components/actions/signout.module.css +15 -0
  28. package/app/components/actions/signout.tsx +50 -0
  29. package/app/components/audit/user-audit-viewer.tsx +975 -0
  30. package/app/components/audit/user-audit.module.css +568 -0
  31. package/app/components/auth/auth-provider.tsx +78 -0
  32. package/app/components/auth/mfa-enrollment.module.css +268 -0
  33. package/app/components/auth/mfa-enrollment.tsx +398 -0
  34. package/app/components/auth/mfa-verification.module.css +251 -0
  35. package/app/components/auth/mfa-verification.tsx +295 -0
  36. package/app/components/button/button.module.css +63 -0
  37. package/app/components/button/button.tsx +46 -0
  38. package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
  39. package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
  40. package/app/components/canvas/canvas.module.css +314 -0
  41. package/app/components/canvas/canvas.tsx +449 -0
  42. package/app/components/canvas/confirmation/confirmation.module.css +187 -0
  43. package/app/components/canvas/confirmation/confirmation.tsx +214 -0
  44. package/app/components/colors/colors.module.css +59 -0
  45. package/app/components/colors/colors.tsx +68 -0
  46. package/app/components/form/base-form.tsx +21 -0
  47. package/app/components/form/form-button.tsx +28 -0
  48. package/app/components/form/form-field.tsx +53 -0
  49. package/app/components/form/form-message.tsx +17 -0
  50. package/app/components/form/form-toggle.tsx +23 -0
  51. package/app/components/form/form.module.css +427 -0
  52. package/app/components/form/index.ts +6 -0
  53. package/app/components/icon/icon.module.css +3 -0
  54. package/app/components/icon/icon.tsx +27 -0
  55. package/app/components/icon/icons.svg +102 -0
  56. package/app/components/icon/manifest.json +110 -0
  57. package/app/components/sidebar/case-export/case-export.module.css +386 -0
  58. package/app/components/sidebar/case-export/case-export.tsx +317 -0
  59. package/app/components/sidebar/case-import/case-import.module.css +626 -0
  60. package/app/components/sidebar/case-import/case-import.tsx +404 -0
  61. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
  62. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
  63. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
  64. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
  65. package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
  66. package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
  67. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
  68. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
  69. package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
  70. package/app/components/sidebar/case-import/index.ts +18 -0
  71. package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
  72. package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
  73. package/app/components/sidebar/cases/cases-modal.module.css +166 -0
  74. package/app/components/sidebar/cases/cases-modal.tsx +201 -0
  75. package/app/components/sidebar/cases/cases.module.css +713 -0
  76. package/app/components/sidebar/files/files-modal.module.css +209 -0
  77. package/app/components/sidebar/files/files-modal.tsx +239 -0
  78. package/app/components/sidebar/hash/hash-utility.module.css +366 -0
  79. package/app/components/sidebar/hash/hash-utility.tsx +982 -0
  80. package/app/components/sidebar/notes/notes-modal.tsx +51 -0
  81. package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
  82. package/app/components/sidebar/notes/notes.module.css +360 -0
  83. package/app/components/sidebar/sidebar-container.tsx +149 -0
  84. package/app/components/sidebar/sidebar.module.css +321 -0
  85. package/app/components/sidebar/sidebar.tsx +215 -0
  86. package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
  87. package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
  88. package/app/components/theme-provider/theme-provider.tsx +131 -0
  89. package/app/components/theme-provider/theme.ts +155 -0
  90. package/app/components/toast/toast.module.css +137 -0
  91. package/app/components/toast/toast.tsx +56 -0
  92. package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
  93. package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
  94. package/app/components/toolbar/toolbar.module.css +42 -0
  95. package/app/components/toolbar/toolbar.tsx +167 -0
  96. package/app/components/user/delete-account.module.css +274 -0
  97. package/app/components/user/delete-account.tsx +471 -0
  98. package/app/components/user/inactivity-warning.module.css +145 -0
  99. package/app/components/user/inactivity-warning.tsx +84 -0
  100. package/app/components/user/manage-profile.module.css +190 -0
  101. package/app/components/user/manage-profile.tsx +253 -0
  102. package/app/components/user/mfa-phone-update.tsx +739 -0
  103. package/app/config-example/admin-service.json +13 -0
  104. package/app/config-example/config.json +17 -0
  105. package/app/config-example/firebase.ts +21 -0
  106. package/app/config-example/inactivity.ts +13 -0
  107. package/app/config-example/meta-config.json +6 -0
  108. package/app/contexts/auth.context.ts +12 -0
  109. package/app/entry.client.tsx +12 -0
  110. package/app/entry.server.tsx +44 -0
  111. package/app/hooks/useInactivityTimeout.ts +110 -0
  112. package/app/root.tsx +170 -0
  113. package/app/routes/_index.tsx +16 -0
  114. package/app/routes/auth/emailActionHandler.module.css +232 -0
  115. package/app/routes/auth/emailActionHandler.tsx +405 -0
  116. package/app/routes/auth/emailVerification.tsx +120 -0
  117. package/app/routes/auth/login.module.css +523 -0
  118. package/app/routes/auth/login.tsx +654 -0
  119. package/app/routes/auth/passwordReset.module.css +274 -0
  120. package/app/routes/auth/passwordReset.tsx +154 -0
  121. package/app/routes/auth/route.ts +16 -0
  122. package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
  123. package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
  124. package/app/routes/mobile-prevented/route.ts +14 -0
  125. package/app/routes/striae/striae.module.css +30 -0
  126. package/app/routes/striae/striae.tsx +417 -0
  127. package/app/services/audit-export.service.ts +755 -0
  128. package/app/services/audit.service.ts +1454 -0
  129. package/app/services/firebase-errors.ts +106 -0
  130. package/app/services/firebase.ts +15 -0
  131. package/app/styles/legal-pages.module.css +113 -0
  132. package/app/styles/root.module.css +146 -0
  133. package/app/tailwind.css +225 -0
  134. package/app/types/annotations.ts +45 -0
  135. package/app/types/audit.ts +301 -0
  136. package/app/types/case.ts +90 -0
  137. package/app/types/export.ts +8 -0
  138. package/app/types/file.ts +30 -0
  139. package/app/types/import.ts +107 -0
  140. package/app/types/index.ts +24 -0
  141. package/app/types/user.ts +38 -0
  142. package/app/utils/SHA256.ts +461 -0
  143. package/app/utils/annotation-timestamp.ts +25 -0
  144. package/app/utils/audit-export-signature.ts +117 -0
  145. package/app/utils/auth-action-settings.ts +48 -0
  146. package/app/utils/auth.ts +34 -0
  147. package/app/utils/batch-operations.ts +135 -0
  148. package/app/utils/confirmation-signature.ts +193 -0
  149. package/app/utils/data-operations.ts +871 -0
  150. package/app/utils/device-detection.ts +5 -0
  151. package/app/utils/html-sanitizer.ts +80 -0
  152. package/app/utils/id-generator.ts +36 -0
  153. package/app/utils/meta.ts +48 -0
  154. package/app/utils/mfa-phone.ts +97 -0
  155. package/app/utils/mfa.ts +79 -0
  156. package/app/utils/password-policy.ts +28 -0
  157. package/app/utils/permissions.ts +562 -0
  158. package/app/utils/signature-utils.ts +160 -0
  159. package/app/utils/style.ts +83 -0
  160. package/app/utils/version.ts +5 -0
  161. package/firebase.json +11 -0
  162. package/functions/[[path]].ts +10 -0
  163. package/package.json +138 -0
  164. package/postcss.config.js +6 -0
  165. package/public/.well-known/publickey.info@striae.org.asc +17 -0
  166. package/public/.well-known/security.txt +7 -0
  167. package/public/_headers +28 -0
  168. package/public/_routes.json +13 -0
  169. package/public/assets/striae.jpg +0 -0
  170. package/public/clear.jpg +0 -0
  171. package/public/favicon.ico +0 -0
  172. package/public/favicon.svg +9 -0
  173. package/public/icon-256.png +0 -0
  174. package/public/icon-512.png +0 -0
  175. package/public/logo-dark.png +0 -0
  176. package/public/manifest.json +25 -0
  177. package/public/oin-badge.png +0 -0
  178. package/public/shortcut.png +0 -0
  179. package/public/social-image.png +0 -0
  180. package/public/striae-ascii.txt +10 -0
  181. package/scripts/deploy-all.sh +100 -0
  182. package/scripts/deploy-config.sh +940 -0
  183. package/scripts/deploy-pages.sh +34 -0
  184. package/scripts/deploy-worker-secrets.sh +215 -0
  185. package/scripts/dev.cjs +23 -0
  186. package/scripts/install-workers.sh +88 -0
  187. package/scripts/run-eslint.cjs +35 -0
  188. package/scripts/update-compatibility-dates.cjs +124 -0
  189. package/scripts/update-markdown-versions.cjs +43 -0
  190. package/tailwind.config.ts +22 -0
  191. package/tsconfig.json +33 -0
  192. package/vite.config.ts +35 -0
  193. package/worker-configuration.d.ts +7490 -0
  194. package/workers/audit-worker/package.json +17 -0
  195. package/workers/audit-worker/src/audit-worker.example.ts +195 -0
  196. package/workers/audit-worker/worker-configuration.d.ts +7448 -0
  197. package/workers/audit-worker/wrangler.jsonc.example +29 -0
  198. package/workers/data-worker/package.json +17 -0
  199. package/workers/data-worker/src/data-worker.example.ts +267 -0
  200. package/workers/data-worker/src/signature-utils.ts +79 -0
  201. package/workers/data-worker/src/signing-payload-utils.ts +290 -0
  202. package/workers/data-worker/worker-configuration.d.ts +7448 -0
  203. package/workers/data-worker/wrangler.jsonc.example +30 -0
  204. package/workers/image-worker/package.json +17 -0
  205. package/workers/image-worker/src/image-worker.example.ts +180 -0
  206. package/workers/image-worker/worker-configuration.d.ts +7447 -0
  207. package/workers/image-worker/wrangler.jsonc.example +22 -0
  208. package/workers/keys-worker/package.json +17 -0
  209. package/workers/keys-worker/src/keys.example.ts +66 -0
  210. package/workers/keys-worker/src/keys.ts +66 -0
  211. package/workers/keys-worker/worker-configuration.d.ts +7447 -0
  212. package/workers/keys-worker/wrangler.jsonc.example +22 -0
  213. package/workers/pdf-worker/package.json +17 -0
  214. package/workers/pdf-worker/src/format-striae.ts +534 -0
  215. package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
  216. package/workers/pdf-worker/src/report-types.ts +69 -0
  217. package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
  218. package/workers/pdf-worker/wrangler.jsonc.example +26 -0
  219. package/workers/user-worker/package.json +17 -0
  220. package/workers/user-worker/src/user-worker.example.ts +636 -0
  221. package/workers/user-worker/worker-configuration.d.ts +7448 -0
  222. package/workers/user-worker/wrangler.jsonc.example +29 -0
  223. package/wrangler.toml.example +8 -0
@@ -0,0 +1,827 @@
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
+ import { useState, useEffect, useMemo, useCallback } from 'react';
12
+ import styles from './cases.module.css';
13
+ import { CasesModal } from './cases-modal';
14
+ import { FilesModal } from '../files/files-modal';
15
+ import { CaseExport, ExportFormat } from '../case-export/case-export';
16
+ import { ImageUploadZone } from '../upload/image-upload-zone';
17
+ import { UserAuditViewer } from '~/components/audit/user-audit-viewer';
18
+ import {
19
+ validateCaseNumber,
20
+ checkExistingCase,
21
+ createNewCase,
22
+ renameCase,
23
+ deleteCase,
24
+ } from '../../actions/case-manage';
25
+ import {
26
+ fetchFiles,
27
+ deleteFile,
28
+ } from '../../actions/image-manage';
29
+ import {
30
+ checkReadOnlyCaseExists
31
+ } from '../../actions/case-review';
32
+ import {
33
+ canCreateCase,
34
+ canUploadFile,
35
+ getLimitsDescription,
36
+ getUserData
37
+ } from '~/utils/permissions';
38
+ import { getFileAnnotations } from '~/utils/data-operations';
39
+ import { FileData, CaseActionType } from '~/types';
40
+
41
+ interface CaseSidebarProps {
42
+ user: User;
43
+ onImageSelect: (file: FileData) => void;
44
+ onCaseChange: (caseNumber: string) => void;
45
+ imageLoaded: boolean;
46
+ setImageLoaded: (loaded: boolean) => void;
47
+ onNotesClick: () => void;
48
+ files: FileData[];
49
+ setFiles: React.Dispatch<React.SetStateAction<FileData[]>>;
50
+ caseNumber: string;
51
+ setCaseNumber: (caseNumber: string) => void;
52
+ currentCase: string | null;
53
+ setCurrentCase: (caseNumber: string) => void;
54
+ error: string;
55
+ setError: (error: string) => void;
56
+ successAction: CaseActionType;
57
+ setSuccessAction: (action: CaseActionType) => void;
58
+ isReadOnly?: boolean;
59
+ isConfirmed?: boolean;
60
+ confirmationSaveVersion?: number;
61
+ selectedFileId?: string;
62
+ isUploading?: boolean;
63
+ onUploadStatusChange?: (isUploading: boolean) => void;
64
+ onUploadComplete?: (result: { successCount: number; failedFiles: string[] }) => void;
65
+ }
66
+
67
+ const SUCCESS_MESSAGE_TIMEOUT = 3000;
68
+
69
+ export const CaseSidebar = ({
70
+ user,
71
+ onImageSelect,
72
+ onCaseChange,
73
+ imageLoaded,
74
+ setImageLoaded,
75
+ onNotesClick,
76
+ files,
77
+ setFiles,
78
+ caseNumber,
79
+ setCaseNumber,
80
+ currentCase,
81
+ setCurrentCase,
82
+ error,
83
+ setError,
84
+ successAction,
85
+ setSuccessAction,
86
+ isReadOnly = false,
87
+ isConfirmed = false,
88
+ confirmationSaveVersion = 0,
89
+ selectedFileId,
90
+ isUploading = false,
91
+ onUploadStatusChange,
92
+ onUploadComplete
93
+ }: CaseSidebarProps) => {
94
+
95
+ const [isDeletingCase, setIsDeletingCase] = useState(false);
96
+ const [isRenaming, setIsRenaming] = useState(false);
97
+ const [isLoading, setIsLoading] = useState(false);
98
+ const [isModalOpen, setIsModalOpen] = useState(false);
99
+ const [fileError, setFileError] = useState('');
100
+ const [newCaseName, setNewCaseName] = useState('');
101
+ const [showCaseActions, setShowCaseActions] = useState(false);
102
+ const [canCreateNewCase, setCanCreateNewCase] = useState(true);
103
+ const [canUploadNewFile, setCanUploadNewFile] = useState(true);
104
+ const [createCaseError, setCreateCaseError] = useState('');
105
+ const [uploadFileError, setUploadFileError] = useState('');
106
+ const [limitsDescription, setLimitsDescription] = useState('');
107
+ const [permissionChecking, setPermissionChecking] = useState(false);
108
+ const [isExportModalOpen, setIsExportModalOpen] = useState(false);
109
+ const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
110
+ const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
111
+ const [isAuditTrailOpen, setIsAuditTrailOpen] = useState(false);
112
+ const [fileConfirmationStatus, setFileConfirmationStatus] = useState<{
113
+ [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean }
114
+ }>({});
115
+ const [caseConfirmationStatus, setCaseConfirmationStatus] = useState<{
116
+ includeConfirmation: boolean;
117
+ isConfirmed: boolean;
118
+ }>({ includeConfirmation: false, isConfirmed: false });
119
+
120
+ const fileIdsKey = useMemo(
121
+ () => files.map((file) => file.id).sort().join('|'),
122
+ [files]
123
+ );
124
+
125
+ const calculateCaseConfirmationStatus = useCallback((
126
+ statuses: { [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean } }
127
+ ) => {
128
+ const filesRequiringConfirmation = files
129
+ .map((file) => statuses[file.id] || { includeConfirmation: false, isConfirmed: false })
130
+ .filter((status) => status.includeConfirmation);
131
+
132
+ const allConfirmedFiles = filesRequiringConfirmation.every((status) => status.isConfirmed);
133
+
134
+ return {
135
+ includeConfirmation: filesRequiringConfirmation.length > 0,
136
+ isConfirmed: filesRequiringConfirmation.length > 0 ? allConfirmedFiles : false,
137
+ };
138
+ }, [files]);
139
+
140
+ // Check user permissions on mount and when user changes
141
+ useEffect(() => {
142
+ checkUserPermissions();
143
+ }, [user]);
144
+
145
+ // Function to check user permissions (extracted for reuse)
146
+ const checkUserPermissions = async () => {
147
+ setPermissionChecking(true);
148
+ try {
149
+ const casePermission = await canCreateCase(user);
150
+ setCanCreateNewCase(casePermission.canCreate);
151
+ setCreateCaseError(casePermission.reason || '');
152
+
153
+ // Only show limits description for restricted accounts
154
+ const userData = await getUserData(user);
155
+ if (userData && !userData.permitted) {
156
+ const description = await getLimitsDescription(user);
157
+ setLimitsDescription(description);
158
+ } else {
159
+ setLimitsDescription(''); // Clear the description for permitted users
160
+ }
161
+ } catch (error) {
162
+ console.error('Error checking user permissions:', error);
163
+ setCreateCaseError('Unable to verify account permissions');
164
+ } finally {
165
+ setPermissionChecking(false);
166
+ }
167
+ };
168
+
169
+ // Function to check file upload permissions (extracted for reuse)
170
+ const checkFileUploadPermissions = async (fileCount?: number) => {
171
+ if (currentCase) {
172
+ try {
173
+ // Use provided fileCount or fall back to current files.length
174
+ const currentFileCount = fileCount !== undefined ? fileCount : files.length;
175
+ const permission = await canUploadFile(user, currentFileCount);
176
+ setCanUploadNewFile(permission.canUpload);
177
+ setUploadFileError(permission.reason || '');
178
+ } catch (error) {
179
+ console.error('Error checking file upload permission:', error);
180
+ setCanUploadNewFile(false);
181
+ setUploadFileError('Unable to verify upload permissions');
182
+ }
183
+ } else {
184
+ setCanUploadNewFile(true);
185
+ setUploadFileError('');
186
+ }
187
+ };
188
+
189
+ // Check file upload permissions when currentCase or files change
190
+ useEffect(() => {
191
+ checkFileUploadPermissions();
192
+ }, [user, currentCase, files.length]);
193
+
194
+ useEffect(() => {
195
+ if (currentCase) {
196
+ setIsLoading(true);
197
+ fetchFiles(user, currentCase, { skipValidation: true })
198
+ .then(loadedFiles => {
199
+ setFiles(loadedFiles);
200
+ })
201
+ .catch(err => {
202
+ console.error('Failed to load files:', err);
203
+ setFileError(err instanceof Error ? err.message : 'Failed to load files');
204
+ })
205
+ .finally(() => {
206
+ setIsLoading(false);
207
+ });
208
+ } else {
209
+ setFiles([]);
210
+ }
211
+ }, [user, currentCase, setFiles]);
212
+
213
+ // Fetch confirmation status for all files when case/files change
214
+ useEffect(() => {
215
+ let isCancelled = false;
216
+
217
+ const fetchConfirmationStatuses = async () => {
218
+ if (!currentCase || !user || files.length === 0) {
219
+ if (!isCancelled) {
220
+ setFileConfirmationStatus({});
221
+ setCaseConfirmationStatus({ includeConfirmation: false, isConfirmed: false });
222
+ }
223
+ return;
224
+ }
225
+
226
+ // Fetch all annotations in parallel
227
+ const annotationPromises = files.map(async (file) => {
228
+ try {
229
+ const annotations = await getFileAnnotations(user, currentCase, file.id);
230
+ return {
231
+ fileId: file.id,
232
+ includeConfirmation: annotations?.includeConfirmation ?? false,
233
+ isConfirmed: !!annotations?.confirmationData,
234
+ };
235
+ } catch (err) {
236
+ console.error(`Error fetching annotations for file ${file.id}:`, err);
237
+ return {
238
+ fileId: file.id,
239
+ includeConfirmation: false,
240
+ isConfirmed: false,
241
+ };
242
+ }
243
+ });
244
+
245
+ // Wait for all fetches to complete
246
+ const results = await Promise.all(annotationPromises);
247
+
248
+ // Build the statuses map from results
249
+ const statuses: { [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
250
+ results.forEach((result) => {
251
+ statuses[result.fileId] = {
252
+ includeConfirmation: result.includeConfirmation,
253
+ isConfirmed: result.isConfirmed,
254
+ };
255
+ });
256
+
257
+ if (isCancelled) {
258
+ return;
259
+ }
260
+
261
+ setFileConfirmationStatus(statuses);
262
+ setCaseConfirmationStatus(calculateCaseConfirmationStatus(statuses));
263
+ };
264
+
265
+ fetchConfirmationStatuses();
266
+
267
+ return () => {
268
+ isCancelled = true;
269
+ };
270
+ }, [currentCase, fileIdsKey, user, calculateCaseConfirmationStatus]);
271
+
272
+ // Refresh only selected file confirmation status after confirmation-related data is persisted
273
+ useEffect(() => {
274
+ let isCancelled = false;
275
+
276
+ const refreshSelectedFileConfirmationStatus = async () => {
277
+ if (!currentCase || !user || !selectedFileId || files.length === 0) {
278
+ return;
279
+ }
280
+
281
+ try {
282
+ const annotations = await getFileAnnotations(user, currentCase, selectedFileId);
283
+ const selectedStatus = {
284
+ includeConfirmation: annotations?.includeConfirmation ?? false,
285
+ isConfirmed: !!annotations?.confirmationData,
286
+ };
287
+
288
+ if (isCancelled) {
289
+ return;
290
+ }
291
+
292
+ setFileConfirmationStatus((previous) => {
293
+ const next = {
294
+ ...previous,
295
+ [selectedFileId]: selectedStatus,
296
+ };
297
+
298
+ setCaseConfirmationStatus(calculateCaseConfirmationStatus(next));
299
+ return next;
300
+ });
301
+ } catch (err) {
302
+ console.error(`Error refreshing confirmation status for file ${selectedFileId}:`, err);
303
+ }
304
+ };
305
+
306
+ refreshSelectedFileConfirmationStatus();
307
+
308
+ return () => {
309
+ isCancelled = true;
310
+ };
311
+ }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, calculateCaseConfirmationStatus]);
312
+
313
+ const handleCase = async () => {
314
+ setIsLoading(true);
315
+ setError('');
316
+ setCreateCaseError(''); // Clear permission errors when starting new operation
317
+
318
+ if (!validateCaseNumber(caseNumber)) {
319
+ setError('Invalid case number format');
320
+ setIsLoading(false);
321
+ return;
322
+ }
323
+
324
+ try {
325
+ const existingCase = await checkExistingCase(user, caseNumber);
326
+
327
+ if (existingCase) {
328
+ // Loading existing case - always allowed
329
+ setCurrentCase(caseNumber);
330
+ onCaseChange(caseNumber);
331
+ const files = await fetchFiles(user, caseNumber, { skipValidation: true });
332
+ setFiles(files);
333
+ setCaseNumber('');
334
+ setSuccessAction('loaded');
335
+ setTimeout(() => setSuccessAction(null), SUCCESS_MESSAGE_TIMEOUT);
336
+ return;
337
+ }
338
+
339
+ // Check if a read-only case with this number exists
340
+ const existingReadOnlyCase = await checkReadOnlyCaseExists(user, caseNumber);
341
+ if (existingReadOnlyCase) {
342
+ setError(`Case "${caseNumber}" already exists as a read-only review case. You cannot create a case with the same number.`);
343
+ setIsLoading(false);
344
+ return;
345
+ }
346
+
347
+ // Creating new case - check permissions
348
+ if (!canCreateNewCase) {
349
+ setError(createCaseError || 'You cannot create more cases.');
350
+ setCreateCaseError(''); // Clear duplicate error
351
+ setIsLoading(false);
352
+ return;
353
+ }
354
+
355
+ const newCase = await createNewCase(user, caseNumber);
356
+ setCurrentCase(newCase.caseNumber);
357
+ onCaseChange(newCase.caseNumber);
358
+ setFiles([]);
359
+ setCaseNumber('');
360
+ setSuccessAction('created');
361
+ setTimeout(() => setSuccessAction(null), SUCCESS_MESSAGE_TIMEOUT);
362
+
363
+ // Refresh permissions after successful case creation
364
+ // This updates the UI for users with limited permissions
365
+ await checkUserPermissions();
366
+ } catch (err) {
367
+ setError(err instanceof Error ? err.message : 'Failed to load/create case');
368
+ console.error(err);
369
+ } finally {
370
+ setIsLoading(false);
371
+ }
372
+ };
373
+
374
+
375
+
376
+ const handleFileDelete = async (fileId: string) => {
377
+ // Don't allow file deletion for read-only cases
378
+ if (isReadOnly) {
379
+ return;
380
+ }
381
+
382
+ if (!currentCase) return;
383
+
384
+ setFileError('');
385
+ setDeletingFileId(fileId);
386
+
387
+ try {
388
+ await deleteFile(user, currentCase, fileId);
389
+ const updatedFiles = files.filter(f => f.id !== fileId);
390
+ setFiles(updatedFiles);
391
+ onImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
392
+ setImageLoaded(false);
393
+
394
+ // Refresh file upload permissions after successful file deletion
395
+ // Pass the new file count directly to avoid state update timing issues
396
+ await checkFileUploadPermissions(updatedFiles.length);
397
+ } catch (err) {
398
+ setFileError(err instanceof Error ? err.message : 'Delete failed');
399
+ } finally {
400
+ setDeletingFileId(null);
401
+ }
402
+ };
403
+
404
+ const handleRenameCase = async () => {
405
+ // Don't allow renaming read-only cases
406
+ if (isReadOnly) {
407
+ return;
408
+ }
409
+
410
+ if (!currentCase || !newCaseName) return;
411
+
412
+ if (!validateCaseNumber(newCaseName)) {
413
+ setError('Invalid new case number format');
414
+ return;
415
+ }
416
+
417
+ setIsRenaming(true);
418
+ setError('');
419
+
420
+ try {
421
+ // Check if a read-only case with the new name exists
422
+ const existingReadOnlyCase = await checkReadOnlyCaseExists(user, newCaseName);
423
+ if (existingReadOnlyCase) {
424
+ setError(`Case "${newCaseName}" already exists as a read-only review case. You cannot rename to this case number.`);
425
+ setIsRenaming(false);
426
+ return;
427
+ }
428
+
429
+ await renameCase(user, currentCase, newCaseName);
430
+ setCurrentCase(newCaseName);
431
+ onCaseChange(newCaseName);
432
+ setNewCaseName('');
433
+ setSuccessAction('loaded');
434
+ setTimeout(() => setSuccessAction(null), SUCCESS_MESSAGE_TIMEOUT);
435
+ } catch (err) {
436
+ setError(err instanceof Error ? err.message : 'Failed to rename case');
437
+ } finally {
438
+ setIsRenaming(false);
439
+ }
440
+ };
441
+
442
+ const handleDeleteCase = async () => {
443
+ // Don't allow deleting read-only cases
444
+ if (isReadOnly) {
445
+ return;
446
+ }
447
+
448
+ if (!currentCase) return;
449
+
450
+ const confirmed = window.confirm(
451
+ `Are you sure you want to delete case ${currentCase}? This will permanently delete all associated files and cannot be undone.`
452
+ );
453
+
454
+ if (!confirmed) return;
455
+
456
+ setIsDeletingCase(true);
457
+ setError('');
458
+
459
+ try {
460
+ await deleteCase(user, currentCase);
461
+ setCurrentCase('');
462
+ onCaseChange('');
463
+ setFiles([]);
464
+ setSuccessAction('deleted');
465
+ setTimeout(() => setSuccessAction(null), SUCCESS_MESSAGE_TIMEOUT);
466
+
467
+ // Refresh permissions after successful case deletion
468
+ // This allows users with limited permissions to create a new case
469
+ await checkUserPermissions();
470
+ } catch (err) {
471
+ setError(err instanceof Error ? err.message : 'Failed to delete case');
472
+ } finally {
473
+ setIsDeletingCase(false);
474
+ }
475
+ };
476
+
477
+ const handleImageSelect = (file: FileData) => {
478
+ onImageSelect(file);
479
+ // Prevent notes from opening against stale image state while selection loads.
480
+ setImageLoaded(false);
481
+ };
482
+
483
+ const selectedFileConfirmationState = selectedFileId
484
+ ? fileConfirmationStatus[selectedFileId]
485
+ : undefined;
486
+
487
+ const isCheckingSelectedFileConfirmation = Boolean(
488
+ selectedFileId && !selectedFileConfirmationState
489
+ );
490
+
491
+ const isSelectedFileConfirmed =
492
+ isConfirmed || !!selectedFileConfirmationState?.isConfirmed;
493
+
494
+ const isImageNotesDisabled =
495
+ !imageLoaded ||
496
+ isReadOnly ||
497
+ isSelectedFileConfirmed ||
498
+ isUploading ||
499
+ isCheckingSelectedFileConfirmation;
500
+
501
+ const imageNotesTitle = isUploading
502
+ ? 'Cannot edit notes while uploading'
503
+ : isCheckingSelectedFileConfirmation
504
+ ? 'Checking confirmation status...'
505
+ : isSelectedFileConfirmed
506
+ ? 'Cannot edit notes for confirmed images'
507
+ : isReadOnly
508
+ ? 'Cannot edit notes for read-only cases'
509
+ : !imageLoaded
510
+ ? 'Select an image first'
511
+ : undefined;
512
+
513
+ const handleExport = async (exportCaseNumber: string, format: ExportFormat, includeImages?: boolean) => {
514
+ try {
515
+ if (includeImages) {
516
+ // ZIP export with images - only available for single case exports
517
+ await downloadCaseAsZip(user, exportCaseNumber, format);
518
+ } else {
519
+ // Standard data-only export
520
+ const exportData = await exportCaseData(user, exportCaseNumber, {
521
+ includeMetadata: true
522
+ });
523
+
524
+ // Download the exported data in the selected format
525
+ if (format === 'json') {
526
+ await downloadCaseAsJSON(user, exportData);
527
+ } else {
528
+ await downloadCaseAsCSV(user, exportData);
529
+ }
530
+ }
531
+
532
+ } catch (error) {
533
+ console.error('Export failed:', error);
534
+ throw error; // Re-throw to be handled by the modal
535
+ }
536
+ };
537
+
538
+ const handleExportAll = async (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => {
539
+ try {
540
+ // Export all cases with progress callback
541
+ const exportData = await exportAllCases(user, {
542
+ includeMetadata: true
543
+ }, onProgress);
544
+
545
+ // Download the exported data in the selected format
546
+ if (format === 'json') {
547
+ await downloadAllCasesAsJSON(user, exportData);
548
+ } else {
549
+ await downloadAllCasesAsCSV(user, exportData);
550
+ }
551
+
552
+ } catch (error) {
553
+ console.error('Export all failed:', error);
554
+ throw error; // Re-throw to be handled by the modal
555
+ }
556
+ };
557
+
558
+ return (
559
+ <div className={styles.caseSection}>
560
+ <div className={styles.caseSection}>
561
+ <h4>Case Management</h4>
562
+ {limitsDescription && (
563
+ <p className={styles.limitsInfo}>
564
+ {limitsDescription}
565
+ </p>
566
+ )}
567
+ <div className={`${styles.caseInput} mb-4`}>
568
+ <input
569
+ type="text"
570
+ value={caseNumber}
571
+ onChange={(e) => setCaseNumber(e.target.value)}
572
+ placeholder="Case #"
573
+ />
574
+ </div>
575
+ <div className={`${styles.caseLoad} mb-4`}>
576
+ <button
577
+ onClick={handleCase}
578
+ disabled={isLoading || !caseNumber || permissionChecking || (isReadOnly && !!currentCase) || isUploading}
579
+ title={
580
+ isUploading
581
+ ? "Cannot load/create cases while uploading files"
582
+ : (isReadOnly && currentCase)
583
+ ? "Cannot load/create cases while reviewing a read-only case. Clear the current case first."
584
+ : (!canCreateNewCase ? createCaseError : undefined)
585
+ }
586
+ >
587
+ {isLoading ? 'Loading...' : permissionChecking ? 'Checking permissions...' : 'Load/Create Case'}
588
+ </button>
589
+ </div>
590
+ <div className={styles.caseInput}>
591
+ <button
592
+ onClick={() => setIsModalOpen(true)}
593
+ className={styles.listButton}
594
+ disabled={isUploading}
595
+ title={isUploading ? "Cannot list cases while uploading files" : undefined}
596
+ >
597
+ List All Cases
598
+ </button>
599
+ </div>
600
+ {error && <p className={styles.error}>{error}</p>}
601
+ {successAction && (
602
+ <p className={styles.success}>
603
+ Case {currentCase} {successAction} successfully!
604
+ </p>
605
+ )}
606
+ <CasesModal
607
+ isOpen={isModalOpen}
608
+ onClose={() => setIsModalOpen(false)}
609
+ onSelectCase={setCaseNumber}
610
+ currentCase={currentCase || ''}
611
+ user={user}
612
+ />
613
+
614
+ <CaseExport
615
+ isOpen={isExportModalOpen}
616
+ onClose={() => setIsExportModalOpen(false)}
617
+ onExport={handleExport}
618
+ onExportAll={handleExportAll}
619
+ currentCaseNumber={currentCase || ''}
620
+ isReadOnly={isReadOnly}
621
+ />
622
+
623
+ <FilesModal
624
+ isOpen={isFilesModalOpen}
625
+ onClose={() => setIsFilesModalOpen(false)}
626
+ onFileSelect={handleImageSelect}
627
+ currentCase={currentCase}
628
+ files={files}
629
+ setFiles={setFiles}
630
+ isReadOnly={isReadOnly}
631
+ selectedFileId={selectedFileId}
632
+ />
633
+
634
+ <div className={styles.filesSection}>
635
+ <div className={isReadOnly && currentCase ? styles.readOnlyContainer : styles.caseHeader}>
636
+ <h4 className={`${styles.caseNumber} ${
637
+ currentCase && caseConfirmationStatus.includeConfirmation
638
+ ? caseConfirmationStatus.isConfirmed
639
+ ? styles.caseConfirmed
640
+ : styles.caseNotConfirmed
641
+ : ''
642
+ }`}>
643
+ {currentCase || 'No Case Selected'}
644
+ </h4>
645
+ {isReadOnly && currentCase && (
646
+ <div className={styles.readOnlyBadge}>(Read-Only)</div>
647
+ )}
648
+ </div>
649
+ {currentCase && (
650
+ <ImageUploadZone
651
+ user={user}
652
+ currentCase={currentCase}
653
+ isReadOnly={isReadOnly}
654
+ canUploadNewFile={canUploadNewFile}
655
+ uploadFileError={uploadFileError}
656
+ onFilesChanged={setFiles}
657
+ onUploadPermissionCheck={checkFileUploadPermissions}
658
+ currentFiles={files}
659
+ onUploadStatusChange={onUploadStatusChange}
660
+ onUploadComplete={onUploadComplete}
661
+ />
662
+ )}
663
+
664
+ {/* Files Modal Button - positioned between upload and file list */}
665
+ {currentCase && (
666
+ <div className={styles.filesModalSection}>
667
+ <button
668
+ className={styles.filesModalButton}
669
+ onClick={() => setIsFilesModalOpen(true)}
670
+ disabled={files.length === 0 || isUploading}
671
+ title={isUploading ? "Cannot view files while uploading" : files.length === 0 ? "No files to view" : "View all files in modal"}
672
+ >
673
+ View All Files ({files.length})
674
+ </button>
675
+ </div>
676
+ )}
677
+
678
+ {!currentCase ? (
679
+ <p className={styles.emptyState}>Create or select a case to view files</p>
680
+ ) : files.length === 0 ? (
681
+ <p className={styles.emptyState}>No files found for {currentCase}</p>
682
+ ) : (
683
+ <>
684
+ {!canUploadNewFile && (
685
+ <div className={styles.limitReached}>
686
+ <p>Upload limit reached for this case</p>
687
+ </div>
688
+ )}
689
+ <ul className={styles.fileList}>
690
+ {files.map((file) => {
691
+ const confirmationStatus = fileConfirmationStatus[file.id];
692
+ let confirmationClass = '';
693
+
694
+ if (confirmationStatus?.includeConfirmation) {
695
+ confirmationClass = confirmationStatus.isConfirmed
696
+ ? styles.fileItemConfirmed
697
+ : styles.fileItemNotConfirmed;
698
+ }
699
+
700
+ return (
701
+ <li key={file.id}
702
+ className={`${styles.fileItem} ${selectedFileId === file.id ? styles.active : ''} ${confirmationClass}`}>
703
+ <button
704
+ className={styles.fileButton}
705
+ onClick={() => handleImageSelect(file)}
706
+ onKeyDown={(e) => e.key === 'Enter' && handleImageSelect(file)}
707
+ disabled={isUploading}
708
+ title={isUploading ? "Cannot select files while uploading" : undefined}
709
+ >
710
+ <span className={styles.fileName}>{file.originalFilename}</span>
711
+ </button>
712
+ <button
713
+ onClick={() => {
714
+ if (window.confirm('Are you sure you want to delete this file? This action cannot be undone.')) {
715
+ handleFileDelete(file.id);
716
+ }
717
+ }}
718
+ className={styles.deleteButton}
719
+ aria-label="Delete file"
720
+ disabled={isReadOnly || deletingFileId === file.id || isUploading}
721
+ style={{ opacity: (isReadOnly || isUploading) ? 0.5 : 1, cursor: (isReadOnly || isUploading) ? 'not-allowed' : 'pointer' }}
722
+ title={isUploading ? "Cannot delete while uploading" : undefined}
723
+ >
724
+ {deletingFileId === file.id ? '⏳' : '×'}
725
+ </button>
726
+ </li>
727
+ );
728
+ })}
729
+ </ul>
730
+ </>
731
+ )}
732
+ </div>
733
+ <div className={`${styles.sidebarToggle} mb-4`}>
734
+ <button
735
+ onClick={onNotesClick}
736
+ disabled={isImageNotesDisabled}
737
+ title={imageNotesTitle}
738
+ >
739
+ Image Notes
740
+ </button>
741
+ </div>
742
+ {currentCase && (
743
+ <div className={styles.caseActionsSection}>
744
+ <button
745
+ onClick={() => setShowCaseActions(!showCaseActions)}
746
+ className={styles.caseActionsButton}
747
+ disabled={isUploading}
748
+ title={isUploading ? "Cannot access case actions while uploading" : undefined}
749
+ >
750
+ {showCaseActions ? 'Hide Case Actions' : 'Case Actions'}
751
+ </button>
752
+
753
+ {showCaseActions && !isUploading && (
754
+ <div className={styles.caseActionsContent}>
755
+ {/* Export Case Data Section */}
756
+ <div className={styles.exportSection}>
757
+ <button
758
+ onClick={() => setIsExportModalOpen(true)}
759
+ className={styles.exportButton}
760
+ disabled={isUploading}
761
+ title={isUploading ? "Cannot export while uploading" : undefined}
762
+ >
763
+ Export Case Data
764
+ </button>
765
+ </div>
766
+
767
+ {/* Audit Trail Section - Available for all cases */}
768
+ <div className={styles.auditTrailSection}>
769
+ <button
770
+ onClick={() => setIsAuditTrailOpen(true)}
771
+ className={styles.auditTrailButton}
772
+ disabled={isUploading}
773
+ title={isUploading ? "Cannot view audit trail while uploading" : undefined}
774
+ >
775
+ Audit Trail
776
+ </button>
777
+ </div>
778
+
779
+ {/* Rename/Delete Section - Only for owned cases */}
780
+ {!isReadOnly && (
781
+ <div className={styles.renameDeleteSection}>
782
+ <div className={`${styles.caseRename} mb-4`}>
783
+ <input
784
+ type="text"
785
+ value={newCaseName}
786
+ onChange={(e) => setNewCaseName(e.target.value)}
787
+ placeholder="New Case Number"
788
+ disabled={isUploading}
789
+ />
790
+ <button
791
+ onClick={handleRenameCase}
792
+ disabled={isRenaming || !newCaseName || isUploading}
793
+ title={isUploading ? "Cannot rename while uploading" : undefined}
794
+ >
795
+ {isRenaming ? 'Renaming...' : 'Rename Case'}
796
+ </button>
797
+ </div>
798
+
799
+ <div className={styles.deleteCaseSection}>
800
+ <button
801
+ onClick={handleDeleteCase}
802
+ disabled={isDeletingCase || isUploading}
803
+ className={styles.deleteWarningButton}
804
+ title={isUploading ? "Cannot delete while uploading" : undefined}
805
+ >
806
+ {isDeletingCase ? 'Deleting...' : 'Delete Case'}
807
+ </button>
808
+ </div>
809
+ </div>
810
+ )}
811
+ </div>
812
+ )}
813
+ </div>
814
+ )}
815
+
816
+ {/* Unified Audit Viewer */}
817
+ <UserAuditViewer
818
+ caseNumber={currentCase || ''}
819
+ isOpen={isAuditTrailOpen}
820
+ onClose={() => setIsAuditTrailOpen(false)}
821
+ title={`Audit Trail - Case ${currentCase}`}
822
+ />
823
+
824
+ </div>
825
+ </div>
826
+ );
827
+ };