@codaijs/keel 0.2.2 → 0.2.4

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 (80) hide show
  1. package/dist/__tests__/sail-installer.test.js +25 -25
  2. package/dist/sail-installer.js +174 -174
  3. package/dist/scaffold.js +68 -68
  4. package/package.json +58 -58
  5. package/sails/_template/addon.json +20 -20
  6. package/sails/_template/install.ts +402 -402
  7. package/sails/admin-dashboard/README.md +117 -117
  8. package/sails/admin-dashboard/addon.json +28 -28
  9. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -34
  10. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -243
  11. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -40
  12. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -240
  13. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -149
  14. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -173
  15. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -203
  16. package/sails/admin-dashboard/install.ts +305 -305
  17. package/sails/analytics/README.md +178 -178
  18. package/sails/analytics/addon.json +27 -27
  19. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -58
  20. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -64
  21. package/sails/analytics/files/frontend/lib/analytics.ts +103 -103
  22. package/sails/analytics/install.ts +297 -297
  23. package/sails/file-uploads/addon.json +30 -30
  24. package/sails/file-uploads/files/backend/routes/files.ts +198 -198
  25. package/sails/file-uploads/files/backend/schema/files.ts +36 -36
  26. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -128
  27. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -248
  28. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -147
  29. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -106
  30. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -118
  31. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -37
  32. package/sails/file-uploads/install.ts +466 -466
  33. package/sails/gdpr/README.md +174 -174
  34. package/sails/gdpr/addon.json +27 -27
  35. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -140
  36. package/sails/gdpr/files/backend/services/gdpr.ts +293 -293
  37. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -97
  38. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -192
  39. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -75
  40. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -186
  41. package/sails/gdpr/install.ts +756 -756
  42. package/sails/google-oauth/README.md +121 -121
  43. package/sails/google-oauth/addon.json +22 -22
  44. package/sails/google-oauth/files/GoogleButton.tsx +50 -50
  45. package/sails/google-oauth/install.ts +252 -252
  46. package/sails/i18n/README.md +193 -193
  47. package/sails/i18n/addon.json +30 -30
  48. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -108
  49. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -31
  50. package/sails/i18n/files/frontend/lib/i18n.ts +32 -32
  51. package/sails/i18n/files/frontend/locales/de/common.json +44 -44
  52. package/sails/i18n/files/frontend/locales/en/common.json +44 -44
  53. package/sails/i18n/install.ts +407 -407
  54. package/sails/push-notifications/README.md +163 -163
  55. package/sails/push-notifications/addon.json +31 -31
  56. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -153
  57. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -31
  58. package/sails/push-notifications/files/backend/services/notifications.ts +117 -117
  59. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -12
  60. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -154
  61. package/sails/push-notifications/install.ts +384 -384
  62. package/sails/r2-storage/addon.json +29 -29
  63. package/sails/r2-storage/files/backend/services/storage.ts +71 -71
  64. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -167
  65. package/sails/r2-storage/install.ts +412 -412
  66. package/sails/rate-limiting/addon.json +20 -20
  67. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -104
  68. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -137
  69. package/sails/rate-limiting/install.ts +300 -300
  70. package/sails/registry.json +107 -107
  71. package/sails/stripe/README.md +214 -214
  72. package/sails/stripe/addon.json +24 -24
  73. package/sails/stripe/files/backend/routes/stripe.ts +154 -154
  74. package/sails/stripe/files/backend/schema/stripe.ts +74 -74
  75. package/sails/stripe/files/backend/services/stripe.ts +224 -224
  76. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -135
  77. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -86
  78. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -116
  79. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -226
  80. package/sails/stripe/install.ts +378 -378
@@ -39,13 +39,13 @@ describe("insertAtMarker", () => {
39
39
  });
