@codaijs/keel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/__tests__/cli.test.d.ts +2 -0
  2. package/dist/__tests__/cli.test.d.ts.map +1 -0
  3. package/dist/__tests__/cli.test.js +173 -0
  4. package/dist/__tests__/cli.test.js.map +1 -0
  5. package/dist/__tests__/registry.test.d.ts +2 -0
  6. package/dist/__tests__/registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/registry.test.js +86 -0
  8. package/dist/__tests__/registry.test.js.map +1 -0
  9. package/dist/__tests__/sail-installer.test.d.ts +2 -0
  10. package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
  11. package/dist/__tests__/sail-installer.test.js +158 -0
  12. package/dist/__tests__/sail-installer.test.js.map +1 -0
  13. package/dist/create-runner.d.ts +11 -0
  14. package/dist/create-runner.d.ts.map +1 -0
  15. package/dist/create-runner.js +63 -0
  16. package/dist/create-runner.js.map +1 -0
  17. package/dist/create.d.ts +10 -0
  18. package/dist/create.d.ts.map +1 -0
  19. package/dist/create.js +15 -0
  20. package/dist/create.js.map +1 -0
  21. package/dist/manage.d.ts +24 -0
  22. package/dist/manage.d.ts.map +1 -0
  23. package/dist/manage.js +1461 -0
  24. package/dist/manage.js.map +1 -0
  25. package/dist/prompts.d.ts +36 -0
  26. package/dist/prompts.d.ts.map +1 -0
  27. package/dist/prompts.js +208 -0
  28. package/dist/prompts.js.map +1 -0
  29. package/dist/sail-installer.d.ts +37 -0
  30. package/dist/sail-installer.d.ts.map +1 -0
  31. package/dist/sail-installer.js +935 -0
  32. package/dist/sail-installer.js.map +1 -0
  33. package/dist/scaffold.d.ts +10 -0
  34. package/dist/scaffold.d.ts.map +1 -0
  35. package/dist/scaffold.js +297 -0
  36. package/dist/scaffold.js.map +1 -0
  37. package/package.json +57 -0
  38. package/sails/_template/addon.json +20 -0
  39. package/sails/_template/install.ts +402 -0
  40. package/sails/admin-dashboard/README.md +117 -0
  41. package/sails/admin-dashboard/addon.json +28 -0
  42. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
  43. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
  44. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
  45. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
  46. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
  47. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
  48. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
  49. package/sails/admin-dashboard/install.ts +305 -0
  50. package/sails/analytics/README.md +178 -0
  51. package/sails/analytics/addon.json +27 -0
  52. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
  53. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
  54. package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
  55. package/sails/analytics/install.ts +297 -0
  56. package/sails/file-uploads/README.md +191 -0
  57. package/sails/file-uploads/addon.json +30 -0
  58. package/sails/file-uploads/files/backend/routes/files.ts +198 -0
  59. package/sails/file-uploads/files/backend/schema/files.ts +36 -0
  60. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
  61. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
  62. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
  63. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
  64. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
  65. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
  66. package/sails/file-uploads/install.ts +466 -0
  67. package/sails/gdpr/README.md +174 -0
  68. package/sails/gdpr/addon.json +27 -0
  69. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
  70. package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
  71. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
  72. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
  73. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
  74. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
  75. package/sails/gdpr/install.ts +756 -0
  76. package/sails/google-oauth/README.md +121 -0
  77. package/sails/google-oauth/addon.json +22 -0
  78. package/sails/google-oauth/files/GoogleButton.tsx +50 -0
  79. package/sails/google-oauth/install.ts +252 -0
  80. package/sails/i18n/README.md +193 -0
  81. package/sails/i18n/addon.json +30 -0
  82. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
  83. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
  84. package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
  85. package/sails/i18n/files/frontend/locales/de/common.json +44 -0
  86. package/sails/i18n/files/frontend/locales/en/common.json +44 -0
  87. package/sails/i18n/install.ts +407 -0
  88. package/sails/push-notifications/README.md +163 -0
  89. package/sails/push-notifications/addon.json +31 -0
  90. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
  91. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
  92. package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
  93. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
  94. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
  95. package/sails/push-notifications/install.ts +384 -0
  96. package/sails/r2-storage/README.md +101 -0
  97. package/sails/r2-storage/addon.json +29 -0
  98. package/sails/r2-storage/files/backend/services/storage.ts +71 -0
  99. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
  100. package/sails/r2-storage/install.ts +412 -0
  101. package/sails/rate-limiting/README.md +145 -0
  102. package/sails/rate-limiting/addon.json +20 -0
  103. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
  104. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
  105. package/sails/rate-limiting/install.ts +300 -0
  106. package/sails/registry.json +107 -0
  107. package/sails/stripe/README.md +214 -0
  108. package/sails/stripe/addon.json +24 -0
  109. package/sails/stripe/files/backend/routes/stripe.ts +154 -0
  110. package/sails/stripe/files/backend/schema/stripe.ts +74 -0
  111. package/sails/stripe/files/backend/services/stripe.ts +224 -0
  112. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
  113. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
  114. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
  115. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
  116. package/sails/stripe/install.ts +378 -0
