mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-12 18:43:50 +00:00
feat(#55): Enable deleting account and anonymising purchases and subscriptions (no email sending)
This commit is contained in:
Vendored
+26
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 } }) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user