diff --git a/src/lib/types/supabase.d.ts b/src/lib/types/supabase.d.ts index d774c18..199b7cd 100644 --- a/src/lib/types/supabase.d.ts +++ b/src/lib/types/supabase.d.ts @@ -34,6 +34,32 @@ export type Database = { } public: { Tables: { + account_deletion_requests: { + Row: { + requested_at: string + token: string + user_id: string + } + Insert: { + requested_at?: string + token: string + user_id: string + } + Update: { + requested_at?: string + token?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "account_deletion_requests_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } credit_transactions: { Row: { created_at: string diff --git a/src/lib/utils/supabase/admin.ts b/src/lib/utils/supabase/admin.ts index 4052695..8e324fd 100644 --- a/src/lib/utils/supabase/admin.ts +++ b/src/lib/utils/supabase/admin.ts @@ -480,6 +480,55 @@ const getProductById = async (productId: string) => { return product as Product; }; +const requestAccountDeletion = async (userId: string, userEmail: string, baseUrl: string) => { + const token = crypto.randomUUID(); + + const { error: insertError } = await supabaseAdmin.from('account_deletion_requests').upsert({ + user_id: userId, + token, + requested_at: new Date().toISOString() + }); + + if (insertError) throw new Error(`Account deletion request failed: ${insertError.message}`); + + const deletionLink = `${baseUrl}/api/delete-account?token=${token}`; + + // Send deletion email using Brevo + // try { + // await brevoClient.sendTransactionalEmail({ + // to: [{ email: userEmail }], + // subject: 'Confirm Account Deletion', + // htmlContent: `

Click here to confirm your account deletion.

` + // }); + // console.log('Deletion email sent successfully.'); + // } catch (emailError) { + // console.error('Failed to send deletion email:', emailError); + // throw new Error('Failed to send confirmation email.'); + // } + console.log('Deletion email would be sent successfully.', deletionLink); +}; + +const deleteAccount = async (token: string) => { + // Verify the token + const { data: deletionRequest, error } = await supabaseAdmin + .from('account_deletion_requests') + .select('user_id') + .eq('token', token) + .single(); + + if (error || !deletionRequest) { + return new Response('Invalid or expired token', { status: 400 }); + } + + // N.B. The SQL here will either cascade delete or nullify the foreign key constraints depending on the table, to enable anonymisation + const { error: deletionError } = await supabaseAdmin.auth.admin.deleteUser( + deletionRequest.user_id + ); + if (deletionError) { + throw new Error(`Failed to delete user: ${deletionError.message}`); + } +}; + export { upsertProductRecord, upsertPriceRecord, @@ -495,5 +544,7 @@ export { getActiveProductsWithPrices, getProductById, upsertCustomerToSupabase, - getStripeCustomerId + getStripeCustomerId, + requestAccountDeletion, + deleteAccount }; diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index d839775..9e420a9 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -1,5 +1,6 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; +import { requestAccountDeletion } from '$lib/utils/supabase/admin'; export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { const { session } = await safeGetSession(); @@ -18,6 +19,16 @@ export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession }; export const actions: Actions = { + delete: async ({ url, locals: { safeGetSession } }) => { + const { session } = await safeGetSession(); + const baseUrl = url.origin; + if (session) { + await requestAccountDeletion(session.user.id, session.user.email!, baseUrl); + return {}; + } else { + redirect(303, '/'); + } + }, update: async ({ request, locals: { supabase, safeGetSession } }) => { const formData = await request.formData(); const name = formData.get('name') as string; @@ -27,20 +38,23 @@ export const actions: Actions = { if (!session) { return fail(401, { name }); } - const { error } = await supabase.from('users').update({ - name: name, - }).eq('id', session?.user.id); + const { error } = await supabase + .from('users') + .update({ + name: name + }) + .eq('id', session?.user.id); console.error(error); if (error) { return fail(500, { - name, + name }); } return { - name, + name }; }, signout: async ({ locals: { supabase, safeGetSession } }) => { diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index d133724..51ba98e 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -26,6 +26,16 @@ update(); }; }; + + const handleAccountDeletion: SubmitFunction = () => { + loading = true; + if (session.user && session.user.email) { + alert('Check your email to confirm account deletion.'); + } + return async () => { + loading = false; + }; + };
@@ -56,4 +66,10 @@
+ +
+
+ +
+
diff --git a/src/routes/api/delete-account/+server.ts b/src/routes/api/delete-account/+server.ts new file mode 100644 index 0000000..0bfac7b --- /dev/null +++ b/src/routes/api/delete-account/+server.ts @@ -0,0 +1,18 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { deleteAccount } from '$lib/utils/supabase/admin'; + +export const GET: RequestHandler = async ({ url }) => { + const token = url.searchParams.get('token'); + if (!token) { + return new Response('Invalid token', { status: 400 }); + } + + try { + await deleteAccount(token); + } catch (error) { + console.error(error); + return new Response('Failed to delete account', { status: 500 }); + } + + return new Response('Account deleted successfully', { status: 200 }); +}; diff --git a/supabase/migrations/20241029193900_account_deletion.sql b/supabase/migrations/20241029193900_account_deletion.sql new file mode 100644 index 0000000..bd2290d --- /dev/null +++ b/supabase/migrations/20241029193900_account_deletion.sql @@ -0,0 +1,46 @@ +create table account_deletion_requests ( + user_id uuid references auth.users not null primary key, + token text not null, + requested_at timestamp with time zone default now() not null +); + +alter table account_deletion_requests enable row level security; +create policy "Can view own deletion request data" on account_deletion_requests + for select using (auth.uid() = user_id); +create policy "Can insert own deletion request data" on account_deletion_requests + for insert with check (auth.uid() = user_id); + +ALTER TABLE customers +DROP CONSTRAINT IF EXISTS customers_id_fkey; +ALTER TABLE customers +ADD CONSTRAINT customers_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE; + +ALTER TABLE subscriptions +DROP CONSTRAINT IF EXISTS subscriptions_user_id_fkey; +ALTER TABLE subscriptions +ADD CONSTRAINT subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL; + +ALTER TABLE purchases +DROP CONSTRAINT IF EXISTS purchases_user_id_fkey; +ALTER TABLE purchases +ADD CONSTRAINT purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL; + +ALTER TABLE user_credits +DROP CONSTRAINT IF EXISTS user_credits_user_id_fkey; +ALTER TABLE user_credits +ADD CONSTRAINT user_credits_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; + +ALTER TABLE credit_transactions +DROP CONSTRAINT IF EXISTS credit_transactions_user_id_fkey; +ALTER TABLE credit_transactions +ADD CONSTRAINT credit_transactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL; + +ALTER TABLE account_deletion_requests +DROP CONSTRAINT IF EXISTS account_deletion_requests_user_id_fkey; +ALTER TABLE account_deletion_requests +ADD CONSTRAINT account_deletion_requests_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; + +ALTER TABLE users +DROP CONSTRAINT IF EXISTS users_id_fkey; +ALTER TABLE users +ADD CONSTRAINT users_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;