@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
@@ -1,756 +1,756 @@
1
- /**
2
- * GDPR/DSGVO Compliance Sail Installer
3
- *
4
- * Adds full GDPR compliance to your keel project:
5
- * consent tracking, data export, account deletion (30-day grace period),
6
- * consent checkboxes on signup, and a privacy policy page.
7
- *
8
- * Usage:
9
- * npx tsx sails/gdpr/install.ts
10
- */
11
-
12
- import {
13
- readFileSync,
14
- writeFileSync,
15
- copyFileSync,
16
- existsSync,
17
- mkdirSync,
18
- } from "node:fs";
19
- import { resolve, dirname, join } from "node:path";
20
- import { execSync } from "node:child_process";
21
- import { randomBytes } from "node:crypto";
22
- import { input, confirm } from "@inquirer/prompts";
23
-
24
- // ---------------------------------------------------------------------------
25
- // Paths
26
- // ---------------------------------------------------------------------------
27
-
28
- const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
29
- const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
30
- const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
31
- const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
32
-
33
- // ---------------------------------------------------------------------------
34
- // Helpers
35
- // ---------------------------------------------------------------------------
36
-
37
- interface SailManifest {
38
- name: string;
39
- displayName: string;
40
- version: string;
41
- requiredEnvVars: { key: string; description: string }[];
42
- dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
43
- }
44
-
45
- function loadManifest(): SailManifest {
46
- return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
47
- }
48
-
49
- function insertAtMarker(filePath: string, marker: string, code: string): void {
50
- if (!existsSync(filePath)) {
51
- console.warn(` Warning: File not found: ${filePath}`);
52
- return;
53
- }
54
- let content = readFileSync(filePath, "utf-8");
55
- if (!content.includes(marker)) {
56
- console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
57
- return;
58
- }
59
- if (content.includes(code.trim())) {
60
- console.log(` Skipped (already present) -> ${filePath}`);
61
- return;
62
- }
63
- content = content.replace(marker, `${code}\n${marker}`);
64
- writeFileSync(filePath, content, "utf-8");
65
- console.log(` Modified -> ${filePath}`);
66
- }
67
-
68
- function insertAfterMarker(filePath: string, marker: string, code: string): void {
69
- if (!existsSync(filePath)) {
70
- console.warn(` Warning: File not found: ${filePath}`);
71
- return;
72
- }
73
- let content = readFileSync(filePath, "utf-8");
74
- if (!content.includes(marker)) {
75
- console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
76
- return;
77
- }
78
- if (content.includes(code.trim())) {
79
- console.log(` Skipped (already present) -> ${filePath}`);
80
- return;
81
- }
82
- content = content.replace(marker, `${marker}\n${code}`);
83
- writeFileSync(filePath, content, "utf-8");
84
- console.log(` Modified -> ${filePath}`);
85
- }
86
-
87
- function copyFile(src: string, dest: string, label: string): void {
88
- mkdirSync(dirname(dest), { recursive: true });
89
- copyFileSync(src, dest);
90
- console.log(` Copied -> ${label}`);
91
- }
92
-
93
- function appendToEnvExample(entries: Record<string, string>): void {
94
- const envPath = join(BACKEND_ROOT, ".env.example");
95
- if (!existsSync(envPath)) return;
96
- let content = readFileSync(envPath, "utf-8");
97
- const lines: string[] = [];
98
- for (const [key, val] of Object.entries(entries)) {
99
- if (!content.includes(key)) lines.push(`${key}=${val}`);
100
- }
101
- if (lines.length > 0) {
102
- content += `\n# GDPR\n${lines.join("\n")}\n`;
103
- writeFileSync(envPath, content, "utf-8");
104
- }
105
- }
106
-
107
- function insertBeforeClosingParen(filePath: string, searchString: string, insertion: string): void {
108
- if (!existsSync(filePath)) {
109
- console.warn(` Warning: File not found: ${filePath}`);
110
- return;
111
- }
112
- let content = readFileSync(filePath, "utf-8");
113
- if (!content.includes(searchString)) {
114
- console.warn(` Warning: "${searchString}" not found in ${filePath}`);
115
- return;
116
- }
117
- if (content.includes(insertion.trim())) {
118
- console.log(` Skipped (already present) -> ${filePath}`);
119
- return;
120
- }
121
- content = content.replace(searchString, insertion + searchString);
122
- writeFileSync(filePath, content, "utf-8");
123
- console.log(` Modified -> ${filePath}`);
124
- }
125
-
126
- // ---------------------------------------------------------------------------
127
- // Schema definitions to insert
128
- // ---------------------------------------------------------------------------
129
-
130
- const SCHEMA_IMPORTS_ADDITION = `import { pgTable, text, boolean, varchar, timestamp } from "drizzle-orm/pg-core";`;
131
-
132
- const CONSENT_RECORDS_SCHEMA = `
133
- export const consentRecords = pgTable("consent_records", {
134
- id: text("id")
135
- .primaryKey()
136
- .$defaultFn(() => crypto.randomUUID()),
137
- userId: text("user_id")
138
- .notNull()
139
- .references(() => users.id, { onDelete: "cascade" }),
140
- consentType: varchar("consent_type", { length: 50 }).notNull(),
141
- granted: boolean("granted").notNull(),
142
- version: varchar("version", { length: 20 }).notNull(),
143
- ipAddress: text("ip_address"),
144
- userAgent: text("user_agent"),
145
- grantedAt: timestamp("granted_at").defaultNow().notNull(),
146
- revokedAt: timestamp("revoked_at"),
147
- });
148
-
149
- export const deletionRequests = pgTable("deletion_requests", {
150
- id: text("id")
151
- .primaryKey()
152
- .$defaultFn(() => crypto.randomUUID()),
153
- userId: text("user_id")
154
- .notNull()
155
- .references(() => users.id, { onDelete: "cascade" }),
156
- status: varchar("status", { length: 20 }).default("pending").notNull(),
157
- reason: text("reason"),
158
- requestedAt: timestamp("requested_at").defaultNow().notNull(),
159
- scheduledDeletionAt: timestamp("scheduled_deletion_at").notNull(),
160
- cancelledAt: timestamp("cancelled_at"),
161
- completedAt: timestamp("completed_at"),
162
- });
163
-
164
- export const consentRecordsRelations = relations(consentRecords, ({ one }) => ({
165
- user: one(users, { fields: [consentRecords.userId], references: [users.id] }),
166
- }));
167
-
168
- export const deletionRequestsRelations = relations(deletionRequests, ({ one }) => ({
169
- user: one(users, { fields: [deletionRequests.userId], references: [users.id] }),
170
- }));
171
- `;
172
-
173
- const USERS_RELATIONS_GDPR = ` consentRecords: many(consentRecords),
174
- deletionRequests: many(deletionRequests),
175
- `;
176
-
177
- // ---------------------------------------------------------------------------
178
- // Main
179
- // ---------------------------------------------------------------------------
180
-
181
- async function main(): Promise<void> {
182
- const manifest = loadManifest();
183
-
184
- // -- Step 1: Welcome message ------------------------------------------------
185
- console.log("\n------------------------------------------------------------");
186
- console.log(` GDPR/DSGVO Compliance Sail Installer (v${manifest.version})`);
187
- console.log("------------------------------------------------------------");
188
- console.log();
189
- console.log(" This sail adds full GDPR compliance to your project:");
190
- console.log(" - Consent tracking (privacy policy, ToS, marketing, analytics)");
191
- console.log(" - Data export (download all personal data as JSON)");
192
- console.log(" - Account deletion with 30-day grace period");
193
- console.log(" - Immediate account deletion (with password confirmation)");
194
- console.log(" - Consent checkboxes on signup form");
195
- console.log(" - Privacy policy page");
196
- console.log(" - Consent management in account settings");
197
- console.log(" - GDPR-compliant email notifications");
198
- console.log();
199
-
200
- const pkgPath = join(PROJECT_ROOT, "package.json");
201
- if (existsSync(pkgPath)) {
202
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
203
- console.log(` Template version: ${pkg.version ?? "unknown"}`);
204
- console.log();
205
- }
206
-
207
- // -- Step 2: EU users check -------------------------------------------------
208
- const servesEU = await confirm({
209
- message: "Do you serve (or plan to serve) users in the EU?",
210
- default: true,
211
- });
212
-
213
- if (!servesEU) {
214
- console.log();
215
- console.log(" Note: GDPR applies if you process data of EU residents,");
216
- console.log(" regardless of where your company is based. Even if you");
217
- console.log(" don't specifically target EU users, GDPR compliance is");
218
- console.log(" recommended as a privacy best practice.");
219
- console.log();
220
-
221
- const continueAnyway = await confirm({
222
- message: "Continue with GDPR sail installation anyway?",
223
- default: true,
224
- });
225
-
226
- if (!continueAnyway) {
227
- console.log("\n Installation cancelled.\n");
228
- process.exit(0);
229
- }
230
- }
231
-
232
- // -- Step 3: Deletion cron secret -------------------------------------------
233
- console.log();
234
- console.log(" The GDPR sail includes a cron endpoint for processing");
235
- console.log(" scheduled account deletions (30-day grace period).");
236
- console.log(" This endpoint requires a secret to prevent unauthorized access.");
237
- console.log();
238
-
239
- const autoGenerate = await confirm({
240
- message: "Auto-generate a secure DELETION_CRON_SECRET?",
241
- default: true,
242
- });
243
-
244
- let cronSecret: string;
245
- if (autoGenerate) {
246
- cronSecret = randomBytes(32).toString("hex");
247
- console.log();
248
- console.log(` Generated: ${cronSecret.slice(0, 16)}...`);
249
- } else {
250
- cronSecret = await input({
251
- message: "DELETION_CRON_SECRET:",
252
- validate: (value) => {
253
- if (!value || value.trim().length === 0) return "Secret is required.";
254
- if (value.length < 16) return "Secret should be at least 16 characters.";
255
- return true;
256
- },
257
- });
258
- }
259
-
260
- // -- Step 4: Cron job explanation -------------------------------------------
261
- console.log();
262
- console.log(" To process scheduled deletions, set up a cron job that calls:");
263
- console.log();
264
- console.log(" POST {BACKEND_URL}/api/gdpr/process-deletions");
265
- console.log(" Header: x-cron-secret: {DELETION_CRON_SECRET}");
266
- console.log();
267
- console.log(" Recommended schedule: once daily (e.g., 2:00 AM).");
268
- console.log(" Services like cron-job.org, Vercel Cron, or Railway Cron work well.");
269
- console.log();
270
-
271
- await confirm({
272
- message: "I understand the cron job requirement. Continue?",
273
- default: true,
274
- });
275
-
276
- // -- Step 5: Summary --------------------------------------------------------
277
- console.log();
278
- console.log(" Summary of changes:");
279
- console.log(" -------------------");
280
- console.log(" Files to copy (backend):");
281
- console.log(" + packages/backend/src/services/gdpr.ts");
282
- console.log(" + packages/backend/src/routes/gdpr.ts");
283
- console.log();
284
- console.log(" Files to copy (frontend):");
285
- console.log(" + packages/frontend/src/components/gdpr/DataExportButton.tsx");
286
- console.log(" + packages/frontend/src/components/gdpr/AccountDeletionRequest.tsx");
287
- console.log(" + packages/frontend/src/components/auth/ConsentCheckboxes.tsx");
288
- console.log(" + packages/frontend/src/pages/PrivacyPolicy.tsx");
289
- console.log();
290
- console.log(" Files to modify:");
291
- console.log(" ~ packages/backend/src/db/schema.ts (add consent_records, deletion_requests tables)");
292
- console.log(" ~ packages/backend/src/index.ts (add GDPR routes)");
293
- console.log(" ~ packages/backend/src/env.ts (add DELETION_CRON_SECRET)");
294
- console.log(" ~ packages/frontend/src/router.tsx (add /privacy-policy route)");
295
- console.log(" ~ packages/frontend/src/components/auth/SignupForm.tsx (add consent checkboxes)");
296
- console.log(" ~ packages/frontend/src/components/profile/AccountSettings.tsx (add GDPR section)");
297
- console.log(" ~ .env.example");
298
- console.log();
299
- console.log(" Environment variables:");
300
- console.log(` DELETION_CRON_SECRET=${cronSecret.slice(0, 16)}...`);
301
- console.log();
302
-
303
- // -- Step 6: Confirm and execute --------------------------------------------
304
- const proceed = await confirm({ message: "Proceed with installation?", default: true });
305
- if (!proceed) { console.log("\n Installation cancelled.\n"); process.exit(0); }
306
-
307
- console.log();
308
- console.log(" Installing...");
309
- console.log();
310
-
311
- // Copy backend files
312
- console.log(" Copying backend files...");
313
- const backendFiles = [
314
- { src: "backend/services/gdpr.ts", dest: "src/services/gdpr.ts" },
315
- { src: "backend/routes/gdpr.ts", dest: "src/routes/gdpr.ts" },
316
- ];
317
- for (const f of backendFiles) {
318
- copyFile(join(SAIL_DIR, "files", f.src), join(BACKEND_ROOT, f.dest), f.dest);
319
- }
320
-
321
- console.log();
322
- console.log(" Copying frontend files...");
323
- const frontendFiles = [
324
- { src: "frontend/components/gdpr/DataExportButton.tsx", dest: "src/components/gdpr/DataExportButton.tsx" },
325
- { src: "frontend/components/gdpr/AccountDeletionRequest.tsx", dest: "src/components/gdpr/AccountDeletionRequest.tsx" },
326
- { src: "frontend/components/auth/ConsentCheckboxes.tsx", dest: "src/components/auth/ConsentCheckboxes.tsx" },
327
- { src: "frontend/pages/PrivacyPolicy.tsx", dest: "src/pages/PrivacyPolicy.tsx" },
328
- ];
329
- for (const f of frontendFiles) {
330
- copyFile(join(SAIL_DIR, "files", f.src), join(FRONTEND_ROOT, f.dest), f.dest);
331
- }
332
-
333
- console.log();
334
- console.log(" Modifying backend files...");
335
-
336
- // Add GDPR schema tables to schema.ts
337
- const schemaPath = join(BACKEND_ROOT, "src/db/schema.ts");
338
- if (existsSync(schemaPath)) {
339
- let schemaContent = readFileSync(schemaPath, "utf-8");
340
-
341
- // Ensure necessary imports are present (varchar may not be imported yet)
342
- if (!schemaContent.includes("varchar")) {
343
- // Add varchar to the drizzle-orm/pg-core import line
344
- schemaContent = schemaContent.replace(
345
- /import\s*\{([^}]*)\}\s*from\s*"drizzle-orm\/pg-core"/,
346
- (match, imports) => {
347
- const trimmed = imports.trim().replace(/,\s*$/, "");
348
- return `import { ${trimmed}, varchar } from "drizzle-orm/pg-core"`;
349
- }
350
- );
351
- }
352
-
353
- // Insert table definitions before the [SAIL_SCHEMA] marker
354
- if (schemaContent.includes("// [SAIL_SCHEMA]") && !schemaContent.includes("consentRecords")) {
355
- schemaContent = schemaContent.replace(
356
- "// [SAIL_SCHEMA]",
357
- CONSENT_RECORDS_SCHEMA + "\n// [SAIL_SCHEMA]"
358
- );
359
- }
360
-
361
- // Add GDPR relations to usersRelations
362
- if (schemaContent.includes("usersRelations") && !schemaContent.includes("consentRecords: many(consentRecords)")) {
363
- schemaContent = schemaContent.replace(
364
- /export const usersRelations = relations\(users, \(\{ many \}\) => \(\{/,
365
- `export const usersRelations = relations(users, ({ many }) => ({\n consentRecords: many(consentRecords),\n deletionRequests: many(deletionRequests),`
366
- );
367
- }
368
-
369
- writeFileSync(schemaPath, schemaContent, "utf-8");
370
- console.log(` Modified -> src/db/schema.ts`);
371
- }
372
-
373
- // Add route import and mount
374
- insertAfterMarker(
375
- join(BACKEND_ROOT, "src/index.ts"),
376
- "// [SAIL_IMPORTS]",
377
- 'import gdprRoutes from "./routes/gdpr.js";'
378
- );
379
- insertAfterMarker(
380
- join(BACKEND_ROOT, "src/index.ts"),
381
- "// [SAIL_ROUTES]",
382
- 'app.use("/api/gdpr", gdprRoutes);'
383
- );
384
-
385
- // Add env var to env.ts
386
- const envPath = join(BACKEND_ROOT, "src/env.ts");
387
- if (existsSync(envPath)) {
388
- let envContent = readFileSync(envPath, "utf-8");
389
- if (!envContent.includes("DELETION_CRON_SECRET")) {
390
- // Insert before the closing of the envSchema object
391
- envContent = envContent.replace(
392
- /}\);(\s*\nconst parsed)/,
393
- `\n // GDPR\n DELETION_CRON_SECRET: z.string().default("dev-cron-secret"),\n});$1`
394
- );
395
- writeFileSync(envPath, envContent, "utf-8");
396
- console.log(` Modified -> src/env.ts`);
397
- }
398
- }
399
-
400
- console.log();
401
- console.log(" Modifying frontend files...");
402
-
403
- // Add privacy policy route to router.tsx
404
- const routerPath = join(FRONTEND_ROOT, "src/router.tsx");
405
- if (existsSync(routerPath)) {
406
- let routerContent = readFileSync(routerPath, "utf-8");
407
-
408
- // Add PrivacyPolicy import
409
- if (!routerContent.includes("PrivacyPolicy")) {
410
- routerContent = routerContent.replace(
411
- "export function AppRouter() {",
412
- 'import PrivacyPolicy from "./pages/PrivacyPolicy";\n\nexport function AppRouter() {'
413
- );
414
- }
415
-
416
- // Add the route before the ProtectedRoute
417
- if (!routerContent.includes("/privacy-policy")) {
418
- routerContent = routerContent.replace(
419
- "<Route element={<ProtectedRoute />}>",
420
- '<Route path="/privacy-policy" element={<PrivacyPolicy />} />\n <Route element={<ProtectedRoute />}>'
421
- );
422
- }
423
-
424
- writeFileSync(routerPath, routerContent, "utf-8");
425
- console.log(` Modified -> src/router.tsx`);
426
- }
427
-
428
- // Modify SignupForm to include ConsentCheckboxes
429
- const signupPath = join(FRONTEND_ROOT, "src/components/auth/SignupForm.tsx");
430
- if (existsSync(signupPath)) {
431
- let signupContent = readFileSync(signupPath, "utf-8");
432
-
433
- if (!signupContent.includes("ConsentCheckboxes")) {
434
- // Add import
435
- signupContent = signupContent.replace(
436
- 'import { useAuth } from "@/hooks/useAuth";',
437
- 'import { useAuth } from "@/hooks/useAuth";\nimport { apiPost } from "@/lib/api";\nimport ConsentCheckboxes, { type ConsentState } from "./ConsentCheckboxes";'
438
- );
439
-
440
- // Add consent state
441
- signupContent = signupContent.replace(
442
- ' const [confirmPassword, setConfirmPassword] = useState("");',
443
- ' const [confirmPassword, setConfirmPassword] = useState("");\n const [consent, setConsent] = useState<ConsentState>({\n privacyPolicy: false,\n termsOfService: false,\n marketingEmails: false,\n analytics: false,\n });'
444
- );
445
-
446
- // Add consent validation before setIsSubmitting
447
- signupContent = signupContent.replace(
448
- " setIsSubmitting(true);\n\n try {\n await signup(email, password, name);\n\n setSuccess(true);",
449
- ` if (!consent.privacyPolicy || !consent.termsOfService) {
450
- setError("You must accept the Privacy Policy and Terms of Service.");
451
- return;
452
- }
453
-
454
- setIsSubmitting(true);
455
-
456
- try {
457
- await signup(email, password, name);
458
-
459
- // Record consent after successful signup
460
- try {
461
- await apiPost("/api/gdpr/consent", {
462
- privacyPolicy: consent.privacyPolicy,
463
- termsOfService: consent.termsOfService,
464
- marketingEmails: consent.marketingEmails,
465
- analytics: consent.analytics,
466
- });
467
- } catch {
468
- // Non-critical: consent recording failure shouldn't block signup
469
- }
470
-
471
- setSuccess(true);`
472
- );
473
-
474
- // Add ConsentCheckboxes component before submit button
475
- signupContent = signupContent.replace(
476
- " <button\n type=\"submit\"",
477
- " <ConsentCheckboxes value={consent} onChange={setConsent} />\n\n <button\n type=\"submit\""
478
- );
479
-
480
- writeFileSync(signupPath, signupContent, "utf-8");
481
- console.log(` Modified -> src/components/auth/SignupForm.tsx`);
482
- }
483
- }
484
-
485
- // Modify AccountSettings to include GDPR section
486
- const settingsPath = join(FRONTEND_ROOT, "src/components/profile/AccountSettings.tsx");
487
- if (existsSync(settingsPath)) {
488
- let settingsContent = readFileSync(settingsPath, "utf-8");
489
-
490
- if (!settingsContent.includes("DataExportButton")) {
491
- // Add imports
492
- settingsContent = settingsContent.replace(
493
- 'import { apiGet } from "@/lib/api";',
494
- 'import { apiGet, apiPost } from "@/lib/api";\nimport DataExportButton from "../gdpr/DataExportButton";\nimport AccountDeletionRequest from "../gdpr/AccountDeletionRequest";'
495
- );
496
-
497
- // Add consent state and types
498
- settingsContent = settingsContent.replace(
499
- "interface Session {",
500
- `interface ConsentSettings {
501
- marketingEmails: boolean;
502
- analytics: boolean;
503
- }
504
-
505
- interface Session {`
506
- );
507
-
508
- // Add consent state
509
- settingsContent = settingsContent.replace(
510
- " const [sessions, setSessions] = useState<Session[]>([]);",
511
- ` const [consent, setConsent] = useState<ConsentSettings>({
512
- marketingEmails: false,
513
- analytics: false,
514
- });
515
- const [sessions, setSessions] = useState<Session[]>([]);
516
- const [consentLoading, setConsentLoading] = useState(true);
517
- const [consentSaving, setConsentSaving] = useState(false);`
518
- );
519
-
520
- // Update loadSettings to include consent
521
- settingsContent = settingsContent.replace(
522
- ` async function loadSettings() {
523
- try {
524
- const sessionsData = await apiGet<Session[]>("/api/auth/sessions");
525
- setSessions(sessionsData);
526
- } catch {
527
- // Settings may not exist yet
528
- }
529
- }`,
530
- ` async function loadSettings() {
531
- try {
532
- const [consentData, sessionsData] = await Promise.all([
533
- apiGet<ConsentSettings>("/api/gdpr/consent"),
534
- apiGet<Session[]>("/api/auth/sessions"),
535
- ]);
536
- setConsent(consentData);
537
- setSessions(sessionsData);
538
- } catch {
539
- // Settings may not exist yet
540
- } finally {
541
- setConsentLoading(false);
542
- }
543
- }`
544
- );
545
-
546
- // Add consent change handler before return
547
- settingsContent = settingsContent.replace(
548
- " return (",
549
- ` const handleConsentChange = async (
550
- field: keyof ConsentSettings,
551
- value: boolean,
552
- ) => {
553
- const updated = { ...consent, [field]: value };
554
- setConsent(updated);
555
- setConsentSaving(true);
556
-
557
- try {
558
- await apiPost("/api/gdpr/consent", updated);
559
- } catch {
560
- // Revert on error
561
- setConsent(consent);
562
- } finally {
563
- setConsentSaving(false);
564
- }
565
- };
566
-
567
- return (`
568
- );
569
-
570
- // Add consent management and GDPR sections after ProfilePage and before Active Sessions
571
- settingsContent = settingsContent.replace(
572
- " {/* Active Sessions */}",
573
- ` {/* Consent Management */}
574
- <div className="rounded-xl border border-keel-gray-800 bg-keel-gray-900 p-6">
575
- <h2 className="mb-4 text-lg font-semibold text-white">
576
- Consent Preferences
577
- </h2>
578
-
579
- {consentLoading ? (
580
- <div className="flex items-center gap-2 py-4">
581
- <div className="h-4 w-4 animate-spin rounded-full border-2 border-keel-gray-800 border-t-keel-blue" />
582
- <span className="text-sm text-keel-gray-400">Loading...</span>
583
- </div>
584
- ) : (
585
- <div className="space-y-4">
586
- <label className="flex items-center justify-between">
587
- <div>
588
- <p className="text-sm font-medium text-keel-gray-100">
589
- Marketing emails
590
- </p>
591
- <p className="text-xs text-keel-gray-400">
592
- Receive product updates and promotional content
593
- </p>
594
- </div>
595
- <button
596
- onClick={() =>
597
- handleConsentChange(
598
- "marketingEmails",
599
- !consent.marketingEmails,
600
- )
601
- }
602
- disabled={consentSaving}
603
- className={\`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 \${
604
- consent.marketingEmails ? "bg-keel-blue" : "bg-keel-gray-800"
605
- } disabled:opacity-50\`}
606
- >
607
- <span
608
- className={\`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200 \${
609
- consent.marketingEmails
610
- ? "translate-x-5"
611
- : "translate-x-0"
612
- }\`}
613
- />
614
- </button>
615
- </label>
616
-
617
- <label className="flex items-center justify-between">
618
- <div>
619
- <p className="text-sm font-medium text-keel-gray-100">
620
- Usage analytics
621
- </p>
622
- <p className="text-xs text-keel-gray-400">
623
- Help us improve by sharing anonymous usage data
624
- </p>
625
- </div>
626
- <button
627
- onClick={() =>
628
- handleConsentChange("analytics", !consent.analytics)
629
- }
630
- disabled={consentSaving}
631
- className={\`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 \${
632
- consent.analytics ? "bg-keel-blue" : "bg-keel-gray-800"
633
- } disabled:opacity-50\`}
634
- >
635
- <span
636
- className={\`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200 \${
637
- consent.analytics ? "translate-x-5" : "translate-x-0"
638
- }\`}
639
- />
640
- </button>
641
- </label>
642
- </div>
643
- )}
644
- </div>
645
-
646
- {/* Active Sessions */}`
647
- );
648
-
649
- // Add GDPR section at the end (before closing </div>)
650
- settingsContent = settingsContent.replace(
651
- " </div>\n );\n}",
652
- `
653
- {/* GDPR Section */}
654
- <div className="rounded-xl border border-keel-gray-800 bg-keel-gray-900 p-6">
655
- <h2 className="mb-4 text-lg font-semibold text-white">
656
- Data & Privacy
657
- </h2>
658
-
659
- <div className="space-y-6">
660
- <div>
661
- <h3 className="text-sm font-medium text-keel-gray-100">
662
- Export your data
663
- </h3>
664
- <p className="mb-3 text-xs text-keel-gray-400">
665
- Download a copy of all your personal data.
666
- </p>
667
- <DataExportButton />
668
- </div>
669
-
670
- <hr className="border-keel-gray-800" />
671
-
672
- <div>
673
- <h3 className="text-sm font-medium text-keel-gray-100">
674
- Delete account
675
- </h3>
676
- <p className="mb-3 text-xs text-keel-gray-400">
677
- Permanently delete your account and all associated data.
678
- </p>
679
- <AccountDeletionRequest />
680
- </div>
681
- </div>
682
- </div>
683
- </div>
684
- );
685
- }`
686
- );
687
-
688
- writeFileSync(settingsPath, settingsContent, "utf-8");
689
- console.log(` Modified -> src/components/profile/AccountSettings.tsx`);
690
- }
691
- }
692
-
693
- console.log();
694
- console.log(" Updating environment files...");
695
- appendToEnvExample({ DELETION_CRON_SECRET: cronSecret });
696
-
697
- const dotEnvPath = join(BACKEND_ROOT, ".env");
698
- if (existsSync(dotEnvPath)) {
699
- let dotEnv = readFileSync(dotEnvPath, "utf-8");
700
- if (!dotEnv.includes("DELETION_CRON_SECRET")) {
701
- dotEnv += `\n# GDPR\nDELETION_CRON_SECRET=${cronSecret}\n`;
702
- writeFileSync(dotEnvPath, dotEnv, "utf-8");
703
- console.log(" Updated .env");
704
- }
705
- }
706
-
707
- // -- Step 7: Generate database migrations -----------------------------------
708
- console.log();
709
- console.log(" Generating database migrations...");
710
- try {
711
- execSync("npx drizzle-kit generate", { cwd: BACKEND_ROOT, stdio: "inherit" });
712
- } catch {
713
- console.warn(" Warning: Could not generate migrations. Run manually: cd packages/backend && npx drizzle-kit generate");
714
- }
715
-
716
- // -- Step 8: Next steps -----------------------------------------------------
717
- console.log();
718
- console.log("------------------------------------------------------------");
719
- console.log(" GDPR/DSGVO Compliance installed successfully!");
720
- console.log("------------------------------------------------------------");
721
- console.log();
722
- console.log(" Next steps:");
723
- console.log();
724
- console.log(" 1. Run database migrations:");
725
- console.log(" npm run db:migrate");
726
- console.log();
727
- console.log(" 2. Set up a cron job for processing deletions:");
728
- console.log(" Schedule: daily (e.g., 2:00 AM)");
729
- console.log(" POST {BACKEND_URL}/api/gdpr/process-deletions");
730
- console.log(" Header: x-cron-secret: {DELETION_CRON_SECRET}");
731
- console.log();
732
- console.log(" 3. Customize the privacy policy:");
733
- console.log(" Edit packages/frontend/src/pages/PrivacyPolicy.tsx");
734
- console.log(" Update contact information and company details");
735
- console.log();
736
- console.log(" 4. Review email templates:");
737
- console.log(" The GDPR sail uses these email functions from @keel/email:");
738
- console.log(" - sendDeletionRequestedEmail");
739
- console.log(" - sendDeletionCompletedEmail");
740
- console.log(" - sendDeletionCancelledEmail");
741
- console.log(" - sendDataExportReadyEmail");
742
- console.log(" - sendConsentUpdatedEmail");
743
- console.log(" Customize them in packages/email/src/ as needed.");
744
- console.log();
745
- console.log(" 5. Start your dev server:");
746
- console.log(" npm run dev");
747
- console.log();
748
- console.log(" GDPR features available at:");
749
- console.log(" - Signup: consent checkboxes on registration form");
750
- console.log(" - Settings: consent toggles, data export, account deletion");
751
- console.log(" - /privacy-policy: public privacy policy page");
752
- console.log(" - /api/gdpr/*: backend API endpoints");
753
- console.log();
754
- }
755
-
756
- main().catch((err) => { console.error("Installation failed:", err); process.exit(1); });
1
+ /**
2
+ * GDPR/DSGVO Compliance Sail Installer
3
+ *
4
+ * Adds full GDPR compliance to your keel project:
5
+ * consent tracking, data export, account deletion (30-day grace period),
6
+ * consent checkboxes on signup, and a privacy policy page.
7
+ *
8
+ * Usage:
9
+ * npx tsx sails/gdpr/install.ts
10
+ */
11
+
12
+ import {
13
+ readFileSync,
14
+ writeFileSync,
15
+ copyFileSync,
16
+ existsSync,
17
+ mkdirSync,
18
+ } from "node:fs";
19
+ import { resolve, dirname, join } from "node:path";
20
+ import { execSync } from "node:child_process";
21
+ import { randomBytes } from "node:crypto";
22
+ import { input, confirm } from "@inquirer/prompts";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Paths
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
29
+ const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
30
+ const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
31
+ const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface SailManifest {
38
+ name: string;
39
+ displayName: string;
40
+ version: string;
41
+ requiredEnvVars: { key: string; description: string }[];
42
+ dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
43
+ }
44
+
45
+ function loadManifest(): SailManifest {
46
+ return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
47
+ }
48
+
49
+ function insertAtMarker(filePath: string, marker: string, code: string): void {
50
+ if (!existsSync(filePath)) {
51
+ console.warn(` Warning: File not found: ${filePath}`);
52
+ return;
53
+ }
54
+ let content = readFileSync(filePath, "utf-8");
55
+ if (!content.includes(marker)) {
56
+ console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
57
+ return;
58
+ }
59
+ if (content.includes(code.trim())) {
60
+ console.log(` Skipped (already present) -> ${filePath}`);
61
+ return;
62
+ }
63
+ content = content.replace(marker, `${code}\n${marker}`);
64
+ writeFileSync(filePath, content, "utf-8");
65
+ console.log(` Modified -> ${filePath}`);
66
+ }
67
+
68
+ function insertAfterMarker(filePath: string, marker: string, code: string): void {
69
+ if (!existsSync(filePath)) {
70
+ console.warn(` Warning: File not found: ${filePath}`);
71
+ return;
72
+ }
73
+ let content = readFileSync(filePath, "utf-8");
74
+ if (!content.includes(marker)) {
75
+ console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
76
+ return;
77
+ }
78
+ if (content.includes(code.trim())) {
79
+ console.log(` Skipped (already present) -> ${filePath}`);
80
+ return;
81
+ }
82
+ content = content.replace(marker, `${marker}\n${code}`);
83
+ writeFileSync(filePath, content, "utf-8");
84
+ console.log(` Modified -> ${filePath}`);
85
+ }
86
+
87
+ function copyFile(src: string, dest: string, label: string): void {
88
+ mkdirSync(dirname(dest), { recursive: true });
89
+ copyFileSync(src, dest);
90
+ console.log(` Copied -> ${label}`);
91
+ }
92
+
93
+ function appendToEnvExample(entries: Record<string, string>): void {
94
+ const envPath = join(BACKEND_ROOT, ".env.example");
95
+ if (!existsSync(envPath)) return;
96
+ let content = readFileSync(envPath, "utf-8");
97
+ const lines: string[] = [];
98
+ for (const [key, val] of Object.entries(entries)) {
99
+ if (!content.includes(key)) lines.push(`${key}=${val}`);
100
+ }
101
+ if (lines.length > 0) {
102
+ content += `\n# GDPR\n${lines.join("\n")}\n`;
103
+ writeFileSync(envPath, content, "utf-8");
104
+ }
105
+ }
106
+
107
+ function insertBeforeClosingParen(filePath: string, searchString: string, insertion: string): void {
108
+ if (!existsSync(filePath)) {
109
+ console.warn(` Warning: File not found: ${filePath}`);
110
+ return;
111
+ }
112
+ let content = readFileSync(filePath, "utf-8");
113
+ if (!content.includes(searchString)) {
114
+ console.warn(` Warning: "${searchString}" not found in ${filePath}`);
115
+ return;
116
+ }
117
+ if (content.includes(insertion.trim())) {
118
+ console.log(` Skipped (already present) -> ${filePath}`);
119
+ return;
120
+ }
121
+ content = content.replace(searchString, insertion + searchString);
122
+ writeFileSync(filePath, content, "utf-8");
123
+ console.log(` Modified -> ${filePath}`);
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Schema definitions to insert
128
+ // ---------------------------------------------------------------------------
129
+
130
+ const SCHEMA_IMPORTS_ADDITION = `import { pgTable, text, boolean, varchar, timestamp } from "drizzle-orm/pg-core";`;
131
+
132
+ const CONSENT_RECORDS_SCHEMA = `
133
+ export const consentRecords = pgTable("consent_records", {
134
+ id: text("id")
135
+ .primaryKey()
136
+ .$defaultFn(() => crypto.randomUUID()),
137
+ userId: text("user_id")
138
+ .notNull()
139
+ .references(() => users.id, { onDelete: "cascade" }),
140
+ consentType: varchar("consent_type", { length: 50 }).notNull(),
141
+ granted: boolean("granted").notNull(),
142
+ version: varchar("version", { length: 20 }).notNull(),
143
+ ipAddress: text("ip_address"),
144
+ userAgent: text("user_agent"),
145
+ grantedAt: timestamp("granted_at").defaultNow().notNull(),
146
+ revokedAt: timestamp("revoked_at"),
147
+ });
148
+
149
+ export const deletionRequests = pgTable("deletion_requests", {
150
+ id: text("id")
151
+ .primaryKey()
152
+ .$defaultFn(() => crypto.randomUUID()),
153
+ userId: text("user_id")
154
+ .notNull()
155
+ .references(() => users.id, { onDelete: "cascade" }),
156
+ status: varchar("status", { length: 20 }).default("pending").notNull(),
157
+ reason: text("reason"),
158
+ requestedAt: timestamp("requested_at").defaultNow().notNull(),
159
+ scheduledDeletionAt: timestamp("scheduled_deletion_at").notNull(),
160
+ cancelledAt: timestamp("cancelled_at"),
161
+ completedAt: timestamp("completed_at"),
162
+ });
163
+
164
+ export const consentRecordsRelations = relations(consentRecords, ({ one }) => ({
165
+ user: one(users, { fields: [consentRecords.userId], references: [users.id] }),
166
+ }));
167
+
168
+ export const deletionRequestsRelations = relations(deletionRequests, ({ one }) => ({
169
+ user: one(users, { fields: [deletionRequests.userId], references: [users.id] }),
170
+ }));
171
+ `;
172
+
173
+ const USERS_RELATIONS_GDPR = ` consentRecords: many(consentRecords),
174
+ deletionRequests: many(deletionRequests),
175
+ `;
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Main
179
+ // ---------------------------------------------------------------------------
180
+
181
+ async function main(): Promise<void> {
182
+ const manifest = loadManifest();
183
+
184
+ // -- Step 1: Welcome message ------------------------------------------------
185
+ console.log("\n------------------------------------------------------------");
186
+ console.log(` GDPR/DSGVO Compliance Sail Installer (v${manifest.version})`);
187
+ console.log("------------------------------------------------------------");
188
+ console.log();
189
+ console.log(" This sail adds full GDPR compliance to your project:");
190
+ console.log(" - Consent tracking (privacy policy, ToS, marketing, analytics)");
191
+ console.log(" - Data export (download all personal data as JSON)");
192
+ console.log(" - Account deletion with 30-day grace period");
193
+ console.log(" - Immediate account deletion (with password confirmation)");
194
+ console.log(" - Consent checkboxes on signup form");
195
+ console.log(" - Privacy policy page");
196
+ console.log(" - Consent management in account settings");
197
+ console.log(" - GDPR-compliant email notifications");
198
+ console.log();
199
+
200
+ const pkgPath = join(PROJECT_ROOT, "package.json");
201
+ if (existsSync(pkgPath)) {
202
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
203
+ console.log(` Template version: ${pkg.version ?? "unknown"}`);
204
+ console.log();
205
+ }
206
+
207
+ // -- Step 2: EU users check -------------------------------------------------
208
+ const servesEU = await confirm({
209
+ message: "Do you serve (or plan to serve) users in the EU?",
210
+ default: true,
211
+ });
212
+
213
+ if (!servesEU) {
214
+ console.log();
215
+ console.log(" Note: GDPR applies if you process data of EU residents,");
216
+ console.log(" regardless of where your company is based. Even if you");
217
+ console.log(" don't specifically target EU users, GDPR compliance is");
218
+ console.log(" recommended as a privacy best practice.");
219
+ console.log();
220
+
221
+ const continueAnyway = await confirm({
222
+ message: "Continue with GDPR sail installation anyway?",
223
+ default: true,
224
+ });
225
+
226
+ if (!continueAnyway) {
227
+ console.log("\n Installation cancelled.\n");
228
+ process.exit(0);
229
+ }
230
+ }
231
+
232
+ // -- Step 3: Deletion cron secret -------------------------------------------
233
+ console.log();
234
+ console.log(" The GDPR sail includes a cron endpoint for processing");
235
+ console.log(" scheduled account deletions (30-day grace period).");
236
+ console.log(" This endpoint requires a secret to prevent unauthorized access.");
237
+ console.log();
238
+
239
+ const autoGenerate = await confirm({
240
+ message: "Auto-generate a secure DELETION_CRON_SECRET?",
241
+ default: true,
242
+ });
243
+
244
+ let cronSecret: string;
245
+ if (autoGenerate) {
246
+ cronSecret = randomBytes(32).toString("hex");
247
+ console.log();
248
+ console.log(` Generated: ${cronSecret.slice(0, 16)}...`);
249
+ } else {
250
+ cronSecret = await input({
251
+ message: "DELETION_CRON_SECRET:",
252
+ validate: (value) => {
253
+ if (!value || value.trim().length === 0) return "Secret is required.";
254
+ if (value.length < 16) return "Secret should be at least 16 characters.";
255
+ return true;
256
+ },
257
+ });
258
+ }
259
+
260
+ // -- Step 4: Cron job explanation -------------------------------------------
261
+ console.log();
262
+ console.log(" To process scheduled deletions, set up a cron job that calls:");
263
+ console.log();
264
+ console.log(" POST {BACKEND_URL}/api/gdpr/process-deletions");
265
+ console.log(" Header: x-cron-secret: {DELETION_CRON_SECRET}");
266
+ console.log();
267
+ console.log(" Recommended schedule: once daily (e.g., 2:00 AM).");
268
+ console.log(" Services like cron-job.org, Vercel Cron, or Railway Cron work well.");
269
+ console.log();
270
+
271
+ await confirm({
272
+ message: "I understand the cron job requirement. Continue?",
273
+ default: true,
274
+ });
275
+
276
+ // -- Step 5: Summary --------------------------------------------------------
277
+ console.log();
278
+ console.log(" Summary of changes:");
279
+ console.log(" -------------------");
280
+ console.log(" Files to copy (backend):");
281
+ console.log(" + packages/backend/src/services/gdpr.ts");
282
+ console.log(" + packages/backend/src/routes/gdpr.ts");
283
+ console.log();
284
+ console.log(" Files to copy (frontend):");
285
+ console.log(" + packages/frontend/src/components/gdpr/DataExportButton.tsx");
286
+ console.log(" + packages/frontend/src/components/gdpr/AccountDeletionRequest.tsx");
287
+ console.log(" + packages/frontend/src/components/auth/ConsentCheckboxes.tsx");
288
+ console.log(" + packages/frontend/src/pages/PrivacyPolicy.tsx");
289
+ console.log();
290
+ console.log(" Files to modify:");
291
+ console.log(" ~ packages/backend/src/db/schema.ts (add consent_records, deletion_requests tables)");
292
+ console.log(" ~ packages/backend/src/index.ts (add GDPR routes)");
293
+ console.log(" ~ packages/backend/src/env.ts (add DELETION_CRON_SECRET)");
294
+ console.log(" ~ packages/frontend/src/router.tsx (add /privacy-policy route)");
295
+ console.log(" ~ packages/frontend/src/components/auth/SignupForm.tsx (add consent checkboxes)");
296
+ console.log(" ~ packages/frontend/src/components/profile/AccountSettings.tsx (add GDPR section)");
297
+ console.log(" ~ .env.example");
298
+ console.log();
299
+ console.log(" Environment variables:");
300
+ console.log(` DELETION_CRON_SECRET=${cronSecret.slice(0, 16)}...`);
301
+ console.log();
302
+
303
+ // -- Step 6: Confirm and execute --------------------------------------------
304
+ const proceed = await confirm({ message: "Proceed with installation?", default: true });
305
+ if (!proceed) { console.log("\n Installation cancelled.\n"); process.exit(0); }
306
+
307
+ console.log();
308
+ console.log(" Installing...");
309
+ console.log();
310
+
311
+ // Copy backend files
312
+ console.log(" Copying backend files...");
313
+ const backendFiles = [
314
+ { src: "backend/services/gdpr.ts", dest: "src/services/gdpr.ts" },
315
+ { src: "backend/routes/gdpr.ts", dest: "src/routes/gdpr.ts" },
316
+ ];
317
+ for (const f of backendFiles) {
318
+ copyFile(join(SAIL_DIR, "files", f.src), join(BACKEND_ROOT, f.dest), f.dest);
319
+ }
320
+
321
+ console.log();
322
+ console.log(" Copying frontend files...");
323
+ const frontendFiles = [
324
+ { src: "frontend/components/gdpr/DataExportButton.tsx", dest: "src/components/gdpr/DataExportButton.tsx" },
325
+ { src: "frontend/components/gdpr/AccountDeletionRequest.tsx", dest: "src/components/gdpr/AccountDeletionRequest.tsx" },
326
+ { src: "frontend/components/auth/ConsentCheckboxes.tsx", dest: "src/components/auth/ConsentCheckboxes.tsx" },
327
+ { src: "frontend/pages/PrivacyPolicy.tsx", dest: "src/pages/PrivacyPolicy.tsx" },
328
+ ];
329
+ for (const f of frontendFiles) {
330
+ copyFile(join(SAIL_DIR, "files", f.src), join(FRONTEND_ROOT, f.dest), f.dest);
331
+ }
332
+
333
+ console.log();
334
+ console.log(" Modifying backend files...");
335
+
336
+ // Add GDPR schema tables to schema.ts
337
+ const schemaPath = join(BACKEND_ROOT, "src/db/schema.ts");
338
+ if (existsSync(schemaPath)) {
339
+ let schemaContent = readFileSync(schemaPath, "utf-8");
340
+
341
+ // Ensure necessary imports are present (varchar may not be imported yet)
342
+ if (!schemaContent.includes("varchar")) {
343
+ // Add varchar to the drizzle-orm/pg-core import line
344
+ schemaContent = schemaContent.replace(
345
+ /import\s*\{([^}]*)\}\s*from\s*"drizzle-orm\/pg-core"/,
346
+ (match, imports) => {
347
+ const trimmed = imports.trim().replace(/,\s*$/, "");
348
+ return `import { ${trimmed}, varchar } from "drizzle-orm/pg-core"`;
349
+ }
350
+ );
351
+ }
352
+
353
+ // Insert table definitions before the [SAIL_SCHEMA] marker
354
+ if (schemaContent.includes("// [SAIL_SCHEMA]") && !schemaContent.includes("consentRecords")) {
355
+ schemaContent = schemaContent.replace(
356
+ "// [SAIL_SCHEMA]",
357
+ CONSENT_RECORDS_SCHEMA + "\n// [SAIL_SCHEMA]"
358
+ );
359
+ }
360
+
361
+ // Add GDPR relations to usersRelations
362
+ if (schemaContent.includes("usersRelations") && !schemaContent.includes("consentRecords: many(consentRecords)")) {
363
+ schemaContent = schemaContent.replace(
364
+ /export const usersRelations = relations\(users, \(\{ many \}\) => \(\{/,
365
+ `export const usersRelations = relations(users, ({ many }) => ({\n consentRecords: many(consentRecords),\n deletionRequests: many(deletionRequests),`
366
+ );
367
+ }
368
+
369
+ writeFileSync(schemaPath, schemaContent, "utf-8");
370
+ console.log(` Modified -> src/db/schema.ts`);
371
+ }
372
+
373
+ // Add route import and mount
374
+ insertAfterMarker(
375
+ join(BACKEND_ROOT, "src/index.ts"),
376
+ "// [SAIL_IMPORTS]",
377
+ 'import gdprRoutes from "./routes/gdpr.js";'
378
+ );
379
+ insertAfterMarker(
380
+ join(BACKEND_ROOT, "src/index.ts"),
381
+ "// [SAIL_ROUTES]",
382
+ 'app.use("/api/gdpr", gdprRoutes);'
383
+ );
384
+
385
+ // Add env var to env.ts
386
+ const envPath = join(BACKEND_ROOT, "src/env.ts");
387
+ if (existsSync(envPath)) {
388
+ let envContent = readFileSync(envPath, "utf-8");
389
+ if (!envContent.includes("DELETION_CRON_SECRET")) {
390
+ // Insert before the closing of the envSchema object
391
+ envContent = envContent.replace(
392
+ /}\);(\s*\nconst parsed)/,
393
+ `\n // GDPR\n DELETION_CRON_SECRET: z.string().default("dev-cron-secret"),\n});$1`
394
+ );
395
+ writeFileSync(envPath, envContent, "utf-8");
396
+ console.log(` Modified -> src/env.ts`);
397
+ }
398
+ }
399
+
400
+ console.log();
401
+ console.log(" Modifying frontend files...");
402
+
403
+ // Add privacy policy route to router.tsx
404
+ const routerPath = join(FRONTEND_ROOT, "src/router.tsx");
405
+ if (existsSync(routerPath)) {
406
+ let routerContent = readFileSync(routerPath, "utf-8");
407
+
408
+ // Add PrivacyPolicy import
409
+ if (!routerContent.includes("PrivacyPolicy")) {
410
+ routerContent = routerContent.replace(
411
+ "export function AppRouter() {",
412
+ 'import PrivacyPolicy from "./pages/PrivacyPolicy";\n\nexport function AppRouter() {'
413
+ );
414
+ }
415
+
416
+ // Add the route before the ProtectedRoute
417
+ if (!routerContent.includes("/privacy-policy")) {
418
+ routerContent = routerContent.replace(
419
+ "<Route element={<ProtectedRoute />}>",
420
+ '<Route path="/privacy-policy" element={<PrivacyPolicy />} />\n <Route element={<ProtectedRoute />}>'
421
+ );
422
+ }
423
+
424
+ writeFileSync(routerPath, routerContent, "utf-8");
425
+ console.log(` Modified -> src/router.tsx`);
426
+ }
427
+
428
+ // Modify SignupForm to include ConsentCheckboxes
429
+ const signupPath = join(FRONTEND_ROOT, "src/components/auth/SignupForm.tsx");
430
+ if (existsSync(signupPath)) {
431
+ let signupContent = readFileSync(signupPath, "utf-8");
432
+
433
+ if (!signupContent.includes("ConsentCheckboxes")) {
434
+ // Add import
435
+ signupContent = signupContent.replace(
436
+ 'import { useAuth } from "@/hooks/useAuth";',
437
+ 'import { useAuth } from "@/hooks/useAuth";\nimport { apiPost } from "@/lib/api";\nimport ConsentCheckboxes, { type ConsentState } from "./ConsentCheckboxes";'
438
+ );
439
+
440
+ // Add consent state
441
+ signupContent = signupContent.replace(
442
+ ' const [confirmPassword, setConfirmPassword] = useState("");',
443
+ ' const [confirmPassword, setConfirmPassword] = useState("");\n const [consent, setConsent] = useState<ConsentState>({\n privacyPolicy: false,\n termsOfService: false,\n marketingEmails: false,\n analytics: false,\n });'
444
+ );
445
+
446
+ // Add consent validation before setIsSubmitting
447
+ signupContent = signupContent.replace(
448
+ " setIsSubmitting(true);\n\n try {\n await signup(email, password, name);\n\n setSuccess(true);",
449
+ ` if (!consent.privacyPolicy || !consent.termsOfService) {
450
+ setError("You must accept the Privacy Policy and Terms of Service.");
451
+ return;
452
+ }
453
+
454
+ setIsSubmitting(true);
455
+
456
+ try {
457
+ await signup(email, password, name);
458
+
459
+ // Record consent after successful signup
460
+ try {
461
+ await apiPost("/api/gdpr/consent", {
462
+ privacyPolicy: consent.privacyPolicy,
463
+ termsOfService: consent.termsOfService,
464
+ marketingEmails: consent.marketingEmails,
465
+ analytics: consent.analytics,
466
+ });
467
+ } catch {
468
+ // Non-critical: consent recording failure shouldn't block signup
469
+ }
470
+
471
+ setSuccess(true);`
472
+ );
473
+
474
+ // Add ConsentCheckboxes component before submit button
475
+ signupContent = signupContent.replace(
476
+ " <button\n type=\"submit\"",
477
+ " <ConsentCheckboxes value={consent} onChange={setConsent} />\n\n <button\n type=\"submit\""
478
+ );
479
+
480
+ writeFileSync(signupPath, signupContent, "utf-8");
481
+ console.log(` Modified -> src/components/auth/SignupForm.tsx`);
482
+ }
483
+ }
484
+
485
+ // Modify AccountSettings to include GDPR section
486
+ const settingsPath = join(FRONTEND_ROOT, "src/components/profile/AccountSettings.tsx");
487
+ if (existsSync(settingsPath)) {
488
+ let settingsContent = readFileSync(settingsPath, "utf-8");
489
+
490
+ if (!settingsContent.includes("DataExportButton")) {
491
+ // Add imports
492
+ settingsContent = settingsContent.replace(
493
+ 'import { apiGet } from "@/lib/api";',
494
+ 'import { apiGet, apiPost } from "@/lib/api";\nimport DataExportButton from "../gdpr/DataExportButton";\nimport AccountDeletionRequest from "../gdpr/AccountDeletionRequest";'
495
+ );
496
+
497
+ // Add consent state and types
498
+ settingsContent = settingsContent.replace(
499
+ "interface Session {",
500
+ `interface ConsentSettings {
501
+ marketingEmails: boolean;
502
+ analytics: boolean;
503
+ }
504
+
505
+ interface Session {`
506
+ );
507
+
508
+ // Add consent state
509
+ settingsContent = settingsContent.replace(
510
+ " const [sessions, setSessions] = useState<Session[]>([]);",
511
+ ` const [consent, setConsent] = useState<ConsentSettings>({
512
+ marketingEmails: false,
513
+ analytics: false,
514
+ });
515
+ const [sessions, setSessions] = useState<Session[]>([]);
516
+ const [consentLoading, setConsentLoading] = useState(true);
517
+ const [consentSaving, setConsentSaving] = useState(false);`
518
+ );
519
+
520
+ // Update loadSettings to include consent
521
+ settingsContent = settingsContent.replace(
522
+ ` async function loadSettings() {
523
+ try {
524
+ const sessionsData = await apiGet<Session[]>("/api/auth/sessions");
525
+ setSessions(sessionsData);
526
+ } catch {
527
+ // Settings may not exist yet
528
+ }
529
+ }`,
530
+ ` async function loadSettings() {
531
+ try {
532
+ const [consentData, sessionsData] = await Promise.all([
533
+ apiGet<ConsentSettings>("/api/gdpr/consent"),
534
+ apiGet<Session[]>("/api/auth/sessions"),
535
+ ]);
536
+ setConsent(consentData);
537
+ setSessions(sessionsData);
538
+ } catch {
539
+ // Settings may not exist yet
540
+ } finally {
541
+ setConsentLoading(false);
542
+ }
543
+ }`
544
+ );
545
+
546
+ // Add consent change handler before return
547
+ settingsContent = settingsContent.replace(
548
+ " return (",
549
+ ` const handleConsentChange = async (
550
+ field: keyof ConsentSettings,
551
+ value: boolean,
552
+ ) => {
553
+ const updated = { ...consent, [field]: value };
554
+ setConsent(updated);
555
+ setConsentSaving(true);
556
+
557
+ try {
558
+ await apiPost("/api/gdpr/consent", updated);
559
+ } catch {
560
+ // Revert on error
561
+ setConsent(consent);
562
+ } finally {
563
+ setConsentSaving(false);
564
+ }
565
+ };
566
+
567
+ return (`
568
+ );
569
+
570
+ // Add consent management and GDPR sections after ProfilePage and before Active Sessions
571
+ settingsContent = settingsContent.replace(
572
+ " {/* Active Sessions */}",
573
+ ` {/* Consent Management */}
574
+ <div className="rounded-xl border border-keel-gray-800 bg-keel-gray-900 p-6">
575
+ <h2 className="mb-4 text-lg font-semibold text-white">
576
+ Consent Preferences
577
+ </h2>
578
+
579
+ {consentLoading ? (
580
+ <div className="flex items-center gap-2 py-4">
581
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-keel-gray-800 border-t-keel-blue" />
582
+ <span className="text-sm text-keel-gray-400">Loading...</span>
583
+ </div>
584
+ ) : (
585
+ <div className="space-y-4">
586
+ <label className="flex items-center justify-between">
587
+ <div>
588
+ <p className="text-sm font-medium text-keel-gray-100">
589
+ Marketing emails
590
+ </p>
591
+ <p className="text-xs text-keel-gray-400">
592
+ Receive product updates and promotional content
593
+ </p>
594
+ </div>
595
+ <button
596
+ onClick={() =>
597
+ handleConsentChange(
598
+ "marketingEmails",
599
+ !consent.marketingEmails,
600
+ )
601
+ }
602
+ disabled={consentSaving}
603
+ className={\`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 \${
604
+ consent.marketingEmails ? "bg-keel-blue" : "bg-keel-gray-800"
605
+ } disabled:opacity-50\`}
606
+ >
607
+ <span
608
+ className={\`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200 \${
609
+ consent.marketingEmails
610
+ ? "translate-x-5"
611
+ : "translate-x-0"
612
+ }\`}
613
+ />
614
+ </button>
615
+ </label>
616
+
617
+ <label className="flex items-center justify-between">
618
+ <div>
619
+ <p className="text-sm font-medium text-keel-gray-100">
620
+ Usage analytics
621
+ </p>
622
+ <p className="text-xs text-keel-gray-400">
623
+ Help us improve by sharing anonymous usage data
624
+ </p>
625
+ </div>
626
+ <button
627
+ onClick={() =>
628
+ handleConsentChange("analytics", !consent.analytics)
629
+ }
630
+ disabled={consentSaving}
631
+ className={\`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 \${
632
+ consent.analytics ? "bg-keel-blue" : "bg-keel-gray-800"
633
+ } disabled:opacity-50\`}
634
+ >
635
+ <span
636
+ className={\`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200 \${
637
+ consent.analytics ? "translate-x-5" : "translate-x-0"
638
+ }\`}
639
+ />
640
+ </button>
641
+ </label>
642
+ </div>
643
+ )}
644
+ </div>
645
+
646
+ {/* Active Sessions */}`
647
+ );
648
+
649
+ // Add GDPR section at the end (before closing </div>)
650
+ settingsContent = settingsContent.replace(
651
+ " </div>\n );\n}",
652
+ `
653
+ {/* GDPR Section */}
654
+ <div className="rounded-xl border border-keel-gray-800 bg-keel-gray-900 p-6">
655
+ <h2 className="mb-4 text-lg font-semibold text-white">
656
+ Data & Privacy
657
+ </h2>
658
+
659
+ <div className="space-y-6">
660
+ <div>
661
+ <h3 className="text-sm font-medium text-keel-gray-100">
662
+ Export your data
663
+ </h3>
664
+ <p className="mb-3 text-xs text-keel-gray-400">
665
+ Download a copy of all your personal data.
666
+ </p>
667
+ <DataExportButton />
668
+ </div>
669
+
670
+ <hr className="border-keel-gray-800" />
671
+
672
+ <div>
673
+ <h3 className="text-sm font-medium text-keel-gray-100">
674
+ Delete account
675
+ </h3>
676
+ <p className="mb-3 text-xs text-keel-gray-400">
677
+ Permanently delete your account and all associated data.
678
+ </p>
679
+ <AccountDeletionRequest />
680
+ </div>
681
+ </div>
682
+ </div>
683
+ </div>
684
+ );
685
+ }`
686
+ );
687
+
688
+ writeFileSync(settingsPath, settingsContent, "utf-8");
689
+ console.log(` Modified -> src/components/profile/AccountSettings.tsx`);
690
+ }
691
+ }
692
+
693
+ console.log();
694
+ console.log(" Updating environment files...");
695
+ appendToEnvExample({ DELETION_CRON_SECRET: cronSecret });
696
+
697
+ const dotEnvPath = join(BACKEND_ROOT, ".env");
698
+ if (existsSync(dotEnvPath)) {
699
+ let dotEnv = readFileSync(dotEnvPath, "utf-8");
700
+ if (!dotEnv.includes("DELETION_CRON_SECRET")) {
701
+ dotEnv += `\n# GDPR\nDELETION_CRON_SECRET=${cronSecret}\n`;
702
+ writeFileSync(dotEnvPath, dotEnv, "utf-8");
703
+ console.log(" Updated .env");
704
+ }
705
+ }
706
+
707
+ // -- Step 7: Generate database migrations -----------------------------------
708
+ console.log();
709
+ console.log(" Generating database migrations...");
710
+ try {
711
+ execSync("npx drizzle-kit generate", { cwd: BACKEND_ROOT, stdio: "inherit" });
712
+ } catch {
713
+ console.warn(" Warning: Could not generate migrations. Run manually: cd packages/backend && npx drizzle-kit generate");
714
+ }
715
+
716
+ // -- Step 8: Next steps -----------------------------------------------------
717
+ console.log();
718
+ console.log("------------------------------------------------------------");
719
+ console.log(" GDPR/DSGVO Compliance installed successfully!");
720
+ console.log("------------------------------------------------------------");
721
+ console.log();
722
+ console.log(" Next steps:");
723
+ console.log();
724
+ console.log(" 1. Run database migrations:");
725
+ console.log(" npm run db:migrate");
726
+ console.log();
727
+ console.log(" 2. Set up a cron job for processing deletions:");
728
+ console.log(" Schedule: daily (e.g., 2:00 AM)");
729
+ console.log(" POST {BACKEND_URL}/api/gdpr/process-deletions");
730
+ console.log(" Header: x-cron-secret: {DELETION_CRON_SECRET}");
731
+ console.log();
732
+ console.log(" 3. Customize the privacy policy:");
733
+ console.log(" Edit packages/frontend/src/pages/PrivacyPolicy.tsx");
734
+ console.log(" Update contact information and company details");
735
+ console.log();
736
+ console.log(" 4. Review email templates:");
737
+ console.log(" The GDPR sail uses these email functions from @keel/email:");
738
+ console.log(" - sendDeletionRequestedEmail");
739
+ console.log(" - sendDeletionCompletedEmail");
740
+ console.log(" - sendDeletionCancelledEmail");
741
+ console.log(" - sendDataExportReadyEmail");
742
+ console.log(" - sendConsentUpdatedEmail");
743
+ console.log(" Customize them in packages/email/src/ as needed.");
744
+ console.log();
745
+ console.log(" 5. Start your dev server:");
746
+ console.log(" npm run dev");
747
+ console.log();
748
+ console.log(" GDPR features available at:");
749
+ console.log(" - Signup: consent checkboxes on registration form");
750
+ console.log(" - Settings: consent toggles, data export, account deletion");
751
+ console.log(" - /privacy-policy: public privacy policy page");
752
+ console.log(" - /api/gdpr/*: backend API endpoints");
753
+ console.log();
754
+ }
755
+
756
+ main().catch((err) => { console.error("Installation failed:", err); process.exit(1); });