40
40
  it("inserts code after marker comment", () => {
41
41
  const filePath = join(tempDir, "test.ts");
42
- writeFileSync(filePath, `import express from "express";
43
- // [SAIL_IMPORTS]
44
-
45
- const app = express();
46
- // [SAIL_ROUTES]
47
-
48
- app.listen(3000);
42
+ writeFileSync(filePath, `import express from "express";
43
+ // [SAIL_IMPORTS]
44
+
45
+ const app = express();
46
+ // [SAIL_ROUTES]
47
+
48
+ app.listen(3000);
49
49
  `);
50
50
  const result = insertAtMarker(filePath, "// [SAIL_IMPORTS]", 'import { stripeRouter } from "./routes/stripe.js";');
51
51
  expect(result).toBe(true);
@@ -58,9 +58,9 @@ app.listen(3000);
58
58
  });
59
59
  it("inserts code after JSX marker comment", () => {
60
60
  const filePath = join(tempDir, "router.tsx");
61
- writeFileSync(filePath, `<Route path="/" element={<Home />} />
62
- {/* [SAIL_ROUTES] */}
63
- </Route>
61
+ writeFileSync(filePath, `<Route path="/" element={<Home />} />
62
+ {/* [SAIL_ROUTES] */}
63
+ </Route>
64
64
  `);
65
65
  const result = insertAtMarker(filePath, "{/* [SAIL_ROUTES] */}", ' <Route path="/pricing" element={<Pricing />} />');
66
66
  expect(result).toBe(true);
@@ -69,8 +69,8 @@ app.listen(3000);
69
69
  });
70
70
  it("is idempotent — does not insert the same code twice", () => {
71
71
  const filePath = join(tempDir, "test.ts");
72
- const originalContent = `// [SAIL_IMPORTS]
73
- const x = 1;
72
+ const originalContent = `// [SAIL_IMPORTS]
73
+ const x = 1;
74
74
  `;
75
75
  writeFileSync(filePath, originalContent);
76
76
  const code = 'import { foo } from "./foo.js";';
@@ -87,8 +87,8 @@ const x = 1;
87
87
  });
88
88
  it("records manual step when marker is missing", () => {
89
89
  const filePath = join(tempDir, "test.ts");
90
- writeFileSync(filePath, `import express from "express";
91
- const app = express();
90
+ writeFileSync(filePath, `import express from "express";
91
+ const app = express();
92
92
  `);
93
93
  const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => { });
94
94
  const result = insertAtMarker(filePath, "// [SAIL_IMPORTS]", 'import { foo } from "./foo.js";');
@@ -109,10 +109,10 @@ const app = express();
109
109
  });
110
110
  it("preserves existing content around marker", () => {
111
111
  const filePath = join(tempDir, "test.ts");
112
- writeFileSync(filePath, `line1
113
- // [SAIL_IMPORTS]
114
- line3
115
- line4
112
+ writeFileSync(filePath, `line1
113
+ // [SAIL_IMPORTS]
114
+ line3
115
+ line4
116
116
  `);
117
117
  insertAtMarker(filePath, "// [SAIL_IMPORTS]", "inserted_line");
118
118
  const content = readFileSync(filePath, "utf-8");
@@ -124,13 +124,13 @@ line4
124
124
  });
125
125
  it("handles multiple markers in same file", () => {
126
126
  const filePath = join(tempDir, "test.ts");
127
- writeFileSync(filePath, `// [SAIL_IMPORTS]
128
-
129
- const app = express();
130
-
131
- // [SAIL_ROUTES]
132
-
133
- app.listen(3000);
127
+ writeFileSync(filePath, `// [SAIL_IMPORTS]
128
+
129
+ const app = express();
130
+
131
+ // [SAIL_ROUTES]
132
+
133
+ app.listen(3000);
134
134
  `);
135
135
  insertAtMarker(filePath, "// [SAIL_IMPORTS]", 'import { a } from "./a.js";');
136
136
  insertAtMarker(filePath, "// [SAIL_ROUTES]", 'app.use("/api/a", a);');
@@ -174,12 +174,12 @@ function installGoogleOAuth(sailDir, projectDir) {
174
174
  mkdirSync(destDir, { recursive: true });
175
175
  copyFileSync(join(sailDir, "files/GoogleButton.tsx"), join(destDir, "GoogleButton.tsx"));
176
176
  // Modify backend auth config
177
- insertAtMarker(join(backendDir, "src/auth/index.ts"), "// [SAIL_SOCIAL_PROVIDERS]", ` google: {
178
- clientId: process.env.GOOGLE_CLIENT_ID!,
179
- clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
177
+ insertAtMarker(join(backendDir, "src/auth/index.ts"), "// [SAIL_SOCIAL_PROVIDERS]", ` google: {
178
+ clientId: process.env.GOOGLE_CLIENT_ID!,
179
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
180
180
  },`);
181
181
  // Add env var validation
182
- insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` GOOGLE_CLIENT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "GOOGLE_CLIENT_ID is required in production").default(""),
182
+ insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` GOOGLE_CLIENT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "GOOGLE_CLIENT_ID is required in production").default(""),
183
183
  GOOGLE_CLIENT_SECRET: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "GOOGLE_CLIENT_SECRET is required in production").default(""),`);
184
184
  // Modify login and signup forms
185
185
  for (const form of ["LoginForm.tsx", "SignupForm.tsx"]) {
@@ -223,8 +223,8 @@ function installPushNotifications(sailDir, projectDir) {
223
223
  insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_IMPORTS]", 'import { notificationsRouter } from "./routes/notifications.js";');
224
224
  insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_ROUTES]", 'app.use("/api/notifications", notificationsRouter);');
225
225
  // Add env var validation
226
- insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` FIREBASE_PROJECT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "FIREBASE_PROJECT_ID is required in production").default(""),
227
- FIREBASE_PRIVATE_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "FIREBASE_PRIVATE_KEY is required in production").default(""),
226
+ insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` FIREBASE_PROJECT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "FIREBASE_PROJECT_ID is required in production").default(""),
227
+ FIREBASE_PRIVATE_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "FIREBASE_PRIVATE_KEY is required in production").default(""),
228
228
  FIREBASE_CLIENT_EMAIL: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "FIREBASE_CLIENT_EMAIL is required in production").default(""),`);
229
229
  // Modify Layout.tsx to include PushNotificationInit
230
230
  const layoutPath = join(frontendDir, "src/components/layout/Layout.tsx");
@@ -346,23 +346,23 @@ function installStripe(sailDir, projectDir) {
346
346
  }
347
347
  }
348
348
  // Add env var validation
349
- insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` STRIPE_SECRET_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "STRIPE_SECRET_KEY is required in production").default(""),
350
- STRIPE_PUBLISHABLE_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "STRIPE_PUBLISHABLE_KEY is required in production").default(""),
349
+ insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` STRIPE_SECRET_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "STRIPE_SECRET_KEY is required in production").default(""),
350
+ STRIPE_PUBLISHABLE_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "STRIPE_PUBLISHABLE_KEY is required in production").default(""),
351
351
  STRIPE_WEBHOOK_SECRET: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "STRIPE_WEBHOOK_SECRET is required in production").default(""),`);
