feat(#55): Enable deleting account and anonymising purchases and subscriptions (no email sending)

This commit is contained in:
Josh Creek
2024-10-29 21:20:23 +00:00
parent 90875517da
commit 33409bc375
6 changed files with 177 additions and 6 deletions
+26
View File
@@ -34,6 +34,32 @@ export type Database = {
} }
public: { public: {
Tables: { 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: { credit_transactions: {
Row: { Row: {
created_at: string created_at: string
+52 -1
View File
@@ -480,6 +480,55 @@ const getProductById = async (productId: string) => {
return product as Product; 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: `<p>Click <a href="${deletionLink}">here</a> to confirm your account deletion.</p>`
// });
// 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 { export {
upsertProductRecord, upsertProductRecord,
upsertPriceRecord, upsertPriceRecord,
@@ -495,5 +544,7 @@ export {
getActiveProductsWithPrices, getActiveProductsWithPrices,
getProductById, getProductById,
upsertCustomerToSupabase, upsertCustomerToSupabase,
getStripeCustomerId getStripeCustomerId,
requestAccountDeletion,
deleteAccount
}; };
+19 -5
View File
@@ -1,5 +1,6 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requestAccountDeletion } from '$lib/utils/supabase/admin';
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession(); const { session } = await safeGetSession();
@@ -18,6 +19,16 @@ export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession
}; };
export const actions: Actions = { 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 } }) => { update: async ({ request, locals: { supabase, safeGetSession } }) => {
const formData = await request.formData(); const formData = await request.formData();
const name = formData.get('name') as string; const name = formData.get('name') as string;
@@ -27,20 +38,23 @@ export const actions: Actions = {
if (!session) { if (!session) {
return fail(401, { name }); return fail(401, { name });
} }
const { error } = await supabase.from('users').update({ const { error } = await supabase
name: name, .from('users')
}).eq('id', session?.user.id); .update({
name: name
})
.eq('id', session?.user.id);
console.error(error); console.error(error);
if (error) { if (error) {
return fail(500, { return fail(500, {
name, name
}); });
} }
return { return {
name, name
}; };
}, },
signout: async ({ locals: { supabase, safeGetSession } }) => { signout: async ({ locals: { supabase, safeGetSession } }) => {
+16
View File
@@ -26,6 +26,16 @@
update(); update();
}; };
}; };
const handleAccountDeletion: SubmitFunction = () => {
loading = true;
if (session.user && session.user.email) {
alert('Check your email to confirm account deletion.');
}
return async () => {
loading = false;
};
};
</script> </script>
<div class="form-widget"> <div class="form-widget">
@@ -56,4 +66,10 @@
<button class="button block" disabled={loading}>Sign Out</button> <button class="button block" disabled={loading}>Sign Out</button>
</div> </div>
</form> </form>
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
<div>
<button class="button block" disabled={loading}>Delete My Account</button>
</div>
</form>
</div> </div>
+18
View File
@@ -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 });
};
@@ -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;