@striae-org/striae 4.0.3 → 4.2.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 (118) hide show
  1. package/.env.example +8 -0
  2. package/app/components/actions/case-export/core-export.ts +14 -8
  3. package/app/components/actions/case-export/data-processing.ts +1 -0
  4. package/app/components/actions/case-export/download-handlers.ts +7 -0
  5. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  6. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  7. package/app/components/actions/case-import/orchestrator.ts +78 -32
  8. package/app/components/actions/case-import/storage-operations.ts +97 -8
  9. package/app/components/actions/case-import/zip-processing.ts +159 -86
  10. package/app/components/actions/case-manage.ts +430 -8
  11. package/app/components/actions/confirm-export.ts +13 -4
  12. package/app/components/actions/generate-pdf.ts +10 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +137 -945
  15. package/app/components/audit/user-audit.module.css +41 -0
  16. package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
  17. package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
  18. package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
  19. package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
  20. package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
  21. package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
  22. package/app/components/audit/viewer/types.ts +1 -0
  23. package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
  24. package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
  25. package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
  26. package/app/components/auth/mfa-enrollment.module.css +13 -5
  27. package/app/components/auth/mfa-verification.module.css +13 -5
  28. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  29. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  30. package/app/components/canvas/canvas.module.css +64 -54
  31. package/app/components/canvas/canvas.tsx +17 -16
  32. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  33. package/app/components/canvas/confirmation/confirmation.tsx +17 -47
  34. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  35. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  36. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  37. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  38. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  39. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  40. package/app/components/navbar/navbar.module.css +447 -0
  41. package/app/components/navbar/navbar.tsx +377 -0
  42. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
  43. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
  44. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  45. package/app/components/sidebar/case-export/case-export.tsx +14 -77
  46. package/app/components/sidebar/case-import/case-import.module.css +25 -0
  47. package/app/components/sidebar/case-import/case-import.tsx +64 -40
  48. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  49. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  50. package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
  51. package/app/components/sidebar/cases/cases-modal.module.css +45 -9
  52. package/app/components/sidebar/cases/cases-modal.tsx +16 -16
  53. package/app/components/sidebar/cases/cases.module.css +62 -21
  54. package/app/components/sidebar/files/files-modal.module.css +46 -10
  55. package/app/components/sidebar/files/files-modal.tsx +22 -23
  56. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  57. package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
  58. package/app/components/sidebar/notes/notes-modal.tsx +18 -17
  59. package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
  60. package/app/components/sidebar/notes/notes.module.css +155 -0
  61. package/app/components/sidebar/sidebar-container.tsx +15 -28
  62. package/app/components/sidebar/sidebar.module.css +7 -71
  63. package/app/components/sidebar/sidebar.tsx +24 -125
  64. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  65. package/app/components/toast/toast.module.css +2 -1
  66. package/app/components/toast/toast.tsx +16 -11
  67. package/app/components/user/delete-account.tsx +10 -31
  68. package/app/components/user/inactivity-warning.module.css +9 -6
  69. package/app/components/user/inactivity-warning.tsx +15 -2
  70. package/app/components/user/manage-profile.module.css +2 -0
  71. package/app/components/user/manage-profile.tsx +108 -40
  72. package/app/hooks/useOverlayDismiss.ts +116 -0
  73. package/app/routes/auth/login.example.tsx +19 -8
  74. package/app/routes/auth/login.tsx +785 -774
  75. package/app/routes/auth/passwordReset.module.css +23 -13
  76. package/app/routes/striae/striae.module.css +10 -3
  77. package/app/routes/striae/striae.tsx +477 -31
  78. package/app/routes.ts +7 -0
  79. package/app/services/audit/audit-export-csv.ts +2 -0
  80. package/app/services/audit/audit.service.ts +202 -32
  81. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  82. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  83. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  84. package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
  85. package/app/services/audit/builders/index.ts +1 -0
  86. package/app/types/audit.ts +5 -2
  87. package/app/types/case.ts +29 -0
  88. package/app/types/import.ts +3 -0
  89. package/app/types/user.ts +1 -0
  90. package/app/utils/data/permissions.ts +17 -1
  91. package/app/utils/forensics/audit-export-signature.ts +5 -1
  92. package/app/utils/forensics/confirmation-signature.ts +3 -0
  93. package/app/utils/forensics/export-verification.ts +497 -22
  94. package/functions/api/pdf/[[path]].ts +32 -1
  95. package/load-context.ts +9 -0
  96. package/package.json +6 -2
  97. package/primershear.emails.example +6 -0
  98. package/scripts/deploy-pages-secrets.sh +6 -0
  99. package/scripts/deploy-primershear-emails.sh +167 -0
  100. package/worker-configuration.d.ts +7493 -7491
  101. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  102. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  103. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  104. package/workers/data-worker/wrangler.jsonc.example +1 -1
  105. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  106. package/workers/image-worker/wrangler.jsonc.example +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
  110. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  111. package/workers/pdf-worker/src/report-types.ts +3 -0
  112. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  113. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  114. package/workers/user-worker/src/user-worker.example.ts +6 -1
  115. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  116. package/workers/user-worker/wrangler.jsonc.example +1 -1
  117. package/wrangler.toml.example +1 -1
  118. package/public/.well-known/keybase.txt +0 -56
