@codaijs/keel 0.2.3 → 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.
- package/dist/__tests__/sail-installer.test.js +25 -25
- package/dist/sail-installer.js +174 -174
- package/dist/scaffold.js +68 -68
- package/package.json +58 -58
- package/sails/_template/addon.json +20 -20
- package/sails/_template/install.ts +402 -402
- package/sails/admin-dashboard/README.md +117 -117
- package/sails/admin-dashboard/addon.json +28 -28
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -34
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -243
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -40
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -240
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -149
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -173
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -203
- package/sails/admin-dashboard/install.ts +305 -305
- package/sails/analytics/README.md +178 -178
- package/sails/analytics/addon.json +27 -27
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -58
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -64
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -103
- package/sails/analytics/install.ts +297 -297
- package/sails/file-uploads/addon.json +30 -30
- package/sails/file-uploads/files/backend/routes/files.ts +198 -198
- package/sails/file-uploads/files/backend/schema/files.ts +36 -36
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -128
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -248
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -147
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -106
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -118
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -37
- package/sails/file-uploads/install.ts +466 -466
- package/sails/gdpr/README.md +174 -174
- package/sails/gdpr/addon.json +27 -27
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -140
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -293
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -97
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -192
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -75
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -186
- package/sails/gdpr/install.ts +756 -756
- package/sails/google-oauth/README.md +121 -121
- package/sails/google-oauth/addon.json +22 -22
- package/sails/google-oauth/files/GoogleButton.tsx +50 -50
- package/sails/google-oauth/install.ts +252 -252
- package/sails/i18n/README.md +193 -193
- package/sails/i18n/addon.json +30 -30
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -108
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -31
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -32
- package/sails/i18n/files/frontend/locales/de/common.json +44 -44
- package/sails/i18n/files/frontend/locales/en/common.json +44 -44
- package/sails/i18n/install.ts +407 -407
- package/sails/push-notifications/README.md +163 -163
- package/sails/push-notifications/addon.json +31 -31
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -153
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -31
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -117
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -12
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -154
- package/sails/push-notifications/install.ts +384 -384
- package/sails/r2-storage/addon.json +29 -29
- package/sails/r2-storage/files/backend/services/storage.ts +71 -71
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -167
- package/sails/r2-storage/install.ts +412 -412
- package/sails/rate-limiting/addon.json +20 -20
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -104
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -137
- package/sails/rate-limiting/install.ts +300 -300
- package/sails/registry.json +107 -107
- package/sails/stripe/README.md +214 -214
- package/sails/stripe/addon.json +24 -24
- package/sails/stripe/files/backend/routes/stripe.ts +154 -154
- package/sails/stripe/files/backend/schema/stripe.ts +74 -74
- package/sails/stripe/files/backend/services/stripe.ts +224 -224
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -135
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -86
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -116
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -226
- package/sails/stripe/install.ts +378 -378
package/sails/gdpr/install.ts
CHANGED
|
@@ -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); });
|