352
352
  // Modify frontend router
353
- insertAtMarker(join(frontendDir, "src/router.tsx"), "// [SAIL_IMPORTS]", `import { PricingPage } from "./pages/Pricing";
353
+ insertAtMarker(join(frontendDir, "src/router.tsx"), "// [SAIL_IMPORTS]", `import { PricingPage } from "./pages/Pricing";
354
354
  import { CheckoutPage } from "./pages/Checkout";`);
355
- insertAtMarker(join(frontendDir, "src/router.tsx"), "// [SAIL_ROUTES]", ` {
356
- path: "/pricing",
357
- element: <PricingPage />,
358
- },
359
- {
360
- path: "/checkout/success",
361
- element: <CheckoutPage status="success" />,
362
- },
363
- {
364
- path: "/checkout/cancel",
365
- element: <CheckoutPage status="cancel" />,
355
+ insertAtMarker(join(frontendDir, "src/router.tsx"), "// [SAIL_ROUTES]", ` {
356
+ path: "/pricing",
357
+ element: <PricingPage />,
358
+ },
359
+ {
360
+ path: "/checkout/success",
361
+ element: <CheckoutPage status="success" />,
362
+ },
363
+ {
364
+ path: "/checkout/cancel",
365
+ element: <CheckoutPage status="cancel" />,
366
366
  },`);
367
367
  // Install dependencies
368
368
  const manifest = loadManifest(sailDir);
@@ -417,7 +417,7 @@ function installAdminDashboard(sailDir, projectDir) {
417
417
  // Add env var validation
418
418
  insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ' ADMIN_EMAILS: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "ADMIN_EMAILS is required in production").default(""),');
419
419
  // Add frontend routes
420
- insertAtMarker(join(frontendDir, "src/router.tsx"), "{/* [SAIL_ROUTES] */}", ` <Route path="/admin" element={<ProtectedRoute><AdminDashboard /></ProtectedRoute>} />
420
+ insertAtMarker(join(frontendDir, "src/router.tsx"), "{/* [SAIL_ROUTES] */}", ` <Route path="/admin" element={<ProtectedRoute><AdminDashboard /></ProtectedRoute>} />
421
421
  <Route path="/admin/users/:id" element={<ProtectedRoute><AdminUserDetail /></ProtectedRoute>} />`);
422
422
  // Install dependencies
423
423
  const manifest = loadManifest(sailDir);
@@ -509,45 +509,45 @@ function installGdpr(sailDir, projectDir) {
509
509
  }
510
510
  // Insert GDPR table definitions at the SAIL_SCHEMA marker
511
511
  if (schemaContent.includes("// [SAIL_SCHEMA]") && !schemaContent.includes("consentRecords")) {
512
- const gdprSchema = `
513
- export const consentRecords = pgTable("consent_records", {
514
- id: text("id")
515
- .primaryKey()
516
- .$defaultFn(() => crypto.randomUUID()),
517
- userId: text("user_id")
518
- .notNull()
519
- .references(() => users.id, { onDelete: "cascade" }),
520
- consentType: varchar("consent_type", { length: 50 }).notNull(),
521
- granted: boolean("granted").notNull(),
522
- version: varchar("version", { length: 20 }).notNull(),
523
- ipAddress: text("ip_address"),
524
- userAgent: text("user_agent"),
525
- grantedAt: timestamp("granted_at").defaultNow().notNull(),
526
- revokedAt: timestamp("revoked_at"),
527
- });
528
-
529
- export const deletionRequests = pgTable("deletion_requests", {
530
- id: text("id")
531
- .primaryKey()
532
- .$defaultFn(() => crypto.randomUUID()),
533
- userId: text("user_id")
534
- .notNull()
535
- .references(() => users.id, { onDelete: "cascade" }),
536
- status: varchar("status", { length: 20 }).default("pending").notNull(),
537
- reason: text("reason"),
538
- requestedAt: timestamp("requested_at").defaultNow().notNull(),
539
- scheduledDeletionAt: timestamp("scheduled_deletion_at").notNull(),
540
- cancelledAt: timestamp("cancelled_at"),
541
- completedAt: timestamp("completed_at"),
542
- });
543
-
544
- export const consentRecordsRelations = relations(consentRecords, ({ one }) => ({
545
- user: one(users, { fields: [consentRecords.userId], references: [users.id] }),
546
- }));
547
-
548
- export const deletionRequestsRelations = relations(deletionRequests, ({ one }) => ({
549
- user: one(users, { fields: [deletionRequests.userId], references: [users.id] }),
550
- }));
512
+ const gdprSchema = `
513
+ export const consentRecords = pgTable("consent_records", {
514
+ id: text("id")
515
+ .primaryKey()
516
+ .$defaultFn(() => crypto.randomUUID()),
517
+ userId: text("user_id")
518
+ .notNull()
519
+ .references(() => users.id, { onDelete: "cascade" }),
520
+ consentType: varchar("consent_type", { length: 50 }).notNull(),
521
+ granted: boolean("granted").notNull(),
522
+ version: varchar("version", { length: 20 }).notNull(),
523
+ ipAddress: text("ip_address"),
524
+ userAgent: text("user_agent"),
525
+ grantedAt: timestamp("granted_at").defaultNow().notNull(),
526
+ revokedAt: timestamp("revoked_at"),
527
+ });
528
+
529
+ export const deletionRequests = pgTable("deletion_requests", {
530
+ id: text("id")
531
+ .primaryKey()
532
+ .$defaultFn(() => crypto.randomUUID()),
533
+ userId: text("user_id")
534
+ .notNull()
535
+ .references(() => users.id, { onDelete: "cascade" }),
536
+ status: varchar("status", { length: 20 }).default("pending").notNull(),
537
+ reason: text("reason"),
538
+ requestedAt: timestamp("requested_at").defaultNow().notNull(),
539
+ scheduledDeletionAt: timestamp("scheduled_deletion_at").notNull(),
540
+ cancelledAt: timestamp("cancelled_at"),
541
+ completedAt: timestamp("completed_at"),
542
+ });
543
+
544
+ export const consentRecordsRelations = relations(consentRecords, ({ one }) => ({
545
+ user: one(users, { fields: [consentRecords.userId], references: [users.id] }),
546
+ }));
547
+
548
+ export const deletionRequestsRelations = relations(deletionRequests, ({ one }) => ({
549
+ user: one(users, { fields: [deletionRequests.userId], references: [users.id] }),
550
+ }));
551
551
  `;
552
552
  schemaContent = schemaContent.replace("// [SAIL_SCHEMA]", `// [SAIL_SCHEMA]\n${gdprSchema}`);
553
553
  }
@@ -572,28 +572,28 @@ export const deletionRequestsRelations = relations(deletionRequests, ({ one }) =
572
572
  if (!signupContent.includes("ConsentCheckboxes")) {
573
573
  signupContent = signupContent.replace('import { useAuth } from "@/hooks/useAuth";', 'import { useAuth } from "@/hooks/useAuth";\nimport { apiPost } from "@/lib/api";\nimport ConsentCheckboxes, { type ConsentState } from "./ConsentCheckboxes";');
574
574
  signupContent = signupContent.replace(' const [confirmPassword, setConfirmPassword] = useState("");', ' const [confirmPassword, setConfirmPassword] = useState("");\n const [consent, setConsent] = useState<ConsentState>({\n privacyPolicy: false,\n termsOfService: false,\n marketingEmails: false,\n analytics: false,\n });');
575
- signupContent = signupContent.replace(" setIsSubmitting(true);\n\n try {\n await signup(email, password, name);\n\n setSuccess(true);", ` if (!consent.privacyPolicy || !consent.termsOfService) {
576
- setError("You must accept the Privacy Policy and Terms of Service.");
577
- return;
578
- }
579
-
580
- setIsSubmitting(true);
581
-
582
- try {
583
- await signup(email, password, name);
584
-
585
- // Record consent after successful signup
586
- try {
587
- await apiPost("/api/gdpr/consent", {
588
- privacyPolicy: consent.privacyPolicy,
589
- termsOfService: consent.termsOfService,
590
- marketingEmails: consent.marketingEmails,
591
- analytics: consent.analytics,
592
- });
593
- } catch {
594
- // Non-critical: consent recording failure shouldn't block signup
595
- }
596
-
575
+ signupContent = signupContent.replace(" setIsSubmitting(true);\n\n try {\n await signup(email, password, name);\n\n setSuccess(true);", ` if (!consent.privacyPolicy || !consent.termsOfService) {
576
+ setError("You must accept the Privacy Policy and Terms of Service.");
577
+ return;
578
+ }
579
+
580
+ setIsSubmitting(true);
581
+
582
+ try {
583
+ await signup(email, password, name);
584
+
585
+ // Record consent after successful signup
586
+ try {
587
+ await apiPost("/api/gdpr/consent", {
588
+ privacyPolicy: consent.privacyPolicy,
589
+ termsOfService: consent.termsOfService,
590
+ marketingEmails: consent.marketingEmails,
591
+ analytics: consent.analytics,
592
+ });
593
+ } catch {
594
+ // Non-critical: consent recording failure shouldn't block signup
595
+ }
596
+
597
597
  setSuccess(true);`);
598
598
  signupContent = signupContent.replace(" <button\n type=\"submit\"", " <ConsentCheckboxes value={consent} onChange={setConsent} />\n\n <button\n type=\"submit\"");
599
599
  writeFileSync(signupPath, signupContent, "utf-8");
@@ -605,58 +605,58 @@ export const deletionRequestsRelations = relations(deletionRequests, ({ one }) =
605
605
  let settingsContent = readFileSync(settingsPath, "utf-8");
606
606
  if (!settingsContent.includes("DataExportButton")) {
607
607
  settingsContent = settingsContent.replace('import { apiGet } from "@/lib/api";', 'import { apiGet, apiPost } from "@/lib/api";\nimport DataExportButton from "../gdpr/DataExportButton";\nimport AccountDeletionRequest from "../gdpr/AccountDeletionRequest";');
608
- settingsContent = settingsContent.replace("interface Session {", `interface ConsentSettings {
609
- marketingEmails: boolean;
610
- analytics: boolean;
611
- }
612
-
608
+ settingsContent = settingsContent.replace("interface Session {", `interface ConsentSettings {
609
+ marketingEmails: boolean;
610
+ analytics: boolean;
611
+ }
612
+
613
613
  interface Session {`);
614
- settingsContent = settingsContent.replace(" const [sessions, setSessions] = useState<Session[]>([]);", ` const [consent, setConsent] = useState<ConsentSettings>({
615
- marketingEmails: false,
616
- analytics: false,
617
- });
618
- const [sessions, setSessions] = useState<Session[]>([]);
619
- const [consentLoading, setConsentLoading] = useState(true);
614
+ settingsContent = settingsContent.replace(" const [sessions, setSessions] = useState<Session[]>([]);", ` const [consent, setConsent] = useState<ConsentSettings>({
615
+ marketingEmails: false,
616
+ analytics: false,
617
+ });
618
+ const [sessions, setSessions] = useState<Session[]>([]);
619
+ const [consentLoading, setConsentLoading] = useState(true);
620
620
  const [consentSaving, setConsentSaving] = useState(false);`);
621
- settingsContent = settingsContent.replace(` async function loadSettings() {
622
- try {
623
- const sessionsData = await apiGet<Session[]>("/api/auth/sessions");
624
- setSessions(sessionsData);
625
- } catch {
626
- // Settings may not exist yet
627
- }
628
- }`, ` async function loadSettings() {
629
- try {
630
- const [consentData, sessionsData] = await Promise.all([
631
- apiGet<ConsentSettings>("/api/gdpr/consent"),
632
- apiGet<Session[]>("/api/auth/sessions"),
633
- ]);
634
- setConsent(consentData);
635
- setSessions(sessionsData);
636
- } catch {
637
- // Settings may not exist yet
638
- } finally {
639
- setConsentLoading(false);
640
- }
621
+ settingsContent = settingsContent.replace(` async function loadSettings() {
622
+ try {
623
+ const sessionsData = await apiGet<Session[]>("/api/auth/sessions");
624
+ setSessions(sessionsData);
625
+ } catch {
626
+ // Settings may not exist yet
627
+ }
628
+ }`, ` async function loadSettings() {
629
+ try {
630
+ const [consentData, sessionsData] = await Promise.all([
631
+ apiGet<ConsentSettings>("/api/gdpr/consent"),
632
+ apiGet<Session[]>("/api/auth/sessions"),
633
+ ]);
634
+ setConsent(consentData);
635
+ setSessions(sessionsData);
636
+ } catch {
637
+ // Settings may not exist yet
638
+ } finally {
639
+ setConsentLoading(false);
640
+ }
641
641
  }`);
642
- settingsContent = settingsContent.replace(" return (", ` const handleConsentChange = async (
643
- field: keyof ConsentSettings,
644
- value: boolean,
645
- ) => {
646
- const updated = { ...consent, [field]: value };
647
- setConsent(updated);
648
- setConsentSaving(true);
649
-
650
- try {
651
- await apiPost("/api/gdpr/consent", updated);
652
- } catch {
653
- // Revert on error
654
- setConsent(consent);
655
- } finally {
656
- setConsentSaving(false);
657
- }
658
- };
659
-
642
+ settingsContent = settingsContent.replace(" return (", ` const handleConsentChange = async (
643
+ field: keyof ConsentSettings,
644
+ value: boolean,
645
+ ) => {
646
+ const updated = { ...consent, [field]: value };
647
+ setConsent(updated);
648
+ setConsentSaving(true);
649
+
650
+ try {
651
+ await apiPost("/api/gdpr/consent", updated);
652
+ } catch {
653
+ // Revert on error
654
+ setConsent(consent);
655
+ } finally {
656
+ setConsentSaving(false);
657
+ }
658
+ };
659
+
660
660
  return (`);
661
661
  writeFileSync(settingsPath, settingsContent, "utf-8");
662
662
  }
@@ -688,10 +688,10 @@ function installR2Storage(sailDir, projectDir) {
688
688
  mkdirSync(dirname(destUploadPath), { recursive: true });
689
689
  copyFileSync(join(sailDir, "files/frontend/components/ProfilePictureUpload.tsx"), destUploadPath);
690
690
  // Add R2 env var validation
691
- insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` R2_ACCOUNT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_ACCOUNT_ID is required in production").default(""),
692
- R2_ACCESS_KEY_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_ACCESS_KEY_ID is required in production").default(""),
693
- R2_SECRET_ACCESS_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_SECRET_ACCESS_KEY is required in production").default(""),
694
- R2_BUCKET_NAME: z.string().default("avatars"),
691
+ insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` R2_ACCOUNT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_ACCOUNT_ID is required in production").default(""),
692
+ R2_ACCESS_KEY_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_ACCESS_KEY_ID is required in production").default(""),
693
+ R2_SECRET_ACCESS_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_SECRET_ACCESS_KEY is required in production").default(""),
694
+ R2_BUCKET_NAME: z.string().default("avatars"),
695
695
  R2_PUBLIC_URL: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_PUBLIC_URL is required in production").default(""),`);
696
696
  // Add avatar routes to profile.ts
697
697
  const profilePath = join(backendDir, "src/routes/profile.ts");
@@ -703,40 +703,40 @@ function installR2Storage(sailDir, projectDir) {
703
703
  }
704
704
  // Add avatar routes if not present
705
705
  if (!profileContent.includes("/avatar/upload-url")) {
706
- const avatarRoutes = `
707
- // POST /avatar/upload-url — generate presigned upload URL
708
- router.post("/avatar/upload-url", async (req, res) => {
709
- const { fileType } = req.body as { fileType?: string };
710
-
711
- if (!fileType || typeof fileType !== "string") {
712
- res.status(400).json({ error: "fileType is required" });
713
- return;
714
- }
715
-
716
- const result = await generateUploadUrl(req.user!.id, fileType);
717
- res.json(result);
718
- });
719
-
720
- // DELETE /avatar — delete current avatar
721
- router.delete("/avatar", async (req, res) => {
722
- const user = req.user!;
723
-
724
- if (user.image) {
725
- try {
726
- // Extract key from the image URL or stored key
727
- await deleteObject(user.image);
728
- } catch {
729
- // Continue even if R2 deletion fails
730
- }
731
- }
732
-
733
- const [updated] = await db
734
- .update(users)
735
- .set({ image: null, updatedAt: new Date() })
736
- .where(eq(users.id, user.id))
737
- .returning();
738
-
739
- res.json({ user: updated });
706
+ const avatarRoutes = `
707
+ // POST /avatar/upload-url — generate presigned upload URL
708
+ router.post("/avatar/upload-url", async (req, res) => {
709
+ const { fileType } = req.body as { fileType?: string };
710
+
711
+ if (!fileType || typeof fileType !== "string") {
712
+ res.status(400).json({ error: "fileType is required" });
713
+ return;
714
+ }
715
+
716
+ const result = await generateUploadUrl(req.user!.id, fileType);
717
+ res.json(result);
718
+ });
719
+
720
+ // DELETE /avatar — delete current avatar
721
+ router.delete("/avatar", async (req, res) => {
722
+ const user = req.user!;
723
+
724
+ if (user.image) {
725
+ try {
726
+ // Extract key from the image URL or stored key
727
+ await deleteObject(user.image);
728
+ } catch {
729
+ // Continue even if R2 deletion fails
730
+ }
731
+ }
732
+
733
+ const [updated] = await db
734
+ .update(users)
735
+ .set({ image: null, updatedAt: new Date() })
736
+ .where(eq(users.id, user.id))
737
+ .returning();
738
+
739
+ res.json({ user: updated });
740
740
  });`;
741
741
  profileContent = profileContent.replace("export default router;", `${avatarRoutes}\n\nexport default router;`);
742
742
  }
@@ -798,11 +798,11 @@ function installFileUploads(sailDir, projectDir) {
798
798
  insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_IMPORTS]", 'import { filesRouter } from "./routes/files.js";');
799
799
  insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_ROUTES]", 'app.use("/api/files", filesRouter);');
800
800
  // Add env var validation
801
- insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` S3_ENDPOINT: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_ENDPOINT is required in production").default(""),
802
- S3_ACCESS_KEY_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_ACCESS_KEY_ID is required in production").default(""),
803
- S3_SECRET_ACCESS_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_SECRET_ACCESS_KEY is required in production").default(""),
804
- S3_BUCKET_NAME: z.string().default("uploads"),
805
- S3_PUBLIC_URL: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_PUBLIC_URL is required in production").default(""),
801
+ insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` S3_ENDPOINT: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_ENDPOINT is required in production").default(""),
802
+ S3_ACCESS_KEY_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_ACCESS_KEY_ID is required in production").default(""),
803
+ S3_SECRET_ACCESS_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_SECRET_ACCESS_KEY is required in production").default(""),
804
+ S3_BUCKET_NAME: z.string().default("uploads"),
805
+ S3_PUBLIC_URL: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_PUBLIC_URL is required in production").default(""),
806
806
  S3_REGION: z.string().default("auto"),`);
807
807
  // Add frontend route
808
808
  insertAtMarker(join(frontendDir, "src/router.tsx"), "{/* [SAIL_ROUTES] */}", ' <Route path="/files" element={<ProtectedRoute><FilesPage /></ProtectedRoute>} />');