@@ -0,0 +1,140 @@
1
+ import { Router, type Request, type Response } from "express";
2
+ import { eq, and } from "drizzle-orm";
3
+ import { verifyPassword } from "better-auth/crypto";
4
+ import { consentInputSchema, CONSENT_TYPES } from "@keel/shared";
5
+ import { requireAuth } from "../middleware/auth.js";
6
+ import { env } from "../env.js";
7
+ import { db } from "../db/index.js";
8
+ import { accounts } from "../db/schema/index.js";
9
+ import {
10
+ exportUserData,
11
+ requestDeletion,
12
+ cancelDeletion,
13
+ processPendingDeletions,
14
+ recordConsent,
15
+ revokeConsent,
16
+ getUserConsents,
17
+ immediatelyDeleteUser,
18
+ } from "../services/gdpr.js";
19
+
20
+ const router = Router();
21
+
22
+ // POST /process-deletions — internal endpoint for cron job
23
+ router.post("/process-deletions", async (req: Request, res: Response) => {
24
+ const secret = req.headers["x-cron-secret"];
25
+ if (secret !== env.DELETION_CRON_SECRET) {
26
+ res.status(403).json({ error: "Forbidden" });
27
+ return;
28
+ }
29
+
30
+ const results = await processPendingDeletions();
31
+ res.json({ results });
32
+ });
33
+
34
+ // All routes below require authentication
35
+ router.use(requireAuth);
36
+
37
+ // GET /export — export all user data
38
+ router.get("/export", async (req: Request, res: Response) => {
39
+ const data = await exportUserData(req.user!.id);
40
+ res.json(data);
41
+ });
42
+
43
+ // POST /deletion — request account deletion
44
+ router.post("/deletion", async (req: Request, res: Response) => {
45
+ const { reason } = req.body as { reason?: string };
46
+ const request = await requestDeletion(req.user!.id, reason);
47
+ res.json({ deletionRequest: request });
48
+ });
49
+
50
+ // POST /deletion/cancel — cancel pending deletion
51
+ router.post("/deletion/cancel", async (req: Request, res: Response) => {
52
+ const result = await cancelDeletion(req.user!.id);
53
+ if (!result) {
54
+ res.status(404).json({ error: "No pending deletion request found" });
55
+ return;
56
+ }
57
+ res.json({ deletionRequest: result });
58
+ });
59
+
60
+ // DELETE /account — immediately and permanently delete user account (no grace period)
61
+ router.delete("/account", async (req: Request, res: Response) => {
62
+ const { password } = req.body as { password?: string };
63
+
64
+ if (!password) {
65
+ res.status(400).json({ error: "Password confirmation is required" });
66
+ return;
67
+ }
68
+
69
+ // Look up the credential account and verify the password hash directly
70
+ // (avoids creating a new session just to check the password)
71
+ const account = await db.query.accounts.findFirst({
72
+ where: and(
73
+ eq(accounts.userId, req.user!.id),
74
+ eq(accounts.providerId, "credential"),
75
+ ),
76
+ });
77
+
78
+ if (!account?.password) {
79
+ res.status(401).json({ error: "Invalid password" });
80
+ return;
81
+ }
82
+
83
+ const valid = await verifyPassword({
84
+ hash: account.password,
85
+ password,
86
+ });
87
+
88
+ if (!valid) {
89
+ res.status(401).json({ error: "Invalid password" });
90
+ return;
91
+ }
92
+
93
+ const result = await immediatelyDeleteUser(req.user!.id);
94
+ if (!result) {
95
+ res.status(404).json({ error: "User not found" });
96
+ return;
97
+ }
98
+
99
+ res.json({ message: "Account has been permanently deleted" });
100
+ });
101
+
102
+ // GET /consents — get all user consents
103
+ router.get("/consents", async (req: Request, res: Response) => {
104
+ const consents = await getUserConsents(req.user!.id);
105
+ res.json({ consents });
106
+ });
107
+
108
+ // POST /consents — record a new consent
109
+ router.post("/consents", async (req: Request, res: Response) => {
110
+ const parsed = consentInputSchema.safeParse(req.body);
111
+ if (!parsed.success) {
112
+ res.status(400).json({ error: parsed.error.flatten() });
113
+ return;
114
+ }
115
+
116
+ const ip = req.ip ?? undefined;
117
+ const userAgent = req.headers["user-agent"] ?? undefined;
118
+
119
+ const record = await recordConsent(req.user!.id, parsed.data, ip, userAgent);
120
+ res.status(201).json({ consent: record });
121
+ });
122
+
123
+ // DELETE /consents/:consentType — revoke a specific consent
124
+ router.delete("/consents/:consentType", async (req: Request, res: Response) => {
125
+ const { consentType } = req.params;
126
+
127
+ if (!consentType || !(CONSENT_TYPES as readonly string[]).includes(consentType)) {
128
+ res.status(400).json({ error: `Invalid consentType. Allowed values: ${CONSENT_TYPES.join(", ")}` });
129
+ return;
130
+ }
131
+
132
+ const result = await revokeConsent(req.user!.id, consentType);
133
+ if (!result) {
134
+ res.status(404).json({ error: "No active consent found for this type" });
135
+ return;
136
+ }
137
+ res.json({ consent: result });
138
+ });
139
+
140
+ export default router;
@@ -0,0 +1,293 @@
1
+ import { eq, and, isNull, lte } from "drizzle-orm";
2
+ import { db } from "../db/index.js";
3
+ import { env } from "../env.js";
4
+ import {
5
+ users,
6
+ sessions,
7
+ accounts,
8
+ consentRecords,
9
+ deletionRequests,
10
+ } from "../db/schema/index.js";
11
+ import {
12
+ sendDeletionRequestedEmail,
13
+ sendDeletionCompletedEmail,
14
+ sendDeletionCancelledEmail,
15
+ sendDataExportReadyEmail,
16
+ sendConsentUpdatedEmail,
17
+ } from "../auth/email.js";
18
+
19
+ export async function exportUserData(userId: string) {
20
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
21
+ const userSessions = await db
22
+ .select()
23
+ .from(sessions)
24
+ .where(eq(sessions.userId, userId));
25
+ const userAccounts = await db
26
+ .select({
27
+ id: accounts.id,
28
+ accountId: accounts.accountId,
29
+ providerId: accounts.providerId,
30
+ scope: accounts.scope,
31
+ createdAt: accounts.createdAt,
32
+ })
33
+ .from(accounts)
34
+ .where(eq(accounts.userId, userId));
35
+ const userConsents = await db
36
+ .select()
37
+ .from(consentRecords)
38
+ .where(eq(consentRecords.userId, userId));
39
+
40
+ return {
41
+ profile: user
42
+ ? {
43
+ id: user.id,
44
+ name: user.name,
45
+ email: user.email,
46
+ emailVerified: user.emailVerified,
47
+ image: user.image,
48
+ createdAt: user.createdAt,
49
+ updatedAt: user.updatedAt,
50
+ }
51
+ : null,
52
+ sessions: userSessions.map((s) => ({
53
+ id: s.id,
54
+ ipAddress: s.ipAddress,
55
+ userAgent: s.userAgent,
56
+ createdAt: s.createdAt,
57
+ expiresAt: s.expiresAt,
58
+ })),
59
+ accounts: userAccounts,
60
+ consents: userConsents,
61
+ exportedAt: new Date().toISOString(),
62
+ };
63
+ }
64
+
65
+ export async function requestDeletion(userId: string, reason?: string) {
66
+ // Check for an existing pending deletion request
67
+ const [existing] = await db
68
+ .select()
69
+ .from(deletionRequests)
70
+ .where(
71
+ and(
72
+ eq(deletionRequests.userId, userId),
73
+ eq(deletionRequests.status, "pending"),
74
+ ),
75
+ );
76
+
77
+ if (existing) {
78
+ return existing;
79
+ }
80
+
81
+ const scheduledDeletionAt = new Date();
82
+ scheduledDeletionAt.setDate(scheduledDeletionAt.getDate() + 30);
83
+
84
+ const [request] = await db
85
+ .insert(deletionRequests)
86
+ .values({
87
+ userId,
88
+ reason: reason ?? null,
89
+ scheduledDeletionAt,
90
+ })
91
+ .returning();
92
+
93
+ // Send deletion requested email
94
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
95
+ if (user) {
96
+ const formattedDate = scheduledDeletionAt.toLocaleDateString("en-US", {
97
+ year: "numeric",
98
+ month: "long",
99
+ day: "numeric",
100
+ });
101
+ const cancelUrl = `${env.FRONTEND_URL}/settings/cancel-deletion?requestId=${request.id}`;
102
+ await sendDeletionRequestedEmail(
103
+ user.email,
104
+ user.name,
105
+ formattedDate,
106
+ cancelUrl,
107
+ );
108
+ }
109
+
110
+ return request;
111
+ }
112
+
113
+ export async function cancelDeletion(userId: string) {
114
+ const [updated] = await db
115
+ .update(deletionRequests)
116
+ .set({
117
+ status: "cancelled",
118
+ cancelledAt: new Date(),
119
+ })
120
+ .where(
121
+ and(
122
+ eq(deletionRequests.userId, userId),
123
+ eq(deletionRequests.status, "pending"),
124
+ ),
125
+ )
126
+ .returning();
127
+
128
+ if (!updated) return null;
129
+
130
+ // Send deletion cancelled email
131
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
132
+ if (user) {
133
+ await sendDeletionCancelledEmail(
134
+ user.email,
135
+ user.name,
136
+ `${env.FRONTEND_URL}/dashboard`,
137
+ );
138
+ }
139
+
140
+ return updated;
141
+ }
142
+
143
+ export async function processPendingDeletions() {
144
+ const now = new Date();
145
+
146
+ const expiredRequests = await db
147
+ .select()
148
+ .from(deletionRequests)
149
+ .where(
150
+ and(
151
+ eq(deletionRequests.status, "pending"),
152
+ lte(deletionRequests.scheduledDeletionAt, now),
153
+ ),
154
+ );
155
+
156
+ const results: Array<{ userId: string; success: boolean; error?: string }> =
157
+ [];
158
+
159
+ for (const request of expiredRequests) {
160
+ try {
161
+ // Fetch user info before deletion so we can send the email
162
+ const [user] = await db
163
+ .select()
164
+ .from(users)
165
+ .where(eq(users.id, request.userId));
166
+
167
+ // Delete user data (cascades handle related records)
168
+ await db.delete(users).where(eq(users.id, request.userId));
169
+
170
+ // Mark deletion as completed
171
+ await db
172
+ .update(deletionRequests)
173
+ .set({
174
+ status: "completed",
175
+ completedAt: new Date(),
176
+ })
177
+ .where(eq(deletionRequests.id, request.id));
178
+
179
+ // Send deletion completed email
180
+ if (user) {
181
+ await sendDeletionCompletedEmail(user.email, user.name);
182
+ }
183
+
184
+ results.push({ userId: request.userId, success: true });
185
+ } catch (error) {
186
+ const message =
187
+ error instanceof Error ? error.message : "Unknown error";
188
+ results.push({ userId: request.userId, success: false, error: message });
189
+ }
190
+ }
191
+
192
+ return results;
193
+ }
194
+
195
+ export async function sendExportReadyNotification(
196
+ userId: string,
197
+ downloadUrl: string,
198
+ expiresIn: string,
199
+ ) {
200
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
201
+ if (user) {
202
+ await sendDataExportReadyEmail(user.email, user.name, downloadUrl, expiresIn);
203
+ }
204
+ }
205
+
206
+ export async function recordConsent(
207
+ userId: string,
208
+ input: { consentType: string; granted: boolean; version: string },
209
+ ip?: string,
210
+ userAgent?: string,
211
+ ) {
212
+ const [record] = await db
213
+ .insert(consentRecords)
214
+ .values({
215
+ userId,
216
+ consentType: input.consentType,
217
+ granted: input.granted,
218
+ version: input.version,
219
+ ipAddress: ip ?? null,
220
+ userAgent: userAgent ?? null,
221
+ })
222
+ .returning();
223
+
224
+ // Send consent updated email
225
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
226
+ if (user) {
227
+ await sendConsentUpdatedEmail(user.email, user.name, [
228
+ {
229
+ type: input.consentType,
230
+ action: input.granted ? "granted" : "revoked",
231
+ },
232
+ ]);
233
+ }
234
+
235
+ return record;
236
+ }
237
+
238
+ export async function revokeConsent(userId: string, consentType: string) {
239
+ const [updated] = await db
240
+ .update(consentRecords)
241
+ .set({ revokedAt: new Date() })
242
+ .where(
243
+ and(
244
+ eq(consentRecords.userId, userId),
245
+ eq(consentRecords.consentType, consentType),
246
+ isNull(consentRecords.revokedAt),
247
+ ),
248
+ )
249
+ .returning();
250
+
251
+ if (!updated) return null;
252
+
253
+ // Send consent updated email
254
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
255
+ if (user) {
256
+ await sendConsentUpdatedEmail(user.email, user.name, [
257
+ { type: consentType, action: "revoked" },
258
+ ]);
259
+ }
260
+
261
+ return updated;
262
+ }
263
+
264
+ export async function getUserConsents(userId: string) {
265
+ return db
266
+ .select()
267
+ .from(consentRecords)
268
+ .where(
269
+ and(
270
+ eq(consentRecords.userId, userId),
271
+ isNull(consentRecords.revokedAt),
272
+ ),
273
+ );
274
+ }
275
+
276
+ export async function immediatelyDeleteUser(userId: string) {
277
+ // Fetch user info before deletion
278
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
279
+ if (!user) {
280
+ return null;
281
+ }
282
+
283
+ const email = user.email;
284
+ const name = user.name;
285
+
286
+ // Delete user data (cascades handle related records)
287
+ await db.delete(users).where(eq(users.id, userId));
288
+
289
+ // Send deletion completed email
290
+ await sendDeletionCompletedEmail(email, name);
291
+
292
+ return { userId, email };
293
+ }
@@ -0,0 +1,97 @@
1
+ import { Link } from "react-router";
2
+
3
+ export interface ConsentState {
4
+ privacyPolicy: boolean;
5
+ termsOfService: boolean;
6
+ marketingEmails: boolean;
7
+ analytics: boolean;
8
+ }
9
+
10
+ interface ConsentCheckboxesProps {
11
+ value: ConsentState;
12
+ onChange: (value: ConsentState) => void;
13
+ }
14
+
15
+ export default function ConsentCheckboxes({
16
+ value,
17
+ onChange,
18
+ }: ConsentCheckboxesProps) {
19
+ const update = (field: keyof ConsentState, checked: boolean) => {
20
+ onChange({ ...value, [field]: checked });
21
+ };
22
+
23
+ return (
24
+ <div className="space-y-3">
25
+ <p className="text-xs font-medium text-keel-gray-400 uppercase tracking-wide">
26
+ Consent
27
+ </p>
28
+
29
+ <label className="flex items-start gap-3 cursor-pointer">
30
+ <input
31
+ type="checkbox"
32
+ checked={value.privacyPolicy}
33
+ onChange={(e) => update("privacyPolicy", e.target.checked)}
34
+ className="mt-0.5 h-4 w-4 rounded border-keel-gray-800 bg-keel-gray-900 text-keel-blue focus:ring-keel-blue"
35
+ />
36
+ <span className="text-sm text-keel-gray-100">
37
+ I have read and accept the{" "}
38
+ <Link
39
+ to="/privacy-policy"
40
+ target="_blank"
41
+ className="font-medium text-keel-blue hover:text-keel-blue/80"
42
+ >
43
+ Privacy Policy
44
+ </Link>
45
+ <span className="text-red-400"> *</span>
46
+ </span>
47
+ </label>
48
+
49
+ <label className="flex items-start gap-3 cursor-pointer">
50
+ <input
51
+ type="checkbox"
52
+ checked={value.termsOfService}
53
+ onChange={(e) => update("termsOfService", e.target.checked)}
54
+ className="mt-0.5 h-4 w-4 rounded border-keel-gray-800 bg-keel-gray-900 text-keel-blue focus:ring-keel-blue"
55
+ />
56
+ <span className="text-sm text-keel-gray-100">
57
+ I agree to the{" "}
58
+ <a
59
+ href="#"
60
+ target="_blank"
61
+ rel="noopener noreferrer"
62
+ className="font-medium text-keel-blue hover:text-keel-blue/80"
63
+ >
64
+ Terms of Service
65
+ </a>
66
+ <span className="text-red-400"> *</span>
67
+ </span>
68
+ </label>
69
+
70
+ <label className="flex items-start gap-3 cursor-pointer">
71
+ <input
72
+ type="checkbox"
73
+ checked={value.marketingEmails}
74
+ onChange={(e) => update("marketingEmails", e.target.checked)}
75
+ className="mt-0.5 h-4 w-4 rounded border-keel-gray-800 bg-keel-gray-900 text-keel-blue focus:ring-keel-blue"
76
+ />
77
+ <span className="text-sm text-keel-gray-400">
78
+ I would like to receive product updates and marketing emails
79
+ <span className="ml-1 text-xs text-keel-gray-400/60">(optional)</span>
80
+ </span>
81
+ </label>
82
+
83
+ <label className="flex items-start gap-3 cursor-pointer">
84
+ <input
85
+ type="checkbox"
86
+ checked={value.analytics}
87
+ onChange={(e) => update("analytics", e.target.checked)}
88
+ className="mt-0.5 h-4 w-4 rounded border-keel-gray-800 bg-keel-gray-900 text-keel-blue focus:ring-keel-blue"
89
+ />
90
+ <span className="text-sm text-keel-gray-400">
91
+ I consent to anonymous usage analytics to help improve the service
92
+ <span className="ml-1 text-xs text-keel-gray-400/60">(optional)</span>
93
+ </span>
94
+ </label>
95
+ </div>
96
+ );
97
+ }