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