@striae-org/striae 4.0.3 → 4.1.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 (66) hide show
  1. package/app/components/actions/confirm-export.ts +4 -2
  2. package/app/components/actions/generate-pdf.ts +10 -2
  3. package/app/components/audit/user-audit-viewer.tsx +121 -940
  4. package/app/components/audit/user-audit.module.css +20 -0
  5. package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
  6. package/app/components/audit/viewer/audit-entries-list.tsx +200 -0
  7. package/app/components/audit/viewer/audit-filters-panel.tsx +306 -0
  8. package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
  9. package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
  10. package/app/components/audit/viewer/audit-viewer-utils.ts +121 -0
  11. package/app/components/audit/viewer/types.ts +1 -0
  12. package/app/components/audit/viewer/use-audit-viewer-data.ts +166 -0
  13. package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
  14. package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
  15. package/app/components/auth/mfa-enrollment.module.css +13 -5
  16. package/app/components/auth/mfa-verification.module.css +13 -5
  17. package/app/components/canvas/canvas.tsx +3 -0
  18. package/app/components/canvas/confirmation/confirmation.tsx +13 -37
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +8 -37
  21. package/app/components/sidebar/case-export/case-export.tsx +9 -34
  22. package/app/components/sidebar/case-import/case-import.module.css +2 -0
  23. package/app/components/sidebar/case-import/case-import.tsx +10 -34
  24. package/app/components/sidebar/cases/cases-modal.module.css +44 -9
  25. package/app/components/sidebar/cases/cases-modal.tsx +16 -14
  26. package/app/components/sidebar/files/files-modal.module.css +45 -10
  27. package/app/components/sidebar/files/files-modal.tsx +16 -16
  28. package/app/components/sidebar/notes/notes-modal.tsx +17 -15
  29. package/app/components/sidebar/notes/notes.module.css +2 -0
  30. package/app/components/sidebar/sidebar.module.css +2 -2
  31. package/app/components/toast/toast.module.css +2 -1
  32. package/app/components/toast/toast.tsx +16 -11
  33. package/app/components/user/delete-account.tsx +10 -31
  34. package/app/components/user/inactivity-warning.module.css +8 -6
  35. package/app/components/user/manage-profile.module.css +2 -0
  36. package/app/components/user/manage-profile.tsx +85 -30
  37. package/app/hooks/useOverlayDismiss.ts +68 -0
  38. package/app/routes/auth/login.example.tsx +19 -8
  39. package/app/routes/auth/passwordReset.module.css +23 -13
  40. package/app/routes/striae/striae.tsx +8 -1
  41. package/app/routes.ts +7 -0
  42. package/app/services/audit/audit-export-csv.ts +2 -0
  43. package/app/services/audit/audit.service.ts +29 -5
  44. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  45. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  46. package/app/services/audit/builders/audit-event-builders-workflow.ts +6 -0
  47. package/app/types/audit.ts +2 -1
  48. package/app/types/user.ts +1 -0
  49. package/app/utils/data/permissions.ts +1 -0
  50. package/functions/api/pdf/[[path]].ts +32 -1
  51. package/load-context.ts +9 -0
  52. package/package.json +5 -1
  53. package/primershear.emails.example +6 -0
  54. package/scripts/deploy-pages-secrets.sh +6 -0
  55. package/scripts/deploy-primershear-emails.sh +166 -0
  56. package/worker-configuration.d.ts +7493 -7491
  57. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  58. package/workers/data-worker/wrangler.jsonc.example +1 -1
  59. package/workers/image-worker/wrangler.jsonc.example +1 -1
  60. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  61. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  62. package/workers/pdf-worker/src/report-types.ts +3 -0
  63. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  64. package/workers/user-worker/src/user-worker.example.ts +6 -1
  65. package/workers/user-worker/wrangler.jsonc.example +1 -1
  66. package/wrangler.toml.example +1 -1
@@ -26,6 +26,7 @@ import { getUserData, createUser } from '~/utils/data';
26
26
  import { auditService } from '~/services/audit';
27
27
  import { generateUniqueId } from '~/utils/common';
28
28
  import { evaluatePasswordPolicy, buildActionCodeSettings, userHasMFA } from '~/utils/auth';
29
+ import type { UserData } from '~/types';
29
30
 
30
31
  const APP_CANONICAL_ORIGIN = 'PAGES_CUSTOM_DOMAIN';
31
32
  const SOCIAL_IMAGE_PATH = '/social-image.png';
