@striae-org/striae 5.2.1 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.env.example +2 -10
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +5 -174
  4. package/app/components/actions/case-export/download-handlers.ts +84 -751
  5. package/app/components/actions/case-export/index.ts +6 -30
  6. package/app/components/actions/case-export/metadata-helpers.ts +0 -78
  7. package/app/components/actions/case-export/types-constants.ts +0 -43
  8. package/app/components/actions/case-import/confirmation-import.ts +75 -36
  9. package/app/components/actions/case-import/confirmation-package.ts +68 -1
  10. package/app/components/actions/case-import/index.ts +1 -1
  11. package/app/components/actions/case-import/orchestrator.ts +78 -53
  12. package/app/components/actions/case-import/zip-processing.ts +160 -330
  13. package/app/components/actions/generate-pdf.ts +3 -2
  14. package/app/components/audit/user-audit-viewer.tsx +0 -19
  15. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  16. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  17. package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
  18. package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
  19. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
  20. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
  21. package/app/components/navbar/navbar.tsx +1 -1
  22. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  23. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +51 -3
  24. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  25. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
  26. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
  27. package/app/components/sidebar/case-import/index.ts +1 -4
  28. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  29. package/app/components/toast/toast.module.css +36 -0
  30. package/app/components/toast/toast.tsx +6 -2
  31. package/app/components/user/manage-profile.tsx +4 -3
  32. package/app/config-example/config.json +1 -2
  33. package/app/root.tsx +0 -7
  34. package/app/routes/_index.tsx +1 -1
  35. package/app/routes/auth/login.example.tsx +22 -103
  36. package/app/routes/auth/login.tsx +22 -103
  37. package/app/routes/auth/route.ts +1 -1
  38. package/app/routes/striae/striae.tsx +117 -59
  39. package/app/services/firebase/index.ts +0 -3
  40. package/app/types/case.ts +1 -0
  41. package/app/types/export.ts +2 -2
  42. package/app/types/import.ts +10 -0
  43. package/app/utils/auth/index.ts +0 -1
  44. package/app/utils/data/permissions.ts +3 -2
  45. package/package.json +9 -16
  46. package/public/_headers +0 -4
  47. package/public/_routes.json +0 -1
  48. package/worker-configuration.d.ts +20 -17
  49. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  50. package/workers/audit-worker/src/config.ts +7 -0
  51. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  52. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  53. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  54. package/workers/audit-worker/src/types.ts +56 -0
  55. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  56. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  57. package/workers/data-worker/src/config.ts +11 -0
  58. package/workers/data-worker/src/data-worker.example.ts +21 -942
  59. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  60. package/workers/data-worker/src/handlers/signing.ts +174 -0
  61. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  62. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  63. package/workers/data-worker/src/types.ts +46 -0
  64. package/workers/data-worker/worker-configuration.d.ts +1 -1
  65. package/workers/data-worker/wrangler.jsonc.example +1 -1
  66. package/workers/image-worker/worker-configuration.d.ts +1 -1
  67. package/workers/image-worker/wrangler.jsonc.example +1 -1
  68. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  69. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  70. package/workers/user-worker/src/auth.ts +30 -0
  71. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  72. package/workers/user-worker/src/config.ts +4 -0
  73. package/workers/user-worker/src/encryption-utils.ts +25 -0
  74. package/workers/user-worker/src/firebase/admin.ts +152 -0
  75. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  76. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  77. package/workers/user-worker/src/storage/user-records.ts +34 -0
  78. package/workers/user-worker/src/types.ts +106 -0
  79. package/workers/user-worker/src/user-worker.example.ts +18 -964
  80. package/workers/user-worker/worker-configuration.d.ts +4 -2
  81. package/workers/user-worker/wrangler.jsonc.example +12 -1
  82. package/wrangler.toml.example +1 -1
  83. package/app/components/actions/case-export/data-processing.ts +0 -223
  84. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  85. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  86. package/app/types/exceljs-bare.d.ts +0 -9
  87. package/app/utils/auth/auth.ts +0 -11
  88. package/public/.well-known/security.txt +0 -6
  89. package/public/favicon.ico +0 -0
  90. package/public/icon-256.png +0 -0
  91. package/public/icon-512.png +0 -0
  92. package/public/manifest.json +0 -39
  93. package/public/shortcut.png +0 -0
  94. package/public/social-image.png +0 -0
  95. package/public/vendor/exceljs.LICENSE +0 -22
  96. package/public/vendor/exceljs.bare.min.js +0 -45
  97. package/scripts/deploy-all.sh +0 -166
  98. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  99. package/scripts/deploy-config/modules/keys.sh +0 -404
  100. package/scripts/deploy-config/modules/prompt.sh +0 -372
  101. package/scripts/deploy-config/modules/scaffolding.sh +0 -344
  102. package/scripts/deploy-config/modules/validation.sh +0 -365
  103. package/scripts/deploy-config.sh +0 -236
  104. package/scripts/deploy-pages-secrets.sh +0 -231
  105. package/scripts/deploy-pages.sh +0 -34
  106. package/scripts/deploy-primershear-emails.sh +0 -167
  107. package/scripts/deploy-worker-secrets.sh +0 -374
  108. package/scripts/dev.cjs +0 -23
  109. package/scripts/install-workers.sh +0 -88
  110. package/scripts/run-eslint.cjs +0 -43
  111. package/scripts/update-compatibility-dates.cjs +0 -124
  112. package/scripts/update-markdown-versions.cjs +0 -43
  113. package/workers/keys-worker/package.json +0 -18
  114. package/workers/keys-worker/src/keys.example.ts +0 -67
  115. package/workers/keys-worker/src/keys.ts +0 -67
  116. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  117. package/workers/keys-worker/wrangler.jsonc.example +0 -15
