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