@striae-org/striae 5.2.0 → 5.3.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 (105) hide show
  1. package/.env.example +36 -33
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +2 -174
  4. package/app/components/actions/case-export/download-handlers.ts +83 -750
  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 +13 -14
  9. package/app/components/actions/case-import/zip-processing.ts +92 -12
  10. package/app/components/actions/generate-pdf.ts +3 -2
  11. package/app/components/audit/user-audit-viewer.tsx +0 -19
  12. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  13. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  14. package/app/components/navbar/navbar.tsx +1 -1
  15. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
  19. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  20. package/app/components/toast/toast.module.css +36 -0
  21. package/app/components/toast/toast.tsx +6 -2
  22. package/app/components/user/manage-profile.tsx +4 -3
  23. package/app/config-example/config.json +1 -2
  24. package/app/root.tsx +0 -7
  25. package/app/routes/_index.tsx +1 -1
  26. package/app/routes/auth/login.example.tsx +22 -103
  27. package/app/routes/auth/route.ts +1 -1
  28. package/app/routes/striae/striae.tsx +53 -59
  29. package/app/services/firebase/index.ts +0 -3
  30. package/app/types/export.ts +1 -2
  31. package/app/utils/auth/index.ts +0 -1
  32. package/app/utils/data/permissions.ts +3 -2
  33. package/package.json +10 -17
  34. package/public/_headers +0 -4
  35. package/public/_routes.json +0 -1
  36. package/worker-configuration.d.ts +20 -17
  37. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  38. package/workers/audit-worker/src/config.ts +7 -0
  39. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  40. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  41. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  42. package/workers/audit-worker/src/types.ts +56 -0
  43. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  45. package/workers/data-worker/src/config.ts +11 -0
  46. package/workers/data-worker/src/data-worker.example.ts +21 -942
  47. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  48. package/workers/data-worker/src/handlers/signing.ts +174 -0
  49. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  50. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  51. package/workers/data-worker/src/types.ts +46 -0
  52. package/workers/data-worker/worker-configuration.d.ts +1 -1
  53. package/workers/data-worker/wrangler.jsonc.example +1 -1
  54. package/workers/image-worker/worker-configuration.d.ts +1 -1
  55. package/workers/image-worker/wrangler.jsonc.example +1 -1
  56. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  57. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  58. package/workers/user-worker/src/auth.ts +30 -0
  59. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  60. package/workers/user-worker/src/config.ts +4 -0
  61. package/workers/user-worker/src/encryption-utils.ts +25 -0
  62. package/workers/user-worker/src/firebase/admin.ts +152 -0
  63. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  64. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  65. package/workers/user-worker/src/storage/user-records.ts +34 -0
  66. package/workers/user-worker/src/types.ts +106 -0
  67. package/workers/user-worker/src/user-worker.example.ts +18 -964
  68. package/workers/user-worker/worker-configuration.d.ts +4 -2
  69. package/workers/user-worker/wrangler.jsonc.example +12 -1
  70. package/wrangler.toml.example +1 -1
  71. package/app/components/actions/case-export/data-processing.ts +0 -223
  72. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  73. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  74. package/app/types/exceljs-bare.d.ts +0 -9
  75. package/app/utils/auth/auth.ts +0 -11
  76. package/public/.well-known/security.txt +0 -6
  77. package/public/favicon.ico +0 -0
  78. package/public/icon-256.png +0 -0
  79. package/public/icon-512.png +0 -0
  80. package/public/manifest.json +0 -39
  81. package/public/shortcut.png +0 -0
  82. package/public/social-image.png +0 -0
  83. package/public/vendor/exceljs.LICENSE +0 -22
  84. package/public/vendor/exceljs.bare.min.js +0 -45
  85. package/scripts/deploy-all.sh +0 -166
  86. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  87. package/scripts/deploy-config/modules/keys.sh +0 -404
  88. package/scripts/deploy-config/modules/prompt.sh +0 -372
  89. package/scripts/deploy-config/modules/scaffolding.sh +0 -336
  90. package/scripts/deploy-config/modules/validation.sh +0 -365
  91. package/scripts/deploy-config.sh +0 -236
  92. package/scripts/deploy-pages-secrets.sh +0 -231
  93. package/scripts/deploy-pages.sh +0 -34
  94. package/scripts/deploy-primershear-emails.sh +0 -167
  95. package/scripts/deploy-worker-secrets.sh +0 -374
  96. package/scripts/dev.cjs +0 -23
  97. package/scripts/install-workers.sh +0 -88
  98. package/scripts/run-eslint.cjs +0 -43
  99. package/scripts/update-compatibility-dates.cjs +0 -124
  100. package/scripts/update-markdown-versions.cjs +0 -43
  101. package/workers/keys-worker/package.json +0 -18
  102. package/workers/keys-worker/src/keys.example.ts +0 -67
  103. package/workers/keys-worker/src/keys.ts +0 -67
  104. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  105. package/workers/keys-worker/wrangler.jsonc.example +0 -15