@@ -1,22 +1,46 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { useState, useEffect } from 'react';
3
3
  import { SidebarContainer } from '~/components/sidebar/sidebar-container';
4
+ import { Navbar } from '~/components/navbar/navbar';
5
+ import { RenameCaseModal } from '~/components/navbar/case-modals/rename-case-modal';
6
+ import { ArchiveCaseModal } from '~/components/navbar/case-modals/archive-case-modal';
7
+ import { OpenCaseModal } from '~/components/navbar/case-modals/open-case-modal';
4
8
  import { Toolbar } from '~/components/toolbar/toolbar';
5
9
  import { Canvas } from '~/components/canvas/canvas';
6
10
  import { Toast } from '~/components/toast/toast';
7
- import { getImageUrl } from '~/components/actions/image-manage';
11
+ import { getImageUrl, fetchFiles, deleteFile } from '~/components/actions/image-manage';
8
12
  import { getNotes, saveNotes } from '~/components/actions/notes-manage';
9
13
  import { generatePDF } from '~/components/actions/generate-pdf';
14
+ import { CaseExport, type ExportFormat } from '~/components/sidebar/case-export/case-export';
15
+ import { CasesModal } from '~/components/sidebar/cases/cases-modal';
16
+ import { FilesModal } from '~/components/sidebar/files/files-modal';
17
+ import { NotesEditorModal } from '~/components/sidebar/notes/notes-editor-modal';
18
+ import { UserAuditViewer } from '~/components/audit/user-audit-viewer';
10
19
  import { fetchUserApi } from '~/utils/api';
11
20
  import { resolveEarliestAnnotationTimestamp } from '~/utils/ui';
12
21
  import { type AnnotationData, type FileData } from '~/types';