@@ -143,6 +144,7 @@ export const Login = () => {
143
144
  const [error, setError] = useState('');
144
145
  const [success, setSuccess] = useState('');
145
146
  const [welcomeToastMessage, setWelcomeToastMessage] = useState('');
147
+ const [welcomeToastType, setWelcomeToastType] = useState<'success' | 'warning'>('success');
146
148
  const [isWelcomeToastVisible, setIsWelcomeToastVisible] = useState(false);
147
149
  const [isLogin, setIsLogin] = useState(true);
148
150
  const [isLoading, setIsLoading] = useState(false);
@@ -212,11 +214,9 @@ export const Login = () => {
212
214
  };
213
215
 
214
216
  // Check if user exists in the USER_DB using centralized function
215
- const checkUserExists = async (currentUser: User): Promise<boolean> => {
217
+ const checkUserExists = async (currentUser: User): Promise<UserData | null> => {
216
218
  try {
217
- const userData = await getUserData(currentUser);
218
-
219
- return userData !== null;
219
+ return await getUserData(currentUser);
220
220
  } catch (error) {
221
221
  console.error('Error checking user existence:', error);
222
222
  // On network/API errors, throw error to prevent login
@@ -251,16 +251,19 @@ export const Login = () => {
251
251
  }
252
252
 
253
253
  // Check if user exists in the USER_DB
254
+ let hasBadgeId = true;
254
255
  setIsCheckingUser(true);
255
256
  try {
256
- const userExists = await checkUserExists(currentUser);
257
+ const userData = await checkUserExists(currentUser);
257
258
  setIsCheckingUser(false);
258
259
 
259
- if (!userExists) {
260
+ if (!userData) {
260
261
  handleSignOut();
261
262
  setError('This account does not exist or has been deleted');
262
263
  return;
263
264
  }
265
+
266
+ hasBadgeId = Boolean(userData.badgeId?.trim());
264
267
  } catch (error) {
265
268
  setIsCheckingUser(false);
266
269
  handleSignOut();
@@ -279,7 +282,13 @@ export const Login = () => {
279
282
  setShowMfaEnrollment(false);
280
283
 
281
284
  if (shouldShowWelcomeToastRef.current) {
282
- setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
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
+ }
283
292
  setIsWelcomeToastVisible(true);
284
293
  shouldShowWelcomeToastRef.current = false;
285
294
  }
@@ -302,6 +311,7 @@ export const Login = () => {
302
311
  setShowMfaEnrollment(false);
303
312
  setIsCheckingUser(false);
304
313
  setIsWelcomeToastVisible(false);
314
+ setWelcomeToastType('success');
305
315
  shouldShowWelcomeToastRef.current = false;
306
316
  }
307
317
  });
@@ -517,6 +527,7 @@ export const Login = () => {
517
527
  setShowMfaVerification(false);
518
528
  setMfaResolver(null);
519
529
  setIsWelcomeToastVisible(false);
530
+ setWelcomeToastType('success');
520
531
  shouldShowWelcomeToastRef.current = false;
521
532
  } catch (err) {
522
533
  console.error('Sign out error:', err);
@@ -762,7 +773,7 @@ export const Login = () => {
762
773
  {!shouldHandleEmailAction && (
763
774
  <Toast
764
775
  message={welcomeToastMessage}
765
- type="success"
776
+ type={welcomeToastType}
766
777
  isVisible={isWelcomeToastVisible}
767
778
  onClose={() => setIsWelcomeToastVisible(false)}
768
779
  />
@@ -12,7 +12,8 @@
12
12
  background-color: color-mix(in lab, var(--backgroundLight) 95%, transparent);
13
13
  padding: var(--space2XL);
14
14
  border-radius: var(--spaceXS);
15
- box-shadow: 0 var(--spaceXS) var(--spaceM) color-mix(in lab, var(--black) 10%, transparent);
15
+ box-shadow: 0 var(--spaceXS) var(--spaceM)
16
+ color-mix(in lab, var(--black) 10%, transparent);
16
17
  width: 100%;
17
18
  max-width: var(--maxWidthS);
18
19
  }
@@ -29,7 +30,7 @@
29
30
  left: var(--spaceL);
30
31
  width: 150px;
31
32
  height: 150px;
32
- background-image: url('/logo-dark.png');
33
+ background-image: url("/logo-dark.png");
33
34
  background-size: contain;
34
35
  background-repeat: no-repeat;
35
36
  background-position: center;
@@ -114,7 +115,8 @@
114
115
  margin: var(--spaceXS) 0 var(--spaceS) 0;
115
116
  font-size: var(--fontSizeBodyXS);
116
117
  padding: var(--spaceS) var(--spaceM);
117
- background: linear-gradient(135deg,
118
+ background: linear-gradient(
119
+ 135deg,
118
120
  color-mix(in lab, var(--error) 12%, transparent),
119
121
  color-mix(in lab, var(--error) 8%, transparent)
120
122
  );
@@ -128,7 +130,7 @@
128
130
  }
129
131
 
130
132
  .error::before {
131
- content: '';
133
+ content: "";
132
134
  position: absolute;
133
135
  top: 0;
134
136
  left: 0;
@@ -144,7 +146,8 @@
144
146
  margin: var(--spaceXS) 0 var(--spaceS) 0;
145
147
  font-size: var(--fontSizeBodyXS);
146
148
  padding: var(--spaceS) var(--spaceM);
147
- background: linear-gradient(135deg,
149
+ background: linear-gradient(
150
+ 135deg,
148
151
  color-mix(in lab, var(--success) 12%, transparent),
149
152
  color-mix(in lab, var(--success) 8%, transparent)
150
153
  );
@@ -158,7 +161,7 @@
158
161
  }
159
162
 
160
163
  .success::before {
161
- content: '';
164
+ content: "";
162
165
  position: absolute;
163
166
  top: 0;
164
167
  left: 0;
@@ -176,6 +179,7 @@
176
179
  justify-content: center;
177
180
  align-items: center;
178
181
  z-index: var(--zIndex5);
182
+ cursor: default;
179
183
  }
180
184
 
181
185
  .modal {
@@ -183,9 +187,11 @@
183
187
  border-radius: var(--spaceXS);
184
188
  width: 90%;
185
189
  max-width: 500px;
186
- box-shadow: 0 var(--spaceXS) var(--spaceL) color-mix(in lab, var(--black) 10%, transparent);
190
+ box-shadow: 0 var(--spaceXS) var(--spaceL)
191
+ color-mix(in lab, var(--black) 10%, transparent);
187
192
  overflow: hidden;
188
193
  padding: var(--space2XL);
194
+ cursor: default;
189
195
  }
190
196
 
191
197
  .modalHeader {
@@ -220,7 +226,8 @@
220
226
  }
221
227
 
222
228
  @keyframes errorPulse {
223
- 0%, 100% {
229
+ 0%,
230
+ 100% {
224
231
  opacity: 1;
225
232
  box-shadow: 0 0 0 0 color-mix(in lab, var(--error) 40%, transparent);
226
233
  }
@@ -252,7 +259,8 @@
252
259
  }
253
260
 
254
261
  @keyframes successPulse {
255
- 0%, 100% {
262
+ 0%,
263
+ 100% {
256
264
  opacity: 1;
257
265
  box-shadow: 0 0 0 0 color-mix(in lab, var(--success) 40%, transparent);
258
266
  }
@@ -264,11 +272,13 @@
264
272
 
265
273
  /* Reduce motion for accessibility */
266
274
  @media (prefers-reduced-motion: reduce) {
267
- .error, .success {
275
+ .error,
276
+ .success {
268
277
  animation: none;
269
278
  }
270
-
271
- .error::before, .success::before {
279
+
280
+ .error::before,
281
+ .success::before {
272
282
  animation: none;
273
283
  }
274
- }
284
+ }
@@ -28,6 +28,8 @@ export const Striae = ({ user }: StriaePage) => {
28
28
  // User states
29
29
  const [userCompany, setUserCompany] = useState<string>('');
30
30
  const [userFirstName, setUserFirstName] = useState<string>('');
31
+ const [userLastName, setUserLastName] = useState<string>('');
32
+ const [userBadgeId, setUserBadgeId] = useState<string>('');
31
33
 
32
34
  // Case management states - All managed here
33
35
  const [currentCase, setCurrentCase] = useState<string>('');
@@ -80,9 +82,11 @@ export const Striae = ({ user }: StriaePage) => {
80
82
  });
81
83
 
82
84
  if (response.ok) {
83
- const userData = await response.json() as { company?: string; firstName?: string };
85
+ const userData = await response.json() as { company?: string; firstName?: string; lastName?: string; badgeId?: string };
84
86
  setUserCompany(userData.company || '');
85
87
  setUserFirstName(userData.firstName || '');
88
+ setUserLastName(userData.lastName || '');
89
+ setUserBadgeId(userData.badgeId || '');
86
90
  }
87
91
  } catch (err) {
88
92
  console.error('Failed to load user company:', err);
@@ -167,6 +171,8 @@ export const Striae = ({ user }: StriaePage) => {
167
171
  selectedFilename,
168
172
  userCompany,
169
173
  userFirstName,
174
+ userLastName,
175
+ userBadgeId,
170
176
  currentCase,
171
177
  annotationData,
172
178
  activeAnnotations,
@@ -389,6 +395,7 @@ export const Striae = ({ user }: StriaePage) => {
389
395
  imageUrl={selectedImage}
390
396
  filename={selectedFilename}
391
397
  company={userCompany}
398
+ badgeId={userBadgeId}
392
399
  firstName={userFirstName}
393
400
  error={error ?? ''}
394
401
  activeAnnotations={activeAnnotations}
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() || '',
@@ -60,6 +60,7 @@ export class AuditService {
60
60
  private static instance: AuditService;
61
61
  private auditBuffer: ValidationAuditEntry[] = [];
62
62
  private workflowId: string | null = null;
63
+ private userBadgeIdByUserId = new Map<string, string>();
63
64
 
64
65
  private constructor() {}
65
66
 
@@ -97,7 +98,26 @@ export class AuditService {
97
98
  const startTime = Date.now();
98
99
 
99
100
  try {
100
- const auditEntry = buildValidationAuditEntry(params);
101
+ const providedBadgeId = params.userProfileDetails?.badgeId?.trim();
102
+ if (providedBadgeId && params.userId) {
103
+ this.userBadgeIdByUserId.set(params.userId, providedBadgeId);
104
+ }
105
+
106
+ const resolvedBadgeId =
107
+ providedBadgeId ||
108
+ (params.userId ? this.userBadgeIdByUserId.get(params.userId) : undefined);
109
+
110
+ const paramsWithBadgeId: CreateAuditEntryParams = resolvedBadgeId
111
+ ? {
112
+ ...params,
113
+ userProfileDetails: {
114
+ ...(params.userProfileDetails || {}),
115
+ badgeId: resolvedBadgeId
116
+ }
117
+ }
118
+ : params;
119
+
120
+ const auditEntry = buildValidationAuditEntry(paramsWithBadgeId);
101
121
 
102
122
  // Add to buffer for batch processing
103
123
  this.auditBuffer.push(auditEntry);
@@ -196,13 +216,15 @@ export class AuditService {
196
216
  originalExaminerUid?: string,
197
217
  performanceMetrics?: PerformanceMetrics,
198
218
  imageFileId?: string,
199
- originalImageFileName?: string
219
+ originalImageFileName?: string,
220
+ badgeId?: string
200
221
  ): Promise<void> {
201
222
  await this.logEvent(
202
223
  buildConfirmationCreationAuditParams({
203
224
  user,
204
225
  caseNumber,
205
226
  confirmationId,
227
+ badgeId,
206
228
  result,
207
229
  errors,
208
230
  originalExaminerUid,
@@ -550,12 +572,13 @@ export class AuditService {
550
572
  */
551
573
  public async logUserProfileUpdate(
552
574
  user: User,
553
- profileField: 'displayName' | 'email' | 'organization' | 'role' | 'preferences' | 'avatar',
575
+ profileField: 'displayName' | 'email' | 'organization' | 'role' | 'preferences' | 'avatar' | 'badgeId',
554
576
  oldValue: string,
555
577
  newValue: string,
556
578
  result: AuditResult,
557
579
  sessionId?: string,
558
- errors: string[] = []
580
+ errors: string[] = [],
581
+ badgeId?: string
559
582
  ): Promise<void> {
560
583
  await this.logEvent(
561
584
  buildUserProfileUpdateAuditParams({
@@ -565,7 +588,8 @@ export class AuditService {
565
588
  newValue,
566
589
  result,
567
590
  sessionId,
568
- errors
591
+ errors,
592
+ badgeId
569
593
  })
570
594
  );
571
595
  }
@@ -26,7 +26,8 @@ export const buildValidationAuditEntry = (
26
26
  fileDetails: params.fileDetails,
27
27
  annotationDetails: params.annotationDetails,
28
28
  sessionDetails: params.sessionDetails,
29
- securityDetails: params.securityDetails
29
+ securityDetails: params.securityDetails,
30
+ userProfileDetails: params.userProfileDetails
30
31
  }
31
32
  };
32
33
  };
@@ -57,9 +57,10 @@ export const buildUserLogoutAuditParams = (
57
57
 
58
58
  interface BuildUserProfileUpdateAuditParamsInput {
59
59
  user: User;
60
- profileField: 'displayName' | 'email' | 'organization' | 'role' | 'preferences' | 'avatar';
60
+ profileField: 'displayName' | 'email' | 'organization' | 'role' | 'preferences' | 'avatar' | 'badgeId';
61
61
  oldValue: string;
62
62
  newValue: string;
63
+ badgeId?: string;
63
64
  result: AuditResult;
64
65
  sessionId?: string;
65
66
  errors?: string[];
@@ -85,7 +86,8 @@ export const buildUserProfileUpdateAuditParams = (
85
86
  userProfileDetails: {
86
87
  profileField: input.profileField,
87
88
  oldValue: input.oldValue,
88
- newValue: input.newValue
89
+ newValue: input.newValue,
90
+ badgeId: input.badgeId
89
91
  }
90
92
  };
91
93
  };
@@ -125,6 +125,7 @@ interface BuildConfirmationCreationAuditParamsInput {
125
125
  user: User;
126
126
  caseNumber: string;
127
127
  confirmationId: string;
128
+ badgeId?: string;
128
129
  result: AuditResult;
129
130
  errors?: string[];
130
131
  originalExaminerUid?: string;
@@ -156,6 +157,11 @@ export const buildConfirmationCreationAuditParams = (
156
157
  performanceMetrics: input.performanceMetrics,
157
158
  originalExaminerUid: input.originalExaminerUid,
158
159
  reviewingExaminerUid: input.user.uid,
160
+ userProfileDetails: input.badgeId
161
+ ? {
162
+ badgeId: input.badgeId
163
+ }
164
+ : undefined,
159
165
  fileDetails: input.imageFileId && input.originalImageFileName
160
166
  ? {
161
167
  fileId: input.imageFileId,
@@ -269,9 +269,10 @@ export interface SecurityAuditDetails {
269
269
  * User profile and authentication specific audit details
270
270
  */
271
271
  export interface UserProfileAuditDetails {
272
- profileField?: 'displayName' | 'email' | 'organization' | 'role' | 'preferences' | 'avatar';
272
+ profileField?: 'displayName' | 'email' | 'organization' | 'role' | 'preferences' | 'avatar' | 'badgeId';
273
273
  oldValue?: string;
274
274
  newValue?: string;
275
+ badgeId?: string;
275
276
  resetMethod?: 'email' | 'sms' | 'security-questions' | 'admin-reset';
276
277
  resetToken?: string; // Partial token for tracking (last 4 chars)
277
278
  verificationMethod?: 'email-link' | 'sms-code' | 'totp' | 'backup-codes' | 'admin-verification';
package/app/types/user.ts CHANGED
@@ -8,6 +8,7 @@ export interface UserData {
8
8
  firstName: string;
9
9
  lastName: string;
10
10
  company: string;
11
+ badgeId?: string;
11
12
  permitted: boolean;
12
13
  cases: Array<{
13
14
  caseNumber: string;
@@ -110,6 +110,7 @@ export const createUser = async (
110
110
  firstName,
111
111
  lastName,
112
112
  company,
113
+ badgeId: '',
113
114
  permitted,
114
115
  cases: [],
115
116
  readOnlyCases: [],
@@ -6,6 +6,8 @@ interface PdfProxyContext {
6
6
  }
7
7
 
8
8
  const SUPPORTED_METHODS = new Set(['POST', 'OPTIONS']);
9
+ const PRIMERSHEAR_FORMAT = 'primershear';
10
+ const DEFAULT_FORMAT = 'striae';
9
11
 
10
12
  function textResponse(message: string, status: number): Response {
11
13
  return new Response(message, {
@@ -40,6 +42,12 @@ function extractProxyPath(url: URL): string | null {
40
42
  return remainder.length > 0 ? remainder : '/';
41
43
  }
42
44
 
45
+ function resolveReportFormat(email: string | null, primershearEmails: string): string {
46
+ if (!email) return DEFAULT_FORMAT;
47
+ const allowed = primershearEmails.split(',').map(e => e.trim().toLowerCase()).filter(Boolean);
48
+ return allowed.includes(email.toLowerCase()) ? PRIMERSHEAR_FORMAT : DEFAULT_FORMAT;
49
+ }
50
+
43
51
  export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Response> => {
44
52
  if (!SUPPORTED_METHODS.has(request.method)) {
45
53
  return textResponse('Method not allowed', 405);
@@ -86,12 +94,35 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
86
94
 
87
95
  upstreamHeaders.set('X-Custom-Auth-Key', env.PDF_WORKER_AUTH);
88
96
 
97
+ // Resolve the report format server-side based on the verified user email.
98
+ // This prevents email lists from ever being exposed in the client bundle.
99
+ const reportFormat = resolveReportFormat(
100
+ identity.email,
101
+ env.PRIMERSHEAR_EMAILS ?? ''
102
+ );
103
+
104
+ let upstreamBody: BodyInit;
105
+ try {
106
+ const payload = await request.json() as Record<string, unknown>;
107
+ // Inject the server-resolved format, overriding any client-supplied value.
108
+ if (payload.data && typeof payload.data === 'object') {
109
+ payload.reportFormat = reportFormat;
110
+ } else {
111
+ // Legacy flat payload shape
112
+ payload.reportFormat = reportFormat;
113
+ }
114
+ upstreamBody = JSON.stringify(payload);
115
+ upstreamHeaders.set('Content-Type', 'application/json');
116
+ } catch {
117
+ return textResponse('Invalid request body', 400);
118
+ }
119
+
89
120
  let upstreamResponse: Response;
90
121
  try {
91
122
  upstreamResponse = await fetch(upstreamUrl, {
92
123
  method: request.method,
93
124
  headers: upstreamHeaders,
94
- body: request.body
125
+ body: upstreamBody
95
126
  });
96
127
  } catch {
97
128
  return textResponse('Upstream PDF service unavailable', 502);
@@ -0,0 +1,9 @@
1
+ import { type PlatformProxy } from "wrangler";
2
+
3
+ type Cloudflare = Omit<PlatformProxy<Env>, "dispose">;
4
+
5
+ declare module "react-router" {
6
+ interface AppLoadContext {
7
+ cloudflare: Cloudflare;
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.0.3",
3
+ "version": "4.1.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",
@@ -43,8 +43,10 @@
43
43
  "app/entry.client.tsx",
44
44
  "app/entry.server.tsx",
45
45
  "app/root.tsx",
46
+ "app/routes.ts",
46
47
  "app/tailwind.css",
47
48
  "react-router.config.ts",
49
+ "load-context.ts",
48
50
  "functions/",
49
51
  "public/",
50
52
  "scripts/",
@@ -60,6 +62,7 @@
60
62
  "workers/pdf-worker/src/report-types.ts",
61
63
  "workers/*/wrangler.jsonc.example",
62
64
  ".env.example",
65
+ "primershear.emails.example",
63
66
  "firebase.json",
64
67
  "postcss.config.js",
65
68
  "tailwind.config.ts",
@@ -101,6 +104,7 @@
101
104
  "deploy-workers:secrets": "bash ./scripts/deploy-worker-secrets.sh",
102
105
  "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh",
103
106
  "deploy-pages": "bash ./scripts/deploy-pages.sh",
107
+ "deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh",
104
108
  "deploy-workers:audit": "cd workers/audit-worker && npm run deploy",
105
109
  "deploy-workers:data": "cd workers/data-worker && npm run deploy",
106
110
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
@@ -0,0 +1,6 @@
1
+ # PrimerShear PDF format - authorized email addresses
2
+ # One email per line. Lines starting with # are ignored.
3
+ # This file is untracked. Run: npm run deploy-primershear to push changes.
4
+ #
5
+ # Example:
6
+ # analyst@organization.com
@@ -178,6 +178,12 @@ deploy_pages_environment_secrets() {
178
178
  set_pages_secret "API_TOKEN" "$optional_api_token" "$pages_env"
179
179
  fi
180
180
 
181
+ local optional_primershear_emails
182
+ optional_primershear_emails=$(get_optional_value "PRIMERSHEAR_EMAILS")
183
+ if [ -n "$optional_primershear_emails" ]; then
184
+ set_pages_secret "PRIMERSHEAR_EMAILS" "$optional_primershear_emails" "$pages_env"
185
+ fi
186
+
181
187
  echo -e "${GREEN}✅ Pages secrets deployed to $pages_env${NC}"
182
188
  }
183
189