@@ -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;
@@ -2,9 +2,11 @@ import { useEffect, type ReactNode } from 'react';
2
2
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
3
  import styles from './toast.module.css';
4
4
 
5
+ export type ToastType = 'success' | 'error' | 'warning' | 'loading';
6
+
5
7
  interface ToastProps {
6
8
  message: ReactNode;
7
- type: 'success' | 'error' | 'warning';
9
+ type: ToastType;
8
10
  isVisible: boolean;
9
11
  onClose: () => void;
10
12
  duration?: number;
@@ -45,7 +47,9 @@ export const Toast = ({ message, type, isVisible, onClose, duration = 4000 }: To
45
47
  ></div>
46
48
  <div className={`${styles.toast} ${styles[type]} ${isVisible ? styles.show : ''}`}>
47
49
  <div className={styles.icon}>
48
- {type === 'success' ? '✓' : type === 'warning' ? '!' : '✗'}
50
+ {type === 'loading' ? (
51
+ <span className={styles.spinner} aria-hidden="true" />
52
+ ) : type === 'success' ? '✓' : type === 'warning' ? '!' : '✗'}
49
53
  </div>
50
54
  <span className={styles.message}>{message}</span>
51
55
  <button
@@ -101,7 +101,6 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
101
101
  email: user.email,
102
102
  firstName: firstName || '',
103
103
  lastName: lastName || '',
104
- badgeId: normalizedBadgeId,
105
104
  });
106
105
 
