@striae-org/striae 4.0.2 → 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 (69) 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 +786 -0
  39. package/app/routes/auth/login.module.example.css +523 -0
  40. package/app/routes/auth/login.tsx +1 -1
  41. package/app/routes/auth/passwordReset.module.css +23 -13
  42. package/app/routes/striae/striae.tsx +8 -1
  43. package/app/routes.ts +7 -0
  44. package/app/services/audit/audit-export-csv.ts +2 -0
  45. package/app/services/audit/audit.service.ts +29 -5
  46. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  47. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  48. package/app/services/audit/builders/audit-event-builders-workflow.ts +6 -0
  49. package/app/types/audit.ts +2 -1
  50. package/app/types/user.ts +1 -0
  51. package/app/utils/data/permissions.ts +1 -0
  52. package/functions/api/pdf/[[path]].ts +32 -1
  53. package/load-context.ts +9 -0
  54. package/package.json +5 -1
  55. package/primershear.emails.example +6 -0
  56. package/scripts/deploy-config.sh +27 -0
  57. package/scripts/deploy-pages-secrets.sh +6 -0
  58. package/scripts/deploy-primershear-emails.sh +166 -0
  59. package/worker-configuration.d.ts +7493 -7491
  60. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  61. package/workers/data-worker/wrangler.jsonc.example +1 -1
  62. package/workers/image-worker/wrangler.jsonc.example +1 -1
  63. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  64. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  65. package/workers/pdf-worker/src/report-types.ts +3 -0
  66. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  67. package/workers/user-worker/src/user-worker.example.ts +6 -1
  68. package/workers/user-worker/wrangler.jsonc.example +1 -1
  69. package/wrangler.toml.example +1 -1