@@ -2,50 +2,17 @@ import styles from '../user-audit.module.css';
2
2
 
3
3
  interface AuditViewerHeaderProps {
4
4
  title: string;
5
- hasEntries: boolean;
6
- onExportCSV: () => void;
7
- onExportJSON: () => void;
8
- onGenerateReport: () => void;
9
5
  onClose: () => void;
10
6
  }
11
7
 
12
8
  export const AuditViewerHeader = ({
13
9
  title,
14
- hasEntries,
15
- onExportCSV,
16
- onExportJSON,
17
- onGenerateReport,
18
10
  onClose,
19
11
  }: AuditViewerHeaderProps) => {
20
12
  return (
21
13
  <div className={styles.header}>
22
14
  <h2 className={styles.title}>{title}</h2>
23
15
  <div className={styles.headerActions}>
24
- {hasEntries && (
25
- <div className={styles.exportButtons}>
26
- <button
27
- onClick={onExportCSV}
28
- className={styles.exportButton}
29
- title="CSV - Individual entry log with summary data"
30
- >
31
- 📊 CSV
32
- </button>
33
- <button
34
- onClick={onExportJSON}
35
- className={styles.exportButton}
36
- title="JSON - Complete log data for version capture and auditing"
37
- >
38
- 📄 JSON
39
- </button>
40
- <button
41
- onClick={onGenerateReport}
42
- className={styles.exportButton}
43
- title="Summary report only"
44
- >
45
- 📋 Report
46
- </button>
47
- </div>
48
- )}
49
16
  <button className={styles.closeButton} onClick={onClose}>
50
17
  ×
51
18
  </button>
@@ -82,7 +82,7 @@ export const ArchiveCaseModal = ({
82
82
  Archiving a case permanently renders it read-only.
83
83
  </p>
84
84
  <p>
85
- The archive will be in JSON format and include all images.
85
+ The archive will be packaged as an encrypted case package and will always include all images.
86
86
  </p>
87
87
  <p>
88
88
  The full audit trail is packaged with Striae&apos;s current public key and forensic signatures.
@@ -0,0 +1,27 @@
1
+ .modal {
2
+ width: min(480px, calc(100vw - 2rem));
3
+ }
4
+
5
+ .description {
6
+ margin: 0 0 0.9rem;
7
+ color: #4b5563;
8
+ font-size: 0.86rem;
9
+ line-height: 1.5;
10
+ }
11
+
12
+ .confirmButton {
13
+ background: #1f6feb;
14
+ color: #ffffff;
15
+ border-color: #1560d4;
16
+ }
17
+
18
+ .confirmButton:not(:disabled):hover {
19
+ background: #1560d4;
20
+ }
21
+
22
+ .emailError {
23
+ margin: 0.45rem 0 0;
24
+ color: #b91c1c;
25
+ font-size: 0.83rem;
26
+ line-height: 1.4;
27
+ }
@@ -0,0 +1,132 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
+ import sharedStyles from './case-modal-shared.module.css';
4
+ import styles from './export-case-modal.module.css';
5
+
6
+ interface ExportCaseModalProps {
7
+ isOpen: boolean;
8
+ caseNumber: string;
9
+ currentUserEmail?: string;
10
+ isSubmitting?: boolean;
11
+ onClose: () => void;
12
+ onSubmit: (designatedReviewerEmail: string | undefined) => Promise<void>;
13
+ }
14
+
15
+ export const ExportCaseModal = ({
16
+ isOpen,
17
+ caseNumber,
18
+ currentUserEmail,
19
+ isSubmitting = false,
20
+ onClose,
21
+ onSubmit,
22
+ }: ExportCaseModalProps) => {
23
+ const [email, setEmail] = useState<string>('');
24
+
25
+ const isSelfEmail =
26
+ email.trim().length > 0 &&
27
+ !!currentUserEmail &&
28
+ email.trim().toLowerCase() === currentUserEmail.toLowerCase();
29
+ const inputRef = useRef<HTMLInputElement>(null);
30
+
31
+ const handleClose = () => {
32
+ setEmail('');
33
+ onClose();
34
+ };
35
+
36
+ const isSubmitDisabled = isSubmitting || isSelfEmail;
37
+
38
+ const {
39
+ requestClose,
40
+ overlayProps,
41
+ getCloseButtonProps,
42
+ } = useOverlayDismiss({
43
+ isOpen,
44
+ onClose: handleClose,
45
+ canDismiss: !isSubmitting,
46
+ });
47
+
48
+ useEffect(() => {
49
+ if (!isOpen) {
50
+ return;
51
+ }
52
+
53
+ const focusId = window.requestAnimationFrame(() => {
54
+ inputRef.current?.focus();
55
+ });
56
+
57
+ return () => {
58
+ window.cancelAnimationFrame(focusId);
59
+ };
60
+ }, [isOpen]);
61
+
62
+ if (!isOpen) return null;
63
+
64
+ const handleSubmit = async () => {
65
+ const trimmed = email.trim() || undefined;
66
+ await onSubmit(trimmed);
67
+ setEmail('');
68
+ };
69
+
70
+ return (
71
+ <div
72
+ className={sharedStyles.overlay}
73
+ aria-label="Close export case dialog"
74
+ {...overlayProps}
75
+ >
76
+ <div
77
+ className={`${sharedStyles.modal} ${styles.modal}`}
78
+ role="dialog"
79
+ aria-modal="true"
80
+ aria-label="Export Case"
81
+ >
82
+ <button {...getCloseButtonProps({ ariaLabel: 'Close export case dialog' })}>
83
+ ×
84
+ </button>
85
+ <h3 className={sharedStyles.title}>Export Case</h3>
86
+ <p className={sharedStyles.subtitle}>Case: {caseNumber}</p>
87
+ <p className={styles.description}>
88
+ You may designate a specific email address for review approval. Only the user
89
+ with the supplied email address will be able to open your case for review in
90
+ Striae. (Optional)
91
+ </p>
92
+ <input
93
+ ref={inputRef}
94
+ type="email"
95
+ value={email}
96
+ onChange={(event) => setEmail(event.target.value)}
97
+ className={sharedStyles.input}
98
+ placeholder="Reviewer email address (optional)"
99
+ disabled={isSubmitting}
100
+ onKeyDown={(event) => {
101
+ if (event.key === 'Enter' && !isSubmitDisabled) {
102
+ void handleSubmit();
103
+ }
104
+ }}
105
+ />
106
+ {isSelfEmail && (
107
+ <p className={styles.emailError}>
108
+ You cannot designate yourself as the reviewer. The recipient must be a different Striae user.
109
+ </p>
110
+ )}
111
+ <div className={sharedStyles.actions}>
112
+ <button
113
+ type="button"
114
+ className={sharedStyles.cancelButton}
115
+ onClick={requestClose}
116
+ disabled={isSubmitting}
117
+ >
118
+ Cancel
119
+ </button>
120
+ <button
121
+ type="button"
122
+ className={`${sharedStyles.confirmButton} ${styles.confirmButton}`}
123
+ onClick={() => void handleSubmit()}
124
+ disabled={isSubmitDisabled}
125
+ >
126
+ {isSubmitting ? 'Exporting...' : 'Export Case'}
127
+ </button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ );
132
+ };
@@ -0,0 +1,24 @@
1
+ .modal {
2
+ width: min(400px, calc(100vw - 2rem));
3
+ }
4
+
5
+ .warningPanel {
6
+ margin-bottom: 0.8rem;
7
+ }
8
+
9
+ .description {
10
+ margin: 0 0 0.3rem;
11
+ color: #4b5563;
12
+ font-size: 0.86rem;
13
+ line-height: 1.5;
14
+ }
15
+
16
+ .confirmButton {
17
+ background: #1f6feb;
18
+ color: #ffffff;
19
+ border-color: #1560d4;
20
+ }
21
+
22
+ .confirmButton:not(:disabled):hover {
23
+ background: #1560d4;
24
+ }
@@ -0,0 +1,108 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
+ import sharedStyles from './case-modal-shared.module.css';
4
+ import styles from './export-confirmations-modal.module.css';
5
+
6
+ interface ExportConfirmationsModalProps {
7
+ isOpen: boolean;
8
+ caseNumber: string;
9
+ confirmedCount: number;
10
+ unconfirmedCount: number;
11
+ isSubmitting?: boolean;
12
+ onClose: () => void;
13
+ onConfirm: () => void;
14
+ }
15
+
16
+ export const ExportConfirmationsModal = ({
17
+ isOpen,
18
+ caseNumber,
19
+ confirmedCount,
20
+ unconfirmedCount,
21
+ isSubmitting = false,
22
+ onClose,
23
+ onConfirm,
24
+ }: ExportConfirmationsModalProps) => {
25
+ const confirmButtonRef = useRef<HTMLButtonElement>(null);
26
+
27
+ const {
28
+ requestClose,
29
+ overlayProps,
30
+ getCloseButtonProps,
31
+ } = useOverlayDismiss({
32
+ isOpen,
33
+ onClose,
34
+ canDismiss: !isSubmitting,
35
+ });
36
+
37
+ useEffect(() => {
38
+ if (!isOpen) return;
39
+
40
+ const focusId = window.requestAnimationFrame(() => {
41
+ confirmButtonRef.current?.focus();
42
+ });
43
+
44
+ return () => {
45
+ window.cancelAnimationFrame(focusId);
46
+ };
47
+ }, [isOpen]);
48
+
49
+ if (!isOpen) return null;
50
+
51
+ const confirmationLabel = confirmedCount === 1 ? '1 confirmation' : `${confirmedCount} confirmations`;
52
+
53
+ return (
54
+ <div
55
+ className={sharedStyles.overlay}
56
+ aria-label="Close export confirmations dialog"
57
+ {...overlayProps}
58
+ >
59
+ <div
60
+ className={`${sharedStyles.modal} ${styles.modal}`}
61
+ role="dialog"
62
+ aria-modal="true"
63
+ aria-label="Export Confirmations"
64
+ >
65
+ <button {...getCloseButtonProps({ ariaLabel: 'Close export confirmations dialog' })}>
66
+ ×
67
+ </button>
68
+ <h3 className={sharedStyles.title}>Export Confirmations</h3>
69
+ <p className={sharedStyles.subtitle}>Case: {caseNumber}</p>
70
+ {unconfirmedCount > 0 && (
71
+ <div className={`${sharedStyles.warningPanel} ${styles.warningPanel}`}>
72
+ <p>
73
+ <strong>
74
+ {unconfirmedCount} image{unconfirmedCount !== 1 ? 's' : ''}{' '}
75
+ {unconfirmedCount !== 1 ? 'are' : 'is'} unconfirmed.
76
+ </strong>
77
+ </p>
78
+ <p>Only confirmed images will be included in this export.</p>
79
+ </div>
80
+ )}
81
+ <p className={styles.description}>
82
+ {confirmedCount === 0
83
+ ? 'No confirmed images found for this case.'
84
+ : `${confirmationLabel} will be exported.`}
85
+ </p>
86
+ <div className={sharedStyles.actions}>
87
+ <button
88
+ type="button"
89
+ className={sharedStyles.cancelButton}
90
+ onClick={requestClose}
91
+ disabled={isSubmitting}
92
+ >
93
+ Cancel
94
+ </button>
95
+ <button
96
+ ref={confirmButtonRef}
97
+ type="button"
98
+ className={`${sharedStyles.confirmButton} ${styles.confirmButton}`}
99
+ onClick={onConfirm}
100
+ disabled={isSubmitting || confirmedCount === 0}
101
+ >
102
+ {isSubmitting ? 'Exporting...' : 'Export Confirmations'}
103
+ </button>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ );
108
+ };
@@ -194,7 +194,7 @@ export const Navbar = ({
194
194
  setIsCaseMenuOpen(false);
195
195
  }}
196
196
  >
197
- Export Case Data
197
+ {isReadOnly ? 'Export Confirmations' : 'Export Case Package'}
198
198
  </button>
199
199
  <button
200
200
  type="button"
@@ -461,6 +461,41 @@
461
461
  padding: var(--spaceM);
462
462
  }
463
463
 
464
+ .previewMeta {
465
+ display: flex;
466
+ flex-direction: column;
467
+ gap: var(--spaceS);
468
+ }
469
+
470
+ .previewMetaRow {
471
+ display: grid;
472
+ grid-template-columns: 100px 1fr;
473
+ gap: var(--spaceS);
474
+ font-size: var(--fontSizeBodyS);
475
+ }
476
+
477
+ .previewMetaLabel {
478
+ color: var(--textLight);
479
+ }
480
+
481
+ .previewMetaValue {
482
+ color: var(--textBody);
483
+ font-weight: var(--fontWeightMedium);
484
+ word-break: break-word;
485
+ }
486
+
487
+ .previewValidBadge {
488
+ color: var(--success);
489
+ font-size: var(--fontSizeBodyS);
490
+ font-weight: var(--fontWeightMedium);
491
+ }
492
+
493
+ .previewInvalidBadge {
494
+ color: var(--error);
495
+ font-size: var(--fontSizeBodyS);
496
+ font-weight: var(--fontWeightMedium);
497
+ }
498
+
464
499
  /* Confirmation Dialog */
465
500
  .confirmationOverlay {
466
501
  position: fixed;
@@ -11,6 +11,21 @@ interface CasePreviewSectionProps {
11
11
  isArchivedRegularCaseImportBlocked?: boolean;
12
12
  }
13
13
 
14
+ function formatDate(isoDate: string | undefined): string {
15
+ if (!isoDate) return 'Unknown';
16
+
17
+ const date = new Date(isoDate);
18
+ if (Number.isNaN(date.getTime())) {
19
+ return isoDate;
20
+ }
21
+
22
+ return date.toLocaleDateString(undefined, {
23
+ year: 'numeric',
24
+ month: 'short',
25
+ day: 'numeric'
26
+ });
27
+ }
28
+
14
29
  export const CasePreviewSection = ({
15
30
  casePreview,
16
31
  isLoadingPreview,
@@ -31,9 +46,42 @@ export const CasePreviewSection = ({
31
46
  return (
32
47
  <div className={styles.previewSection}>
33
48
  <h3 className={styles.previewTitle}>Case Import Preview</h3>
34
- <p className={styles.previewMessage}>
35
- Case package detected. Details are hidden until import verification completes.
36
- </p>
49
+ <div className={styles.previewMeta}>
50
+ <div className={styles.previewMetaRow}>
51
+ <span className={styles.previewMetaLabel}>Case</span>
52
+ <span className={styles.previewMetaValue}>{casePreview.caseNumber}</span>
53
+ </div>
54
+ {(casePreview.exportedByName ?? casePreview.exportedBy) && (
55
+ <div className={styles.previewMetaRow}>
56
+ <span className={styles.previewMetaLabel}>Exported by</span>
57
+ <span className={styles.previewMetaValue}>
58
+ {casePreview.exportedByName ?? casePreview.exportedBy}
59
+ </span>
60
+ </div>
61
+ )}
62
+ {casePreview.exportedByCompany && (
63
+ <div className={styles.previewMetaRow}>
64
+ <span className={styles.previewMetaLabel}>Organization</span>
65
+ <span className={styles.previewMetaValue}>{casePreview.exportedByCompany}</span>
66
+ </div>
67
+ )}
68
+ <div className={styles.previewMetaRow}>
69
+ <span className={styles.previewMetaLabel}>Exported</span>
70
+ <span className={styles.previewMetaValue}>{formatDate(casePreview.exportDate)}</span>
71
+ </div>
72
+ <div className={styles.previewMetaRow}>
73
+ <span className={styles.previewMetaLabel}>Files</span>
74
+ <span className={styles.previewMetaValue}>{casePreview.totalFiles}</span>
75
+ </div>
76
+ {casePreview.hashValid !== undefined && (
77
+ <div className={styles.previewMetaRow}>
78
+ <span className={styles.previewMetaLabel}>Integrity</span>
79
+ <span className={casePreview.hashValid ? styles.previewValidBadge : styles.previewInvalidBadge}>
80
+ {casePreview.hashValid ? 'Passed' : 'Failed'}
81
+ </span>
82
+ </div>
83
+ )}
84
+ </div>
37
85
  {casePreview.archived && (
38
86
  <div className={styles.archivedImportNote}>
39
87
  {ARCHIVED_SELF_IMPORT_NOTE}
@@ -24,6 +24,7 @@ export const ConfirmationDialog = ({
24
24
  }: ConfirmationDialogProps) => {
25
25
  if (!showConfirmation || !casePreview) return null;
26
26
 
27
+ const isEncrypted = casePreview.caseNumber === 'ENCRYPTED';
27
28
  const hasDetails = casePreview.archived || isArchivedRegularCaseImportBlocked;
28
29
 
29
30
  return (
@@ -32,10 +33,7 @@ export const ConfirmationDialog = ({
32
33
  <div className={styles.confirmationContent}>
33
34
  <h3 className={styles.confirmationTitle}>Confirm Case Import</h3>
34
35
  <p className={styles.confirmationText}>
35
- Are you sure you want to import this case for review?
36
- </p>
37
- <p className={styles.confirmationText}>
38
- Package details stay hidden until verification completes.
36
+ Are you sure you want to import{isEncrypted ? ' this encrypted case' : ` case ${casePreview.caseNumber}`} for review?
39
37
  </p>
40
38
 
41
39
  {hasDetails && (
@@ -1,9 +1,15 @@
1
+ import { type ConfirmationImportPreview } from '~/types';
1
2
  import styles from '../case-import.module.css';
2
3
 
3
- export type ConfirmationPreview = Record<string, never>;
4
+ function formatDate(isoDate: string | undefined): string {
5
+ if (!isoDate) return 'Unknown';
6
+ const date = new Date(isoDate);
7
+ if (Number.isNaN(date.getTime())) return isoDate;
8
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
9
+ }
4
10
 
5
11
  interface ConfirmationPreviewSectionProps {
6
- confirmationPreview: ConfirmationPreview | null;
12
+ confirmationPreview: ConfirmationImportPreview | null;
7
13
  isLoadingPreview: boolean;
8
14
  }
9
15
 
@@ -23,9 +29,34 @@ export const ConfirmationPreviewSection = ({ confirmationPreview, isLoadingPrevi
23
29
  return (
24
30
  <div className={styles.previewSection}>
25
31
  <h3 className={styles.previewTitle}>Confirmation Import Preview</h3>
26
- <p className={styles.previewMessage}>
27
- Confirmation package detected. Details are hidden until import verification completes.
28
- </p>
32
+ <div className={styles.previewMeta}>
33
+ <div className={styles.previewMetaRow}>
34
+ <span className={styles.previewMetaLabel}>Case</span>
35
+ <span className={styles.previewMetaValue}>{confirmationPreview.caseNumber}</span>
36
+ </div>
37
+ {(confirmationPreview.exportedByName || confirmationPreview.exportedBy) && (
38
+ <div className={styles.previewMetaRow}>
39
+ <span className={styles.previewMetaLabel}>Exported by</span>
40
+ <span className={styles.previewMetaValue}>
41
+ {confirmationPreview.exportedByName || confirmationPreview.exportedBy}
42
+ </span>
43
+ </div>
44
+ )}
45
+ {confirmationPreview.exportedByCompany && (
46
+ <div className={styles.previewMetaRow}>
47
+ <span className={styles.previewMetaLabel}>Organization</span>
48
+ <span className={styles.previewMetaValue}>{confirmationPreview.exportedByCompany}</span>
49
+ </div>
50
+ )}
51
+ <div className={styles.previewMetaRow}>
52
+ <span className={styles.previewMetaLabel}>Exported</span>
53
+ <span className={styles.previewMetaValue}>{formatDate(confirmationPreview.exportDate)}</span>
54
+ </div>
55
+ <div className={styles.previewMetaRow}>
56
+ <span className={styles.previewMetaLabel}>Confirmations</span>
57
+ <span className={styles.previewMetaValue}>{confirmationPreview.totalConfirmations}</span>
58
+ </div>
59
+ </div>
29
60
  </div>
30
61
  );
31
62
  };
@@ -1,12 +1,11 @@
1
1
  import { useState, useCallback } from 'react';
2
2
  import type { User } from 'firebase/auth';
3
- import { previewCaseImport, extractConfirmationImportPackage } from '~/components/actions/case-review';
4
- import { type CaseImportPreview } from '~/types';
5
- import { type ConfirmationPreview } from '../components/ConfirmationPreviewSection';
3
+ import { previewCaseImport, previewConfirmationImport } from '~/components/actions/case-review';
4
+ import { type CaseImportPreview, type ConfirmationImportPreview } from '~/types';
6
5
 
7
6
  interface UseFilePreviewReturn {
8
7
  casePreview: CaseImportPreview | null;
9
- confirmationPreview: ConfirmationPreview | null;
8
+ confirmationPreview: ConfirmationImportPreview | null;
10
9
  loadCasePreview: (file: File) => Promise<void>;
11
10
  loadConfirmationPreview: (file: File) => Promise<void>;
12
11
  clearPreviews: () => void;
@@ -22,7 +21,7 @@ export const useFilePreview = (
22
21
  clearImportData: () => void
23
22
  ): UseFilePreviewReturn => {
24
23
  const [casePreview, setCasePreview] = useState<CaseImportPreview | null>(null);
25
- const [confirmationPreview, setConfirmationPreview] = useState<ConfirmationPreview | null>(null);
24
+ const [confirmationPreview, setConfirmationPreview] = useState<ConfirmationImportPreview | null>(null);
26
25
 
27
26
  const loadCasePreview = useCallback(async (file: File) => {
28
27
  if (!user) {
@@ -51,10 +50,7 @@ export const useFilePreview = (
51
50
 
52
51
  setIsLoadingPreview(true);
53
52
  try {
54
- await extractConfirmationImportPackage(file);
55
-
56
- const preview: ConfirmationPreview = {};
57
-
53
+ const preview = await previewConfirmationImport(file, user);
58
54
  setConfirmationPreview(preview);
59
55
  } catch (error) {
60
56
  console.error('Error loading confirmation preview:', error);
@@ -12,7 +12,4 @@ export { useFilePreview } from './hooks/useFilePreview';
12
12
  export { useImportExecution } from './hooks/useImportExecution';
13
13
 
14
14
  // Utils
15
- export * from './utils/file-validation';
16
-
17
- // Types
18
- export type { ConfirmationPreview } from './components/ConfirmationPreviewSection';
15
+ export * from './utils/file-validation';
@@ -89,10 +89,10 @@ export const SHOTSHELL_BUCKSHOT_OPTIONS = [
89
89
 
90
90
  export const ALL_CALIBERS: string[] = [...PISTOL_CALIBERS, ...RIFLE_CALIBERS];
91
91
  export const BULLET_JACKET_METAL_OPTIONS = ['Cu', 'Brass', 'Ni-plated', 'Al', 'Steel', 'None'] as const;
92
- export const BULLET_CORE_METAL_OPTIONS = ['Pb', 'Steel'] as const;
92
+ export const BULLET_CORE_METAL_OPTIONS = ['Pb', 'Steel', 'Solid Cu', 'Frangible'] as const;
93
93
  export const BULLET_TYPE_OPTIONS = ['FMJ', 'TMJ', 'HP', 'WC'] as const;
94
94
  export const BULLET_BARREL_TYPE_OPTIONS = ['Conventional', 'Polygonal'] as const;
95
- export const CARTRIDGE_METAL_OPTIONS = ['Brass', 'Ni-plated', 'Al', 'Steel'] as const;
95
+ export const CARTRIDGE_METAL_OPTIONS = ['Brass', 'Ni-plated', 'Al', 'Steel', 'Bi-metal'] as const;
96
96
  export const CARTRIDGE_PRIMER_TYPE_OPTIONS = ['CF', 'RF'] as const;
97
97
  export const CARTRIDGE_FPI_SHAPE_OPTIONS = ['Circular', 'Elliptical', 'Rectangular/Square', 'Tear-drop'] as const;
98
98
  export const CARTRIDGE_APERTURE_SHAPE_OPTIONS = ['Circular', 'Rectangular'] as const;
@@ -90,11 +90,47 @@
90
90
  font-size: 14px;
91
91
  }
92
92
 
93
+ .toast.loading {
94
+ background: var(--backgroundLight);
95
+ border-color: var(--primary);
96
+ box-shadow: 0 8px 32px color-mix(in lab, var(--primary) 20%, transparent);
97
+ }
98
+
99
+ .toast.loading .icon {
100
+ color: var(--primary);
101
+ background: color-mix(in lab, var(--primary) 15%, transparent);
102
+ border-radius: 50%;
103
+ width: 28px;
104
+ height: 28px;
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ }
109
+
93
110
  .icon {
94
111
  font-weight: bold;
95
112
  flex-shrink: 0;
96
113
  }
97
114
 
115
+ .spinner {
116
+ width: 14px;
117
+ height: 14px;
118
+ border: 2px solid color-mix(in lab, var(--primary) 20%, transparent);
119
+ border-top-color: var(--primary);
120
+ border-radius: 50%;
121
+ animation: spin 0.8s linear infinite;
122
+ }
123
+
124
+ @keyframes spin {
125
+ from {
126
+ transform: rotate(0deg);
127
+ }
128
+
129
+ to {
130
+ transform: rotate(360deg);
131
+ }
132
+ }
133
+
98
134
  .message {
99
135
  flex: 1;
100
136
  font-size: 16px;