107
106
  await auditService.logUserProfileUpdate(
@@ -253,12 +252,14 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
253
252
  id="badgeId"
254
253
  type="text"
255
254
  value={badgeId}
256
- onChange={(e) => setBadgeId(e.target.value)}
255
+ disabled
256
+ readOnly
257
257
  className={styles.input}
258
258
  autoComplete="off"
259
+ style={{ backgroundColor: '#f8f9fa', cursor: 'not-allowed' }}
259
260
  />
260
261
  <p className={styles.helpText}>
261
- Enter your Badge/ID number for confirmations and reports. This can be updated as needed.
262
+ Badge/ID number can only be changed by an administrator. Contact support if changes are needed.
262
263
  </p>
263
264
  </div>
264
265
 
@@ -1,6 +1,5 @@
1
1
  {
2
- "url": "PAGES_CUSTOM_DOMAIN",
3
- "account_hash": "ACCOUNT_HASH",
2
+ "url": "PAGES_CUSTOM_DOMAIN",
4
3
  "manifest_signing_key_id": "MANIFEST_SIGNING_KEY_ID",
5
4
  "manifest_signing_public_key": "MANIFEST_SIGNING_PUBLIC_KEY",
6
5
  "manifest_signing_public_keys": {
package/app/root.tsx CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { LinksFunction } from 'react-router';
2
2
  import {
3
3
  Links,
4
- Meta,
5
4
  Outlet,
6
5
  Scripts,
7
6
  ScrollRestoration,
@@ -29,8 +28,6 @@ export const links: LinksFunction = () => [
29
28
  rel: "stylesheet",
30
29
  href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
31
30
  },
32
- { rel: 'manifest', href: '/manifest.json' },
33
- { rel: 'icon', href: '/favicon.ico' },
34
31
  ];
35
32
 
36
33
  type AppTheme = 'dark' | 'light';
@@ -60,17 +57,13 @@ const resolveRouteTheme = (matches: ReturnType<typeof useMatches>): AppTheme =>
60
57
  export function Layout({ children }: { children: React.ReactNode }) {
61
58
  const matches = useMatches();
62
59
  const theme = resolveRouteTheme(matches);
63
- const themeColor = theme === 'dark' ? '#000000' : '#377087';
64
60
 
65
61
  return (
66
62
  <html lang="en" data-theme={theme}>
67
63
  <head>
68
64
  <meta charSet="utf-8" />
69
65
  <meta name="viewport" content="width=device-width, initial-scale=1" />
70
- <meta name="theme-color" content={themeColor} />
71
- <meta name="color-scheme" content={theme} />
72
66
  <style dangerouslySetInnerHTML={{ __html: themeStyles }} />
73
- <Meta />
74
67
  <Links />
75
68
  </head>
76
69
  <body className="flex flex-col h-screen w-full overflow-x-hidden">
@@ -1 +1 @@
1
- export { Login as default, meta } from './auth/login';
1
+ export { Login as default } from './auth/login';
@@ -1,8 +1,8 @@
1
1
  import { useState, useEffect, useRef } from 'react';
2
- import { Link, useSearchParams, type MetaFunction } from 'react-router';
2
+ import { Link, useSearchParams } from 'react-router';
3
3
  import { auth } from '~/services/firebase';
4
4
  import {
5
- signInWithEmailAndPassword,
5
+ signInWithEmailAndPassword,
6
6
  createUserWithEmailAndPassword,
7
7
  onAuthStateChanged,
8
8
  sendEmailVerification,
@@ -28,95 +28,7 @@ import { generateUniqueId } from '~/utils/common';
28
28
  import { evaluatePasswordPolicy, buildActionCodeSettings, userHasMFA } from '~/utils/auth';
29
29
  import type { UserData } from '~/types';
30
30
 
31
- const APP_CANONICAL_ORIGIN = 'PAGES_CUSTOM_DOMAIN';
32
- const SOCIAL_IMAGE_PATH = '/social-image.png';
33
- const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
34
- const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
35
-
36
- type AuthMetaContent = {
37
- title: string;
38
- description: string;
39
- robots: string;
40
- };
41
-
42
- const getCanonicalPath = (pathname: string): string => {
43
- if (!pathname || LOGIN_PATH_ALIASES.has(pathname)) {
44
- return '/';
45
- }
46
-
47
- return pathname.startsWith('/') ? pathname : `/${pathname}`;
48
- };
49
-
50
- const getAuthMetaContent = (mode: string | null, hasActionCode: boolean): AuthMetaContent => {
51
- if (!mode && !hasActionCode) {
52
- return {
53
- title: 'Striae: A Firearms Examiner\'s Comparison Companion',
54
- description: 'Sign in to Striae to access your comparison annotation workspace, case files, and review tools.',
55
- robots: 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1',
56
- };
57
- }
58
-
59
- if (mode === 'resetPassword') {
60
- return {
61
- title: 'Striae | Reset Your Password',
62
- description: 'Use this secure page to reset your Striae account password and restore access to your workspace.',
63
- robots: 'noindex,nofollow,noarchive',
64
- };
65
- }
66
-
67
- if (mode === 'verifyEmail') {
68
- return {
69
- title: 'Striae | Verify Your Email Address',
70
- description: 'Confirm your email address to complete Striae account activation and continue securely.',
71
- robots: 'noindex,nofollow,noarchive',
72
- };
73
- }
74
-
75
- if (mode === 'recoverEmail') {
76
- return {
77
- title: 'Striae | Recover Email Access',
78
- description: 'Complete your Striae account email recovery steps securely.',
79
- robots: 'noindex,nofollow,noarchive',
80
- };
81
- }
82
-
83
- return {
84
- title: 'Striae | Account Action',
85
- description: 'Complete your Striae account action securely.',
86
- robots: 'noindex,nofollow,noarchive',
87
- };
88
- };
89
-
90
- export const meta: MetaFunction = ({ location }) => {
91
- const searchParams = new URLSearchParams(location.search);
92
- const mode = searchParams.get('mode');
93
- const hasActionCode = Boolean(searchParams.get('oobCode'));
94
-
95
- const canonicalPath = getCanonicalPath(location.pathname);
96
- const canonicalHref = `${APP_CANONICAL_ORIGIN}${canonicalPath}`;
97
- const socialImageHref = `${APP_CANONICAL_ORIGIN}${SOCIAL_IMAGE_PATH}`;
98
- const { title, description, robots } = getAuthMetaContent(mode, hasActionCode);
99
-
100
- return [
101
- { title },
102
- { name: 'description', content: description },
103
- { name: 'robots', content: robots },
104
- { property: 'og:site_name', content: 'Striae' },
105
- { property: 'og:type', content: 'website' },
106
- { property: 'og:url', content: canonicalHref },
107
- { property: 'og:title', content: title },
108
- { property: 'og:description', content: description },
109
- { property: 'og:image', content: socialImageHref },
110
- { property: 'og:image:secure_url', content: socialImageHref },
111
- { property: 'og:image:alt', content: SOCIAL_IMAGE_ALT },
112
- { name: 'twitter:card', content: 'summary_large_image' },
113
- { name: 'twitter:title', content: title },
114
- { name: 'twitter:description', content: description },
115
- { name: 'twitter:image', content: socialImageHref },
116
- { name: 'twitter:image:alt', content: SOCIAL_IMAGE_ALT },
117
- { tagName: 'link', rel: 'canonical', href: canonicalHref },
118
- ];
119
- };
31
+ const DEMO_COMPANY_NAME = 'STRIAE DEMO';
120
32
 
121
33
  const SUPPORTED_EMAIL_ACTION_MODES = new Set(['resetPassword', 'verifyEmail', 'recoverEmail']);
122
34
 
@@ -157,7 +69,8 @@ export const Login = () => {
157
69
  const [isClient, setIsClient] = useState(false);
158
70
  const [firstName, setFirstName] = useState('');
159
71
  const [lastName, setLastName] = useState('');
160
- const [company, setCompany] = useState('');
72
+ const [company, setCompany] = useState(DEMO_COMPANY_NAME);
73
+ const [badgeId, setBadgeId] = useState('');
161
74
  const [confirmPasswordValue, setConfirmPasswordValue] = useState('');
162
75
 
163
76
  // MFA state
@@ -251,7 +164,6 @@ export const Login = () => {
251
164
  }
252
165
 
253
166
  // Check if user exists in the USER_DB
254
- let hasBadgeId = true;
255
167
  setIsCheckingUser(true);
256
168
  try {
257
169
  const userData = await checkUserExists(currentUser);
@@ -262,8 +174,6 @@ export const Login = () => {
262
174
  setError('This account does not exist or has been deleted');
263
175
  return;
264
176
  }
265
-
266
- hasBadgeId = Boolean(userData.badgeId?.trim());
267
177
  } catch (error) {
268
178
  setIsCheckingUser(false);
269
179
  handleSignOut();
@@ -282,13 +192,8 @@ export const Login = () => {
282
192
  setShowMfaEnrollment(false);
283
193
 
284
194
  if (shouldShowWelcomeToastRef.current) {
285
- if (hasBadgeId) {
286
- setWelcomeToastType('success');
287
- setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
288
- } else {
289
- setWelcomeToastType('warning');
290
- setWelcomeToastMessage('Your badge or ID number is not set. You can set one in Manage Profile.');
291
- }
195
+ setWelcomeToastType('success');
196
+ setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
292
197
  setIsWelcomeToastVisible(true);
293
198
  shouldShowWelcomeToastRef.current = false;
294
199
  }
@@ -373,6 +278,7 @@ export const Login = () => {
373
278
  const formFirstName = firstName;
374
279
  const formLastName = lastName;
375
280
  const formCompany = company;
281
+ const formBadgeId = badgeId;
376
282
 
377
283
  try {
378
284
  if (!isLogin) {
@@ -411,7 +317,8 @@ export const Login = () => {
411
317
  formFirstName,
412
318
  formLastName,
413
319
  companyName || '',
414
- true
320
+ true,
321
+ formBadgeId.trim()
415
322
  );
416
323
 
417
324
  // Log user registration audit event
@@ -691,6 +598,17 @@ export const Login = () => {
691
598
  disabled={isLoading}
692
599
  value={company}
693
600
  onChange={(e) => setCompany(e.target.value)}
601
+ />
602
+ <input
603
+ type="text"
604
+ name="badgeId"
605
+ required
606
+ placeholder="Badge/ID # (required)"
607
+ autoComplete="off"
608
+ className={styles.input}
609
+ disabled={isLoading}
610
+ value={badgeId}
611
+ onChange={(e) => setBadgeId(e.target.value)}
694
612
  />
695
613
  {passwordStrength && (
696
614
  <div className={styles.passwordStrength}>
@@ -740,6 +658,7 @@ export const Login = () => {
740
658
  setFirstName('');
741
659
  setLastName('');
742
660
  setCompany('');
661
+ setBadgeId('');
743
662
  setConfirmPasswordValue('');
744
663
  }}
745
664
  className={styles.toggleButton}
@@ -5,4 +5,4 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
5
5
  throw redirect(`/${requestUrl.search}`);
6
6
  };
7
7
 
8
- export { Login as default, meta } from './login';
8
+ export { Login as default } from './login';
@@ -7,11 +7,11 @@ import { ArchiveCaseModal } from '~/components/navbar/case-modals/archive-case-m
7
7
  import { OpenCaseModal } from '~/components/navbar/case-modals/open-case-modal';
8
8
  import { Toolbar } from '~/components/toolbar/toolbar';
9
9
  import { Canvas } from '~/components/canvas/canvas';
10
- import { Toast } from '~/components/toast/toast';
10
+ import { Toast, type ToastType } from '~/components/toast/toast';
11
11
  import { getImageUrl, fetchFiles, deleteFile } from '~/components/actions/image-manage';
12
12
  import { getNotes, saveNotes } from '~/components/actions/notes-manage';
13
13
  import { generatePDF } from '~/components/actions/generate-pdf';
14
- import { CaseExport, type ExportFormat } from '~/components/sidebar/case-export/case-export';
14
+ import { exportConfirmationData } from '~/components/actions/confirm-export';
15
15
  import { CasesModal } from '~/components/sidebar/cases/cases-modal';
16
16
  import { FilesModal } from '~/components/sidebar/files/files-modal';
17
17
  import { NotesEditorModal } from '~/components/sidebar/notes/notes-editor-modal';
@@ -77,8 +77,8 @@ export const Striae = ({ user }: StriaePage) => {
77
77
  const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
78
78
  const [showToast, setShowToast] = useState(false);
79
79
  const [toastMessage, setToastMessage] = useState('');
80
- const [toastType, setToastType] = useState<'success' | 'error' | 'warning'>('success');
81
- const [isCaseExportModalOpen, setIsCaseExportModalOpen] = useState(false);
80
+ const [toastType, setToastType] = useState<ToastType>('success');
81
+ const [toastDuration, setToastDuration] = useState(4000);
82
82
  const [isAuditTrailOpen, setIsAuditTrailOpen] = useState(false);
83
83
  const [isRenameCaseModalOpen, setIsRenameCaseModalOpen] = useState(false);
84
84
  const [isOpenCaseModalOpen, setIsOpenCaseModalOpen] = useState(false);
@@ -253,13 +253,19 @@ export const Striae = ({ user }: StriaePage) => {
253
253
  setIsGeneratingPDF,
254
254
  setToastType,
255
255
  setToastMessage,
256
- setShowToast
256
+ setShowToast,
257
+ setToastDuration
257
258
  });
258
259
  };
259
260
 
260
- const showNotification = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
261
+ const showNotification = (
262
+ message: string,
263
+ type: ToastType = 'success',
264
+ duration = 4000
265
+ ) => {
261
266
  setToastType(type);
262
267
  setToastMessage(message);
268
+ setToastDuration(duration);
263
269
  setShowToast(true);
264
270
  };
265
271
 
@@ -270,60 +276,45 @@ export const Striae = ({ user }: StriaePage) => {
270
276
 
271
277
  const handleExport = async (
272
278
  exportCaseNumber: string,
273
- format: ExportFormat,
274
- includeImages?: boolean,
275
279
  onProgress?: (progress: number, label: string) => void
276
280
  ) => {
277
- const caseExportActions = await loadCaseExportActions();
278
-
279
- if (includeImages) {
280
- await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format, (progress) => {
281
- const label = getExportProgressLabel(progress);
282
- onProgress?.(Math.round(progress), label);
283
- });
284
- showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
281
+ if (!exportCaseNumber) {
282
+ showNotification('Select a case before exporting.', 'error');
285
283
  return;
286
284
  }
287
285
 
288
- onProgress?.(5, 'Loading case data');
289
- const exportData = await caseExportActions.exportCaseData(
290
- user,
291
- exportCaseNumber,
292
- { includeMetadata: true },
293
- (current, total, label) => {
294
- const progress = total > 0 ? Math.round(10 + (current / total) * 60) : 10;
295
- onProgress?.(progress, label);
296
- }
297
- );
286
+ showNotification(`Exporting case ${exportCaseNumber}...`, 'loading', 0);
287
+
288
+ try {
289
+ const caseExportActions = await loadCaseExportActions();
290
+
291
+ await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, (progress) => {
292
+ const roundedProgress = Math.round(progress);
293
+ const label = getExportProgressLabel(progress);
294
+ setToastType('loading');
295
+ setToastMessage(`Exporting case ${exportCaseNumber}... ${label} (${roundedProgress}%)`);
296
+ setToastDuration(0);
297
+ setShowToast(true);
298
+ onProgress?.(roundedProgress, label);
299
+ });
298
300
 
299
- onProgress?.(75, 'Preparing download');
300
- if (format === 'json') {
301
- await caseExportActions.downloadCaseAsJSON(user, exportData);
302
- } else {
303
- await caseExportActions.downloadCaseAsCSV(user, exportData);
301
+ showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
302
+ } catch (error) {
303
+ showNotification(error instanceof Error ? error.message : 'Export failed. Please try again.', 'error');
304
304
  }
305
- onProgress?.(100, 'Complete');
306
- showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
307
305
  };
308
306
 
309
- const handleExportAll = async (
310
- onProgress: (current: number, total: number, caseName: string) => void,
311
- format: ExportFormat
312
- ) => {
313
- const caseExportActions = await loadCaseExportActions();
314
- const exportData = await caseExportActions.exportAllCases(
315
- user,
316
- { includeMetadata: true },
317
- onProgress
318
- );
307
+ const handleExportConfirmations = async () => {
308
+ if (!currentCase || !user) return;
319
309
 
320
- if (format === 'json') {
321
- await caseExportActions.downloadAllCasesAsJSON(user, exportData);
322
- } else {
323
- await caseExportActions.downloadAllCasesAsCSV(user, exportData);
324
- }
310
+ showNotification(`Exporting confirmations for case ${currentCase}...`, 'loading', 0);
325
311
 
326
- showNotification('All cases exported successfully.', 'success');
312
+ try {
313
+ await exportConfirmationData(user, currentCase);
314
+ showNotification(`Confirmations for case ${currentCase} exported successfully.`, 'success');
315
+ } catch (e) {
316
+ showNotification(e instanceof Error ? e.message : 'Confirmation export failed. Please try again.', 'error');
317
+ }
327
318
  };
328
319
 
329
320
  const handleRenameCaseSubmit = async (newCaseName: string) => {
@@ -370,6 +361,8 @@ export const Striae = ({ user }: StriaePage) => {
370
361
  }
371
362
 
372
363
  setIsDeletingCase(true);
364
+ showNotification(`Deleting case ${currentCase}...`, 'loading', 0);
365
+
373
366
  try {
374
367
  const deleteResult = await deleteCase(user, currentCase);
375
368
  clearLoadedCaseState();
@@ -474,6 +467,8 @@ export const Striae = ({ user }: StriaePage) => {
474
467
  }
475
468
 
476
469
  setIsArchivingCase(true);
470
+ showNotification(`Archiving case ${currentCase}... Preparing archive package.`, 'loading', 0);
471
+
477
472
  try {
478
473
  await archiveCase(user, currentCase, archiveReason);
479
474
  setIsReadOnlyCase(true);
@@ -767,7 +762,13 @@ export const Striae = ({ user }: StriaePage) => {
767
762
  void handleOpenCaseModal();
768
763
  }}
769
764
  onOpenListAllCases={() => setIsListCasesModalOpen(true)}
770
- onOpenCaseExport={() => setIsCaseExportModalOpen(true)}
765
+ onOpenCaseExport={() => {
766
+ if (isReadOnlyCase) {
767
+ void handleExportConfirmations();
768
+ } else {
769
+ void handleExport(currentCase || '');
770
+ }
771
+ }}
771
772
  onOpenAuditTrail={() => setIsAuditTrailOpen(true)}
772
773
  onOpenRenameCase={() => setIsRenameCaseModalOpen(true)}
773
774
  onDeleteCase={() => {
@@ -790,7 +791,7 @@ export const Striae = ({ user }: StriaePage) => {
790
791
  onOpenCase={() => {
791
792
  void handleOpenCaseModal();
792
793
  }}
793
- onOpenCaseExport={() => setIsCaseExportModalOpen(true)}
794
+ onOpenCaseExport={() => void handleExportConfirmations()}
794
795
  imageId={imageId}
795
796
  currentCase={currentCase}
796
797
  imageLoaded={imageLoaded}
@@ -883,14 +884,6 @@ export const Striae = ({ user }: StriaePage) => {
883
884
  isUploading={isUploading}
884
885
  showNotification={showNotification}
885
886
  />
886
- <CaseExport
887
- isOpen={isCaseExportModalOpen}
888
- onClose={() => setIsCaseExportModalOpen(false)}
889
- onExport={handleExport}
890
- onExportAll={handleExportAll}
891
- currentCaseNumber={currentCase}
892
- isReadOnly={isReadOnlyCase}
893
- />
894
887
  <UserAuditViewer
895
888
  caseNumber={currentCase || ''}
896
889
  isOpen={isAuditTrailOpen}
@@ -916,6 +909,7 @@ export const Striae = ({ user }: StriaePage) => {
916
909
  type={toastType}
917
910
  isVisible={showToast}
918
911
  onClose={closeToast}
912
+ duration={toastDuration}
919
913
  />
920
914
  </div>
921
915
  );
@@ -6,14 +6,11 @@ import {
6
6
  //connectAuthEmulator,
7
7
  } from 'firebase/auth';
8
8
  import firebaseConfig from '~/config/firebase';
9
- import { getAppVersion } from '~/utils/common';
10
9
 
11
10
  export const app = initializeApp(firebaseConfig, "Striae");
12
11
  export const auth = getAuth(app);
13
12
 
14
13
  setPersistence(auth, browserSessionPersistence);
15
14
 
16
- console.log(`Welcome to ${app.name} v${getAppVersion()}`);
17
-
18
15
  //Connect to the Firebase Auth emulator if running locally
19
16
  //connectAuthEmulator(auth, 'http://127.0.0.1:9099');
@@ -1,8 +1,7 @@
1
1
  // Export-related types and interfaces
2
2
 
3
3
  export interface ExportOptions {
4
- format?: 'json' | 'csv';
5
4
  includeMetadata?: boolean;
6
5
  includeUserInfo?: boolean;
7
- protectForensicData?: boolean; // Enable read-only protection
6
+ protectForensicData?: boolean;
8
7
  }
@@ -1,4 +1,3 @@
1
- export * from './auth';
2
1
  export * from './auth-action-settings';
3
2
  export * from './mfa';
4
3
  export * from './mfa-phone';
@@ -103,7 +103,8 @@ export const createUser = async (
103
103
  firstName: string,
104
104
  lastName: string,
105
105
  company: string,
106
- permitted: boolean = false
106
+ permitted: boolean = false,
107
+ badgeId: string = ''
107
108
  ): Promise<UserData> => {
108
109
  try {
109
110
  const userData: UserData = {
@@ -112,7 +113,7 @@ export const createUser = async (
112
113
  firstName,
113
114
  lastName,
114
115
  company,
115
- badgeId: '',
116
+ badgeId,
116
117
  permitted,
117
118
  cases: [],
118
119
  readOnlyCases: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -49,14 +49,15 @@
49
49
  "load-context.ts",
50
50
  "functions/",
51
51
  "public/",
52
- "scripts/",
53
52
  "workers/*/package.json",
54
- "workers/*/src/*.example.ts",
55
- "workers/*/src/*.example.js",
56
- "workers/*/src/*.ts",
53
+ "workers/*/src/**/*.example.ts",
54
+ "workers/*/src/**/*.example.js",
55
+ "workers/*/src/**/*.ts",
57
56
  "workers/pdf-worker/scripts/*.js",
58
- "!workers/*/src/*worker.ts",
57
+ "!workers/*/src/**/*worker.ts",
58
+ "!workers/pdf-worker/src/assets/**/*",
59
59
  "workers/pdf-worker/src/assets/generated-assets.example.ts",
60
+ "!workers/pdf-worker/src/formats/**/*",
60
61
  "workers/pdf-worker/src/formats/format-striae.ts",
61
62
  "workers/pdf-worker/src/report-types.ts",
62
63
  "workers/*/wrangler.jsonc.example",
@@ -96,7 +97,7 @@
96
97
  "deploy-config": "bash ./scripts/deploy-config.sh",
97
98
  "update-env": "bash ./scripts/deploy-config.sh --update-env",
98
99
  "install-workers": "bash ./scripts/install-workers.sh",
99
- "deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:keys && npm run deploy-workers:pdf && npm run deploy-workers:user",
100
+ "deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:pdf && npm run deploy-workers:user",
100
101
  "deploy-workers:secrets": "bash ./scripts/deploy-worker-secrets.sh",
101
102
  "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh --production-only",
102
103
  "deploy-pages": "bash ./scripts/deploy-pages.sh",
@@ -104,13 +105,11 @@
104
105
  "deploy-workers:audit": "cd workers/audit-worker && npm run deploy",
105
106
  "deploy-workers:data": "cd workers/data-worker && npm run deploy",
106
107
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
107
- "deploy-workers:keys": "cd workers/keys-worker && npm run deploy",
108
108
  "deploy-workers:pdf": "cd workers/pdf-worker && npm run deploy",
109
109
  "deploy-workers:user": "cd workers/user-worker && npm run deploy"
110
110
  },
111
111
  "dependencies": {
112
112
  "@react-router/cloudflare": "^7.13.2",
113
- "exceljs": "^4.4.0",
114
113
  "firebase": "^12.10.0",
115
114
  "isbot": "^5.1.36",
116
115
  "jszip": "^3.10.1",
@@ -135,17 +134,11 @@
135
134
  "typescript": "^5.9.3",
136
135
  "vite": "^6.4.1",
137
136
  "vite-tsconfig-paths": "^6.1.1",
138
- "wrangler": "^4.74.0"
137
+ "wrangler": "^4.77.0"
139
138
  },
140
139
  "overrides": {
141
140
  "tar": "7.5.11",
142
- "undici": "7.24.1",
143
- "exceljs": {
144
- "archiver": "7.0.1",
145
- "fast-csv": "5.0.5",
146
- "unzipper": "0.12.3",
147
- "glob": "13.0.6"
148
- }
141
+ "undici": "7.24.1"
149
142
  },
150
143
  "engines": {
151
144
  "node": ">=20.0.0"