@@ -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.2",
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
@@ -697,6 +697,8 @@ validate_generated_configs() {
697
697
  "app/config/config.json"
698
698
  "app/config/firebase.ts"
699
699
  "app/config/admin-service.json"
700
+ "app/routes/auth/login.tsx"
701
+ "app/routes/auth/login.module.css"
700
702
  "workers/audit-worker/wrangler.jsonc"
701
703
  "workers/data-worker/wrangler.jsonc"
702
704
  "workers/image-worker/wrangler.jsonc"
@@ -741,6 +743,7 @@ validate_generated_configs() {
741
743
 
742
744
  assert_contains_literal "app/config/config.json" "https://$PAGES_CUSTOM_DOMAIN" "PAGES_CUSTOM_DOMAIN missing in app/config/config.json"
743
745
  assert_contains_literal "app/config/config.json" "$ACCOUNT_HASH" "ACCOUNT_HASH missing in app/config/config.json"
746
+ assert_contains_literal "app/routes/auth/login.tsx" "const APP_CANONICAL_ORIGIN = 'https://$PAGES_CUSTOM_DOMAIN';" "PAGES_CUSTOM_DOMAIN missing in app/routes/auth/login.tsx canonical origin"
744
747
 
745
748
  assert_contains_literal "app/config/firebase.ts" "$API_KEY" "API_KEY missing in app/config/firebase.ts"
746
749
  assert_contains_literal "app/config/firebase.ts" "$AUTH_DOMAIN" "AUTH_DOMAIN missing in app/config/firebase.ts"
@@ -776,6 +779,7 @@ validate_generated_configs() {
776
779
  "workers/user-worker/src/user-worker.ts"
777
780
  "app/config/config.json"
778
781
  "app/config/firebase.ts"
782
+ "app/routes/auth/login.tsx"
779
783
  )
780
784
 
781
785
  for file_path in "${files_to_scan[@]}"; do
@@ -862,6 +866,23 @@ copy_example_configs() {
862
866
 
863
867
  echo -e "${GREEN} ✅ app: copied $copied_config_files config file(s) from config-example${NC}"
864
868
  fi
869
+
870
+ # Copy auth route template files
871
+ echo -e "${YELLOW} Copying auth route template files...${NC}"
872
+
873
+ if [ -f "app/routes/auth/login.example.tsx" ] && { [ "$update_env" = "true" ] || [ ! -f "app/routes/auth/login.tsx" ]; }; then
874
+ cp app/routes/auth/login.example.tsx app/routes/auth/login.tsx
875
+ echo -e "${GREEN} ✅ auth: login.tsx created from example${NC}"
876
+ elif [ -f "app/routes/auth/login.tsx" ]; then
877
+ echo -e "${YELLOW} ⚠️ auth: login.tsx already exists, skipping copy${NC}"
878
+ fi
879
+
880
+ if [ -f "app/routes/auth/login.module.example.css" ] && { [ "$update_env" = "true" ] || [ ! -f "app/routes/auth/login.module.css" ]; }; then
881
+ cp app/routes/auth/login.module.example.css app/routes/auth/login.module.css
882
+ echo -e "${GREEN} ✅ auth: login.module.css created from example${NC}"
883
+ elif [ -f "app/routes/auth/login.module.css" ]; then
884
+ echo -e "${YELLOW} ⚠️ auth: login.module.css already exists, skipping copy${NC}"
885
+ fi
865
886
 
866
887
  # Navigate to each worker directory and copy the example file
867
888
  echo -e "${YELLOW} Copying worker configuration files...${NC}"
@@ -1450,6 +1471,12 @@ update_wrangler_configs() {
1450
1471
  sed -i "s|\"YOUR_FIREBASE_MEASUREMENT_ID\"|\"$MEASUREMENT_ID\"|g" app/config/firebase.ts
1451
1472
  echo -e "${GREEN} ✅ app firebase.ts updated${NC}"
1452
1473
  fi
1474
+
1475
+ if [ -f "app/routes/auth/login.tsx" ]; then
1476
+ echo -e "${YELLOW} Updating app/routes/auth/login.tsx...${NC}"
1477
+ sed -i "s|^const APP_CANONICAL_ORIGIN = .*;|const APP_CANONICAL_ORIGIN = 'https://$escaped_pages_custom_domain';|g" app/routes/auth/login.tsx
1478
+ echo -e "${GREEN} ✅ app login.tsx canonical origin updated${NC}"
1479
+ fi
1453
1480
 
1454
1481
  echo -e "${GREEN}✅ All configuration files updated${NC}"
1455
1482
  }
@@ -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
 
@@ -0,0 +1,166 @@
1
+ #!/bin/bash
2
+
3
+ # ============================================
4
+ # PRIMERSHEAR EMAIL LIST DEPLOYMENT SCRIPT
5
+ # ============================================
6
+ # Reads primershear.emails, updates PRIMERSHEAR_EMAILS in .env,
7
+ # then deploys that secret directly to Cloudflare Pages.
8
+ #
9
+ # Usage:
10
+ # bash ./scripts/deploy-primershear-emails.sh [--production-only|--preview-only|--env-only]
11
+ #
12
+ # Options:
13
+ # --production-only Deploy to production Pages environment only
14
+ # --preview-only Deploy to preview Pages environment only
15
+ # --env-only Update .env only; do not deploy to Cloudflare
16
+ # -h, --help Show this help message
17
+
18
+ set -e
19
+ set -o pipefail
20
+
21
+ RED='\033[0;31m'
22
+ GREEN='\033[0;32m'
23
+ YELLOW='\033[1;33m'
24
+ BLUE='\033[0;34m'
25
+ NC='\033[0m'
26
+
27
+ echo -e "${BLUE}📧 PrimerShear Email List Deployment${NC}"
28
+ echo "======================================"
29
+
30
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
32
+ cd "$PROJECT_ROOT"
33
+
34
+ trap 'echo -e "\n${RED}❌ deploy-primershear-emails.sh failed near line ${LINENO}${NC}"' ERR
35
+
36
+ # ── Argument parsing ─────────────────────────────────────────────────────────
37
+
38
+ deploy_production=true
39
+ deploy_preview=true
40
+ env_only=false
41
+
42
+ for arg in "$@"; do
43
+ case "$arg" in
44
+ -h|--help)
45
+ echo "Usage: bash ./scripts/deploy-primershear-emails.sh [--production-only|--preview-only|--env-only]"
46
+ echo ""
47
+ echo "Options:"
48
+ echo " --production-only Deploy to production Pages environment only"
49
+ echo " --preview-only Deploy to preview Pages environment only"
50
+ echo " --env-only Update .env only; do not deploy to Cloudflare"
51
+ echo " -h, --help Show this help message"
52
+ exit 0
53
+ ;;
54
+ --production-only)
55
+ deploy_production=true
56
+ deploy_preview=false
57
+ ;;
58
+ --preview-only)
59
+ deploy_production=false
60
+ deploy_preview=true
61
+ ;;
62
+ --env-only)
63
+ env_only=true
64
+ ;;
65
+ *)
66
+ echo -e "${RED}❌ Unknown option: $arg${NC}"
67
+ echo "Use --help to see supported options."
68
+ exit 1
69
+ ;;
70
+ esac
71
+ done
72
+
73
+ # ── Read emails file ──────────────────────────────────────────────────────────
74
+
75
+ EMAILS_FILE="$PROJECT_ROOT/primershear.emails"
76
+
77
+ if [ ! -f "$EMAILS_FILE" ]; then
78
+ echo -e "${RED}❌ primershear.emails not found at: $EMAILS_FILE${NC}"
79
+ echo -e "${YELLOW} Create it with one email address per line.${NC}"
80
+ exit 1
81
+ fi
82
+
83
+ # Strip comment lines and blank lines, then join with commas
84
+ PRIMERSHEAR_EMAILS=$(grep -v '^\s*#' "$EMAILS_FILE" | grep -v '^\s*$' | paste -sd ',' -)
85
+
86
+ if [ -z "$PRIMERSHEAR_EMAILS" ]; then
87
+ echo -e "${YELLOW}⚠️ primershear.emails contains no active email addresses.${NC}"
88
+ echo -e "${YELLOW} The secret will be set to an empty string, disabling the feature.${NC}"
89
+ fi
90
+
91
+ EMAIL_COUNT=$(echo "$PRIMERSHEAR_EMAILS" | tr ',' '\n' | grep -c '[^[:space:]]' || true)
92
+ echo -e "${GREEN}✅ Loaded $EMAIL_COUNT email address(es) from primershear.emails${NC}"
93
+
94
+ # ── Update .env ───────────────────────────────────────────────────────────────
95
+
96
+ ENV_FILE="$PROJECT_ROOT/.env"
97
+
98
+ if [ ! -f "$ENV_FILE" ]; then
99
+ echo -e "${RED}❌ .env not found. Run deploy-config first.${NC}"
100
+ exit 1
101
+ fi
102
+
103
+ # Replace the PRIMERSHEAR_EMAILS= line in .env (handles both empty and populated values)
104
+ if grep -q '^PRIMERSHEAR_EMAILS=' "$ENV_FILE"; then
105
+ # Use a temp file to avoid sed -i portability issues across macOS/Linux
106
+ local_tmp=$(mktemp)
107
+ sed "s|^PRIMERSHEAR_EMAILS=.*|PRIMERSHEAR_EMAILS=${PRIMERSHEAR_EMAILS}|" "$ENV_FILE" > "$local_tmp"
108
+ mv "$local_tmp" "$ENV_FILE"
109
+ echo -e "${GREEN}✅ Updated PRIMERSHEAR_EMAILS in .env${NC}"
110
+ else
111
+ echo "" >> "$ENV_FILE"
112
+ echo "PRIMERSHEAR_EMAILS=${PRIMERSHEAR_EMAILS}" >> "$ENV_FILE"
113
+ echo -e "${GREEN}✅ Appended PRIMERSHEAR_EMAILS to .env${NC}"
114
+ fi
115
+
116
+ if [ "$env_only" = "true" ]; then
117
+ echo -e "\n${GREEN}🎉 .env updated. Skipping Cloudflare deployment (--env-only).${NC}"
118
+ exit 0
119
+ fi
120
+
121
+ # ── Deploy to Cloudflare Pages ────────────────────────────────────────────────
122
+
123
+ if ! command -v wrangler > /dev/null 2>&1; then
124
+ echo -e "${RED}❌ wrangler is not installed or not in PATH${NC}"
125
+ exit 1
126
+ fi
127
+
128
+ source "$ENV_FILE"
129
+
130
+ PAGES_PROJECT_NAME=$(echo "$PAGES_PROJECT_NAME" | tr -d '\r')
131
+ if [ -z "$PAGES_PROJECT_NAME" ]; then
132
+ echo -e "${RED}❌ PAGES_PROJECT_NAME is missing from .env${NC}"
133
+ exit 1
134
+ fi
135
+
136
+ set_secret() {
137
+ local pages_env=$1
138
+ echo -e "${YELLOW} Setting PRIMERSHEAR_EMAILS for $pages_env...${NC}"
139
+ if [ "$pages_env" = "production" ]; then
140
+ printf '%s' "$PRIMERSHEAR_EMAILS" | wrangler pages secret put PRIMERSHEAR_EMAILS \
141
+ --project-name "$PAGES_PROJECT_NAME"
142
+ else
143
+ printf '%s' "$PRIMERSHEAR_EMAILS" | wrangler pages secret put PRIMERSHEAR_EMAILS \
144
+ --project-name "$PAGES_PROJECT_NAME" --env "$pages_env"
145
+ fi
146
+ }
147
+
148
+ if [ "$deploy_production" = "true" ]; then
149
+ set_secret "production"
150
+ echo -e "${GREEN}✅ PRIMERSHEAR_EMAILS deployed to production${NC}"
151
+ fi
152
+
153
+ if [ "$deploy_preview" = "true" ]; then
154
+ set_secret "preview"
155
+ echo -e "${GREEN}✅ PRIMERSHEAR_EMAILS deployed to preview${NC}"
156
+ fi
157
+
158
+ # Deploy Pages so the new secret takes effect immediately
159
+ echo -e "\n${YELLOW}🚀 Building and deploying Pages to activate new secret...${NC}"
160
+ if ! npm run deploy; then
161
+ echo -e "${RED}❌ Pages deployment failed${NC}"
162
+ exit 1
163
+ fi
164
+ echo -e "${GREEN}✅ Pages deployment complete${NC}"
165
+
166
+ echo -e "\n${GREEN}🎉 PrimerShear email list deployment complete!${NC}"