13
- import { checkCaseIsReadOnly } from '~/components/actions/case-manage';
22
+ import type * as CaseExportActions from '~/components/actions/case-export';
23
+ import { checkCaseIsReadOnly, validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
24
+ import { checkReadOnlyCaseExists } from '~/components/actions/case-review';
25
+ import { canCreateCase, getLimitsDescription, getUserData } from '~/utils/data';
14
26
  import styles from './striae.module.css';
15
27
 
16
28
  interface StriaePage {
17
29
  user: User;
18
30
  }
19
31
 
32
+ type CaseExportActionsModule = typeof CaseExportActions;
33
+
34
+ let caseExportActionsPromise: Promise<CaseExportActionsModule> | null = null;
35
+
36
+ const loadCaseExportActions = (): Promise<CaseExportActionsModule> => {
37
+ if (!caseExportActionsPromise) {
38
+ caseExportActionsPromise = import('~/components/actions/case-export');
39
+ }
40
+
41
+ return caseExportActionsPromise;
42
+ };
43
+
20
44
  export const Striae = ({ user }: StriaePage) => {
21
45
  // Image and error states
22
46
  const [selectedImage, setSelectedImage] = useState<string>();
@@ -28,13 +52,14 @@ export const Striae = ({ user }: StriaePage) => {
28
52
  // User states
29
53
  const [userCompany, setUserCompany] = useState<string>('');
30
54
  const [userFirstName, setUserFirstName] = useState<string>('');
55
+ const [userLastName, setUserLastName] = useState<string>('');
56
+ const [userBadgeId, setUserBadgeId] = useState<string>('');
31
57
 
32
58
  // Case management states - All managed here
33
59
  const [currentCase, setCurrentCase] = useState<string>('');
34
60
  const [files, setFiles] = useState<FileData[]>([]);
35
- const [caseNumber, setCaseNumber] = useState('');
36
- const [successAction, setSuccessAction] = useState<'loaded' | 'created' | 'deleted' | null>(null);
37
61
  const [showNotes, setShowNotes] = useState(false);
62
+ const [isUploading, setIsUploading] = useState(false);
38
63
  const [isReadOnlyCase, setIsReadOnlyCase] = useState(false);
39
64
 
40
65
  // Annotation states
@@ -51,7 +76,27 @@ export const Striae = ({ user }: StriaePage) => {
51
76
  const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
52
77
  const [showToast, setShowToast] = useState(false);
53
78
  const [toastMessage, setToastMessage] = useState('');
54
- const [toastType, setToastType] = useState<'success' | 'error'>('success');
79
+ const [toastType, setToastType] = useState<'success' | 'error' | 'warning'>('success');
80
+ const [isCaseExportModalOpen, setIsCaseExportModalOpen] = useState(false);
81
+ const [isAuditTrailOpen, setIsAuditTrailOpen] = useState(false);
82
+ const [isRenameCaseModalOpen, setIsRenameCaseModalOpen] = useState(false);
83
+ const [isOpenCaseModalOpen, setIsOpenCaseModalOpen] = useState(false);
84
+ const [isListCasesModalOpen, setIsListCasesModalOpen] = useState(false);
85
+ const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
86
+ const [isRenamingCase, setIsRenamingCase] = useState(false);
87
+ const [isDeletingCase, setIsDeletingCase] = useState(false);
88
+ const [isArchivingCase, setIsArchivingCase] = useState(false);
89
+ const [isDeletingFile, setIsDeletingFile] = useState(false);
90
+ const [isOpeningCase, setIsOpeningCase] = useState(false);
91
+ const [openCaseHelperText, setOpenCaseHelperText] = useState('');
92
+ const [isArchiveCaseModalOpen, setIsArchiveCaseModalOpen] = useState(false);
93
+ const [archiveDetails, setArchiveDetails] = useState<{
94
+ archived: boolean;
95
+ archivedAt?: string;
96
+ archivedBy?: string;
97
+ archivedByDisplay?: string;
98
+ archiveReason?: string;
99
+ }>({ archived: false });
55
100
 
56
101
 
57
102
  useEffect(() => {
@@ -68,6 +113,7 @@ export const Striae = ({ user }: StriaePage) => {
68
113
  setActiveAnnotations(new Set());
69
114
  setIsBoxAnnotationMode(false);
70
115
  setIsReadOnlyCase(false);
116
+ setArchiveDetails({ archived: false });
71
117
  }
72
118
  }, [currentCase]);
73
119
 
@@ -80,9 +126,11 @@ export const Striae = ({ user }: StriaePage) => {
80
126
  });
81
127
 
82
128
  if (response.ok) {
83
- const userData = await response.json() as { company?: string; firstName?: string };
129
+ const userData = await response.json() as { company?: string; firstName?: string; lastName?: string; badgeId?: string };
84
130
  setUserCompany(userData.company || '');
85
131
  setUserFirstName(userData.firstName || '');
132
+ setUserLastName(userData.lastName || '');
133
+ setUserBadgeId(userData.badgeId || '');
86
134
  }
87
135
  } catch (err) {
88
136
  console.error('Failed to load user company:', err);
@@ -96,7 +144,6 @@ export const Striae = ({ user }: StriaePage) => {
96
144
 
97
145
  const handleCaseChange = (caseNumber: string) => {
98
146
  setCurrentCase(caseNumber);
99
- setCaseNumber(caseNumber);
100
147
  setAnnotationData(null);
101
148
  setSelectedFilename(undefined);
102
149
  setImageId(undefined);
@@ -114,9 +161,12 @@ export const Striae = ({ user }: StriaePage) => {
114
161
  // Check if the case data itself has isReadOnly: true
115
162
  const isReadOnly = await checkCaseIsReadOnly(user, currentCase);
116
163
  setIsReadOnlyCase(isReadOnly);
164
+ const details = await getCaseArchiveDetails(user, currentCase);
165
+ setArchiveDetails(details);
117
166
  } catch (error) {
118
167
  console.error('Error checking read-only status:', error);
119
168
  setIsReadOnlyCase(false);
169
+ setArchiveDetails({ archived: false });
120
170
  }
121
171
  };
122
172
 
@@ -167,6 +217,8 @@ export const Striae = ({ user }: StriaePage) => {
167
217
  selectedFilename,
168
218
  userCompany,
169
219
  userFirstName,
220
+ userLastName,
221
+ userBadgeId,
170
222
  currentCase,
171
223
  annotationData,
172
224
  activeAnnotations,
@@ -177,16 +229,309 @@ export const Striae = ({ user }: StriaePage) => {
177
229
  });
178
230
  };
179
231
 
232
+ const showNotification = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
233
+ setToastType(type);
234
+ setToastMessage(message);
235
+ setShowToast(true);
236
+ };
237
+
180
238
  // Close toast notification
181
239
  const closeToast = () => {
182
240
  setShowToast(false);
183
241
  };
184
242
 
243
+ const handleExport = async (
244
+ exportCaseNumber: string,
245
+ format: ExportFormat,
246
+ includeImages?: boolean,
247
+ onProgress?: (progress: number, label: string) => void
248
+ ) => {
249
+ const caseExportActions = await loadCaseExportActions();
250
+
251
+ if (includeImages) {
252
+ await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format, (progress) => {
253
+ const label = progress < 30 ? 'Loading case data'
254
+ : progress < 50 ? 'Preparing archive'
255
+ : progress < 80 ? 'Adding images'
256
+ : progress < 96 ? 'Finalizing'
257
+ : 'Downloading';
258
+ onProgress?.(Math.round(progress), label);
259
+ });
260
+ showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
261
+ return;
262
+ }
263
+
264
+ onProgress?.(5, 'Loading case data');
265
+ const exportData = await caseExportActions.exportCaseData(
266
+ user,
267
+ exportCaseNumber,
268
+ { includeMetadata: true },
269
+ (current, total, label) => {
270
+ const progress = total > 0 ? Math.round(10 + (current / total) * 60) : 10;
271
+ onProgress?.(progress, label);
272
+ }
273
+ );
274
+
275
+ onProgress?.(75, 'Preparing download');
276
+ if (format === 'json') {
277
+ await caseExportActions.downloadCaseAsJSON(user, exportData);
278
+ } else {
279
+ await caseExportActions.downloadCaseAsCSV(user, exportData);
280
+ }
281
+ onProgress?.(100, 'Complete');
282
+ showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
283
+ };
284
+
285
+ const handleExportAll = async (
286
+ onProgress: (current: number, total: number, caseName: string) => void,
287
+ format: ExportFormat
288
+ ) => {
289
+ const caseExportActions = await loadCaseExportActions();
290
+ const exportData = await caseExportActions.exportAllCases(
291
+ user,
292
+ { includeMetadata: true },
293
+ onProgress
294
+ );
295
+
296
+ if (format === 'json') {
297
+ await caseExportActions.downloadAllCasesAsJSON(user, exportData);
298
+ } else {
299
+ await caseExportActions.downloadAllCasesAsCSV(user, exportData);
300
+ }
301
+
302
+ showNotification('All cases exported successfully.', 'success');
303
+ };
304
+
305
+ const handleRenameCaseSubmit = async (newCaseName: string) => {
306
+ if (!currentCase) {
307
+ showNotification('Select a case before renaming.', 'error');
308
+ return;
309
+ }
310
+
311
+ if (!validateCaseNumber(newCaseName)) {
312
+ showNotification('Invalid case number format.', 'error');
313
+ return;
314
+ }
315
+
316
+ setIsRenamingCase(true);
317
+ try {
318
+ const existingReadOnlyCase = await checkReadOnlyCaseExists(user, newCaseName);
319
+ if (existingReadOnlyCase) {
320
+ showNotification(`Case "${newCaseName}" already exists as a read-only review case.`, 'error');
321
+ return;
322
+ }
323
+
324
+ await renameCase(user, currentCase, newCaseName);
325
+ setCurrentCase(newCaseName);
326
+ setShowNotes(false);
327
+ setIsRenameCaseModalOpen(false);
328
+ showNotification(`Case renamed to ${newCaseName}.`, 'success');
329
+ } catch (renameError) {
330
+ showNotification(renameError instanceof Error ? renameError.message : 'Failed to rename case.', 'error');
331
+ } finally {
332
+ setIsRenamingCase(false);
333
+ }
334
+ };
335
+
336
+ const handleDeleteCaseAction = async () => {
337
+ if (!currentCase) {
338
+ showNotification('Select a case before deleting.', 'error');
339
+ return;
340
+ }
341
+
342
+ const confirmed = window.confirm(
343
+ `Are you sure you want to delete case ${currentCase}? This will permanently delete all associated files and cannot be undone. If any image assets are already missing (404), they will be skipped and the case deletion will continue.`
344
+ );
345
+
346
+ if (!confirmed) {
347
+ return;
348
+ }
349
+
350
+ setIsDeletingCase(true);
351
+ try {
352
+ const deleteResult = await deleteCase(user, currentCase);
353
+ setCurrentCase('');
354
+ setFiles([]);
355
+ setShowNotes(false);
356
+ setIsAuditTrailOpen(false);
357
+ setIsRenameCaseModalOpen(false);
358
+ if (deleteResult.missingImages.length > 0) {
359
+ showNotification(
360
+ `Case deleted. ${deleteResult.missingImages.length} image(s) were not found and were skipped during deletion.`,
361
+ 'warning'
362
+ );
363
+ } else {
364
+ showNotification('Case deleted successfully.', 'success');
365
+ }
366
+ } catch (deleteError) {
367
+ showNotification(deleteError instanceof Error ? deleteError.message : 'Failed to delete case.', 'error');
368
+ } finally {
369
+ setIsDeletingCase(false);
370
+ }
371
+ };
372
+
373
+ const handleDeleteCurrentFileAction = async () => {
374
+ if (!currentCase || !imageId) {
375
+ showNotification('Load an image before deleting a file.', 'error');
376
+ return;
377
+ }
378
+
379
+ if (isReadOnlyCase) {
380
+ showNotification('Cannot delete files for read-only cases.', 'error');
381
+ return;
382
+ }
383
+
384
+ const selectedFile = files.find((file) => file.id === imageId);
385
+ const selectedFileName = selectedFile?.originalFilename || imageId;
386
+ const confirmed = window.confirm(
387
+ `Are you sure you want to delete ${selectedFileName}? This action cannot be undone.`
388
+ );
389
+
390
+ if (!confirmed) {
391
+ return;
392
+ }
393
+
394
+ setIsDeletingFile(true);
395
+ try {
396
+ const deleteResult = await deleteFile(user, currentCase, imageId, 'User-requested deletion via navbar file management');
397
+ const updatedFiles = files.filter((file) => file.id !== imageId);
398
+ setFiles(updatedFiles);
399
+ handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
400
+ setShowNotes(false);
401
+ if (deleteResult.imageMissing) {
402
+ showNotification(
403
+ `File record deleted. Image asset "${deleteResult.fileName}" was not found and was skipped.`,
404
+ 'warning'
405
+ );
406
+ } else {
407
+ showNotification('File deleted successfully.', 'success');
408
+ }
409
+ } catch (deleteError) {
410
+ showNotification(deleteError instanceof Error ? deleteError.message : 'Failed to delete file.', 'error');
411
+ } finally {
412
+ setIsDeletingFile(false);
413
+ }
414
+ };
415
+
416
+ const handleArchiveCaseSubmit = async (archiveReason: string) => {
417
+ if (!currentCase) {
418
+ showNotification('Select a case before archiving.', 'error');
419
+ return;
420
+ }
421
+
422
+ if (isReadOnlyCase) {
423
+ showNotification('This case is already read-only and cannot be archived again.', 'error');
424
+ return;
425
+ }
426
+
427
+ setIsArchivingCase(true);
428
+ try {
429
+ await archiveCase(user, currentCase, archiveReason);
430
+ setIsReadOnlyCase(true);
431
+ setArchiveDetails({
432
+ archived: true,
433
+ archivedAt: new Date().toISOString(),
434
+ archivedBy: user.uid,
435
+ archivedByDisplay: [
436
+ [userFirstName.trim(), userLastName.trim()].filter(Boolean).join(' ').trim(),
437
+ userBadgeId.trim(),
438
+ ].filter(Boolean).join(', ') || user.uid,
439
+ archiveReason: archiveReason.trim() || undefined,
440
+ });
441
+ setShowNotes(false);
442
+ setIsArchiveCaseModalOpen(false);
443
+ showNotification('Case archived successfully. The archive package download has started.', 'success');
444
+ } catch (archiveError) {
445
+ showNotification(archiveError instanceof Error ? archiveError.message : 'Failed to archive case.', 'error');
446
+ } finally {
447
+ setIsArchivingCase(false);
448
+ }
449
+ };
450
+
451
+ const loadCaseIntoWorkspace = async (caseToLoad: string) => {
452
+ setCurrentCase(caseToLoad);
453
+ setShowNotes(false);
454
+ const loadedFiles = await fetchFiles(user, caseToLoad, { skipValidation: true });
455
+ setFiles(loadedFiles);
456
+ showNotification(`Case ${caseToLoad} loaded successfully.`, 'success');
457
+ };
458
+
459
+ const handleOpenCaseSubmit = async (nextCaseNumber: string) => {
460
+ if (!validateCaseNumber(nextCaseNumber)) {
461
+ showNotification('Invalid case number format.', 'error');
462
+ return;
463
+ }
464
+
465
+ setIsOpeningCase(true);
466
+ try {
467
+ const existingCase = await checkExistingCase(user, nextCaseNumber);
468
+ if (existingCase) {
469
+ await loadCaseIntoWorkspace(nextCaseNumber);
470
+ setIsOpenCaseModalOpen(false);
471
+ return;
472
+ }
473
+
474
+ const existingReadOnlyCase = await checkReadOnlyCaseExists(user, nextCaseNumber);
475
+ if (existingReadOnlyCase) {
476
+ showNotification(`Case "${nextCaseNumber}" already exists as a read-only review case.`, 'error');
477
+ return;
478
+ }
479
+
480
+ const permission = await canCreateCase(user);
481
+ if (!permission.canCreate) {
482
+ showNotification(permission.reason || 'You cannot create more cases.', 'error');
483
+ return;
484
+ }
485
+
486
+ const newCase = await createNewCase(user, nextCaseNumber);
487
+ setCurrentCase(newCase.caseNumber);
488
+ setFiles([]);
489
+ setShowNotes(false);
490
+ setIsOpenCaseModalOpen(false);
491
+ showNotification(`Case ${newCase.caseNumber} created successfully.`, 'success');
492
+ } catch (openCaseError) {
493
+ showNotification(openCaseError instanceof Error ? openCaseError.message : 'Failed to load/create case.', 'error');
494
+ } finally {
495
+ setIsOpeningCase(false);
496
+ }
497
+ };
498
+
499
+ const handleOpenCaseModal = async () => {
500
+ setIsOpenCaseModalOpen(true);
501
+ try {
502
+ const userData = await getUserData(user);
503
+ if (userData && !userData.permitted) {
504
+ const limitsDescription = await getLimitsDescription(user);
505
+ setOpenCaseHelperText(limitsDescription || 'Load an existing case or create a new one.');
506
+ } else {
507
+ setOpenCaseHelperText('Load an existing case or create a new one.');
508
+ }
509
+ } catch {
510
+ setOpenCaseHelperText('Load an existing case or create a new one.');
511
+ }
512
+ };
513
+
185
514
  // Function to refresh annotation data (called when notes are saved)
186
515
  const refreshAnnotationData = () => {
187
516
  setAnnotationRefreshTrigger(prev => prev + 1);
188
517
  };
189
518
 
519
+ // Handle import/clear read-only case
520
+ const handleImportComplete = (result: { success: boolean; caseNumber?: string; isReadOnly?: boolean }) => {
521
+ if (result.success) {
522
+ if (result.caseNumber && result.isReadOnly) {
523
+ // Successful read-only case import - load the case
524
+ handleCaseChange(result.caseNumber);
525
+ } else if (!result.caseNumber && !result.isReadOnly) {
526
+ // Read-only case cleared - reset all UI state
527
+ setCurrentCase('');
528
+ setFiles([]);
529
+ handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
530
+ setShowNotes(false);
531
+ }
532
+ }
533
+ };
534
+
190
535
  useEffect(() => {
191
536
  // Cleanup function to clear image when component unmounts
192
537
  return () => {
@@ -281,6 +626,15 @@ export const Striae = ({ user }: StriaePage) => {
281
626
  }
282
627
  };
283
628
 
629
+ const hasLoadedImage = !!(selectedImage && selectedImage !== '/clear.jpg' && imageLoaded);
630
+ const isCurrentImageConfirmed = hasLoadedImage && !!annotationData?.confirmationData;
631
+
632
+ useEffect(() => {
633
+ if (showNotes && (!hasLoadedImage || isCurrentImageConfirmed)) {
634
+ setShowNotes(false);
635
+ }
636
+ }, [showNotes, hasLoadedImage, isCurrentImageConfirmed]);
637
+
284
638
  // Automatic save handler for annotation updates
285
639
  const handleAnnotationUpdate = async (data: AnnotationData) => {
286
640
  if (annotationData?.confirmationData) {
@@ -346,31 +700,54 @@ export const Striae = ({ user }: StriaePage) => {
346
700
 
347
701
  return (
348
702
  <div className={styles.appContainer}>
349
- <SidebarContainer
350
- user={user}
351
- onImageSelect={handleImageSelect}
352
- imageId={imageId}
353
- onCaseChange={handleCaseChange}
354
- currentCase={currentCase}
355
- setCurrentCase={setCurrentCase}
356
- imageLoaded={imageLoaded}
357
- setImageLoaded={setImageLoaded}
358
- files={files}
359
- setFiles={setFiles}
360
- caseNumber={caseNumber}
361
- setCaseNumber={setCaseNumber}
362
- error={error ?? ''}
363
- setError={setError}
364
- successAction={successAction}
365
- setSuccessAction={setSuccessAction}
366
- showNotes={showNotes}
367
- setShowNotes={setShowNotes}
368
- onAnnotationRefresh={refreshAnnotationData}
703
+ <Navbar
704
+ isUploading={isUploading}
705
+ company={userCompany}
369
706
  isReadOnly={isReadOnlyCase}
370
- isConfirmed={!!annotationData?.confirmationData}
371
- confirmationSaveVersion={confirmationSaveVersion}
707
+ currentCase={currentCase}
708
+ currentFileName={selectedFilename}
709
+ isCurrentImageConfirmed={isCurrentImageConfirmed}
710
+ hasLoadedCase={!!currentCase}
711
+ hasLoadedImage={hasLoadedImage}
712
+ archiveDetails={archiveDetails}
713
+ onImportComplete={handleImportComplete}
714
+ onOpenCase={() => {
715
+ void handleOpenCaseModal();
716
+ }}
717
+ onOpenListAllCases={() => setIsListCasesModalOpen(true)}
718
+ onOpenCaseExport={() => setIsCaseExportModalOpen(true)}
719
+ onOpenAuditTrail={() => setIsAuditTrailOpen(true)}
720
+ onOpenRenameCase={() => setIsRenameCaseModalOpen(true)}
721
+ onDeleteCase={() => {
722
+ void handleDeleteCaseAction();
723
+ }}
724
+ onArchiveCase={() => setIsArchiveCaseModalOpen(true)}
725
+ onOpenViewAllFiles={() => setIsFilesModalOpen(true)}
726
+ onDeleteCurrentFile={() => {
727
+ void handleDeleteCurrentFileAction();
728
+ }}
729
+ onOpenImageNotes={() => setShowNotes(true)}
372
730
  />
373
- <main className={styles.mainContent}>
731
+ <div className={styles.contentRow}>
732
+ <SidebarContainer
733
+ user={user}
734
+ onImageSelect={handleImageSelect}
735
+ imageId={imageId}
736
+ currentCase={currentCase}
737
+ imageLoaded={imageLoaded}
738
+ setImageLoaded={setImageLoaded}
739
+ files={files}
740
+ setFiles={setFiles}
741
+ showNotes={showNotes}
742
+ setShowNotes={setShowNotes}
743
+ onAnnotationRefresh={refreshAnnotationData}
744
+ isReadOnly={isReadOnlyCase}
745
+ isConfirmed={!!annotationData?.confirmationData}
746
+ confirmationSaveVersion={confirmationSaveVersion}
747
+ isUploading={isUploading}
748
+ onUploadStatusChange={setIsUploading}
749
+ />
750
+ <main className={styles.mainContent}>
374
751
  <div className={styles.canvasArea}>
375
752
  <div className={styles.toolbarWrapper}>
376
753
  <Toolbar
@@ -389,6 +766,7 @@ export const Striae = ({ user }: StriaePage) => {
389
766
  imageUrl={selectedImage}
390
767
  filename={selectedFilename}
391
768
  company={userCompany}
769
+ badgeId={userBadgeId}
392
770
  firstName={userFirstName}
393
771
  error={error ?? ''}
394
772
  activeAnnotations={activeAnnotations}
@@ -397,11 +775,79 @@ export const Striae = ({ user }: StriaePage) => {
397
775
  boxAnnotationColor={boxAnnotationColor}
398
776
  onAnnotationUpdate={handleAnnotationUpdate}
399
777
  isReadOnly={isReadOnlyCase}
778
+ isArchivedCase={archiveDetails.archived}
400
779
  caseNumber={currentCase}
401
780
  currentImageId={imageId}
402
781
  />
403
782
  </div>
404
- </main>
783
+ </main>
784
+ </div>
785
+ <OpenCaseModal
786
+ isOpen={isOpenCaseModalOpen}
787
+ isSubmitting={isOpeningCase}
788
+ helperText={openCaseHelperText}
789
+ onClose={() => setIsOpenCaseModalOpen(false)}
790
+ onSubmit={handleOpenCaseSubmit}
791
+ />
792
+ <CasesModal
793
+ isOpen={isListCasesModalOpen}
794
+ onClose={() => setIsListCasesModalOpen(false)}
795
+ onSelectCase={(selectedCase) => {
796
+ void loadCaseIntoWorkspace(selectedCase);
797
+ }}
798
+ currentCase={currentCase || ''}
799
+ user={user}
800
+ />
801
+ <FilesModal
802
+ isOpen={isFilesModalOpen}
803
+ onClose={() => setIsFilesModalOpen(false)}
804
+ onFileSelect={(file) => {
805
+ void handleImageSelect(file);
806
+ }}
807
+ currentCase={currentCase || null}
808
+ files={files}
809
+ setFiles={setFiles}
810
+ isReadOnly={isReadOnlyCase}
811
+ selectedFileId={imageId}
812
+ />
813
+ <NotesEditorModal
814
+ isOpen={showNotes}
815
+ onClose={() => setShowNotes(false)}
816
+ currentCase={currentCase}
817
+ user={user}
818
+ imageId={imageId || ''}
819
+ onAnnotationRefresh={refreshAnnotationData}
820
+ originalFileName={files.find(file => file.id === imageId)?.originalFilename}
821
+ isUploading={isUploading}
822
+ />
823
+ <CaseExport
824
+ isOpen={isCaseExportModalOpen}
825
+ onClose={() => setIsCaseExportModalOpen(false)}
826
+ onExport={handleExport}
827
+ onExportAll={handleExportAll}
828
+ currentCaseNumber={currentCase}
829
+ isReadOnly={isReadOnlyCase}
830
+ />
831
+ <UserAuditViewer
832
+ caseNumber={currentCase || ''}
833
+ isOpen={isAuditTrailOpen}
834
+ onClose={() => setIsAuditTrailOpen(false)}
835
+ title={`Audit Trail - Case ${currentCase}`}
836
+ />
837
+ <RenameCaseModal
838
+ isOpen={isRenameCaseModalOpen}
839
+ currentCase={currentCase}
840
+ isSubmitting={isRenamingCase || isDeletingCase || isDeletingFile || isArchivingCase}
841
+ onClose={() => setIsRenameCaseModalOpen(false)}
842
+ onSubmit={handleRenameCaseSubmit}
843
+ />
844
+ <ArchiveCaseModal
845
+ isOpen={isArchiveCaseModalOpen}
846
+ currentCase={currentCase}
847
+ isSubmitting={isArchivingCase}
848
+ onClose={() => setIsArchiveCaseModalOpen(false)}
849
+ onSubmit={handleArchiveCaseSubmit}
850
+ />
405
851
  <Toast
406
852
  message={toastMessage}
407
853
  type={toastType}
package/app/routes.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { type RouteConfig, index, route } from "@react-router/dev/routes";
2
+
3
+ export default [
4
+ index("routes/_index.tsx"),
5
+ route("auth", "routes/auth/route.ts"),
6
+ route("auth/login", "routes/auth/route.ts", { id: "routes/auth/login-alias" }),
7
+ ] satisfies RouteConfig;
@@ -30,6 +30,7 @@ export const AUDIT_CSV_ENTRY_HEADERS = [
30
30
  'Profile Field',
31
31
  'Old Value',
32
32
  'New Value',
33
+ 'Badge/ID',
33
34
  'Total Confirmations In File',
34
35
  'Confirmations Successfully Imported',
35
36
  'Validation Steps Failed',
@@ -112,6 +113,7 @@ export const entryToCSVRow = (entry: ValidationAuditEntry): string => {
112
113
  formatForCSV(userProfileDetails?.profileField),
113
114
  formatForCSV(userProfileDetails?.oldValue),
114
115
  formatForCSV(userProfileDetails?.newValue),
116
+ formatForCSV(userProfileDetails?.badgeId),
115
117
  caseDetails?.totalAnnotations?.toString() || '',
116
118
  performanceMetrics?.validationStepsCompleted?.toString() || '',
117
119
  performanceMetrics?.validationStepsFailed?.toString() || '',