Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

55 enable account closure #68

Merged
merged 12 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@ STRIPE_SECRET_KEY=""
STRIPE_ENDPOINT_SECRET=""
VITE_PRODUCT_ID_EXAMPLEPRODUCT=""
VITE_AXIOM_DATASET=""
VITE_AXIOM_TOKEN=""
VITE_AXIOM_TOKEN=""
# Brevo API configuration for transactional emails (account closure notifications)
VITE_BREVO_API_KEY=""
# Default sender email for system notifications
VITE_BREVO_SENDER_EMAIL="noreply@example.com"
# Display name for the sender email
VITE_BREVO_SENDER_NAME="No Reply"
10 changes: 9 additions & 1 deletion docs/database-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,15 @@ There are two options to create a migration file:

## Applying migrations

Reset database in cloud
To do so without resetting the database, you can use the following command:

`npx supabase migration up`
jcreek marked this conversation as resolved.
Show resolved Hide resolved

> Use this command when you want to preserve existing data whilst applying new schema changes. For a fresh start with test data, use `npx supabase db reset` instead.

And for the database in the cloud:

`npx supabase db push`
OllyNicholass marked this conversation as resolved.
Show resolved Hide resolved

> Prerequisite login using `npx supabase login`

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"type": "module",
"dependencies": {
"@axiomhq/winston": "^1.2.0",
"@getbrevo/brevo": "^2.2.0",
"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.44.2",
"stripe": "^15.4.0",
Expand Down
309 changes: 309 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions src/lib/types/supabase.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,32 @@ export type Database = {
}
public: {
Tables: {
account_deletion_requests: {
Row: {
requested_at: string
jcreek marked this conversation as resolved.
Show resolved Hide resolved
token: string
user_id: string
jcreek marked this conversation as resolved.
Show resolved Hide resolved
}
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
Expand Down
34 changes: 34 additions & 0 deletions src/lib/utils/brevo/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import brevo from '@getbrevo/brevo';
import {
VITE_BREVO_API_KEY,
VITE_BREVO_SENDER_EMAIL,
VITE_BREVO_SENDER_NAME
} from '$env/static/private';

const apiInstance = new brevo.TransactionalEmailsApi();
const apiKey = apiInstance.authentications['apiKey'];
if (!VITE_BREVO_API_KEY) {
throw new Error('Brevo API key is not configured');
}
apiKey.apiKey = VITE_BREVO_API_KEY;

const sendEmail = async (subject: string, htmlContentString: string, toAddress: string) => {
const sendSmtpEmail = new brevo.SendSmtpEmail();

sendSmtpEmail.subject = subject;
sendSmtpEmail.htmlContent = htmlContentString;
sendSmtpEmail.sender = { name: VITE_BREVO_SENDER_NAME, email: VITE_BREVO_SENDER_EMAIL };
sendSmtpEmail.to = [{ email: toAddress, name: toAddress }];
sendSmtpEmail.replyTo = { email: VITE_BREVO_SENDER_EMAIL, name: VITE_BREVO_SENDER_NAME };

apiInstance.sendTransacEmail(sendSmtpEmail).then(
function (data) {
console.log('API called successfully. Returned data: ' + JSON.stringify(data));
},
function (error) {
console.error(error);
}
);
};
OllyNicholass marked this conversation as resolved.
Show resolved Hide resolved

export { sendEmail };
53 changes: 52 additions & 1 deletion src/lib/utils/supabase/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
import { stripe as stripeClient } from '$lib/utils/stripe';
import logger from '$lib/utils/logger/logger';
import { sendEmail } from '../brevo/email';

const toDateTime = (secs: number) => {
const t = new Date(+0); // Unix epoch start.
Expand Down Expand Up @@ -496,6 +497,54 @@ 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 sendEmail(
'Confirm Account Deletion',
`<p>Click <a href="${deletionLink}">here</a> to confirm your account deletion.</p>`,
userEmail
);
console.log('Deletion email sent successfully.');
} catch (emailError) {
console.error('Failed to send deletion email:', emailError);
throw new Error('Failed to send confirmation email.');
}
};
jcreek marked this conversation as resolved.
Show resolved Hide resolved

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}`);
}
};
jcreek marked this conversation as resolved.
Show resolved Hide resolved

const getUserSubscriptions = async (userId: string) => {
try {
// Step 1: Retrieve subscription data
Expand Down Expand Up @@ -601,5 +650,7 @@ export {
upsertCustomerToSupabase,
getStripeCustomerId,
getUserSubscriptions,
getUserTransactions
getUserTransactions,
requestAccountDeletion,
deleteAccount
};
22 changes: 22 additions & 0 deletions src/routes/account/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import logger from '$lib/utils/logger/logger';
import { getUserSubscriptions, getUserTransactions } from '$lib/utils/supabase/admin';
import { requestAccountDeletion } from '$lib/utils/supabase/admin';

export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession();
Expand All @@ -24,6 +25,27 @@ 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) {
if (!session.user.email) {
return fail(400, { message: 'Email is required for account deletion' });
}
try {
await requestAccountDeletion(session.user.id, session.user.email, baseUrl);
return {
success: true,
message: 'Account deletion request submitted successfully'
};
} catch (error) {
console.error('Failed to request account deletion:', error);
return fail(500, { message: 'Failed to process deletion request' });
}
} else {
redirect(303, '/');
}
},
update: async ({ request, locals: { supabase, safeGetSession } }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
Expand Down
30 changes: 29 additions & 1 deletion src/routes/account/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@
loading = false;
};
};

const handleSignOut: SubmitFunction = () => {
loading = true;
return async ({ update }) => {
loading = false;
update();
};
};

const handleAccountDeletion: SubmitFunction = () => {
loading = true;
if (session.user && session.user.email) {
alert('Check your email to confirm account deletion.');
} else {
alert('Please sign in to delete your account.');
}

return async () => {
loading = false;
};
};
</script>

<div
Expand Down Expand Up @@ -163,5 +184,12 @@
<a href="/contact" class="btn btn-outline w-full">Provide Feedback</a>
</section>
</div>
</div>
</form>

<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
<div>
<button class="button block" disabled={loading}>Delete My Account</button>
</div>
</form>
jcreek marked this conversation as resolved.
Show resolved Hide resolved

</div>
18 changes: 18 additions & 0 deletions src/routes/api/delete-account/+server.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
OllyNicholass marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 0 additions & 9 deletions src/routes/api/somebackendfunction/+server.ts

This file was deleted.

46 changes: 46 additions & 0 deletions supabase/migrations/20241029193900_account_deletion.sql
Original file line number Diff line number Diff line change
@@ -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
);
jcreek marked this conversation as resolved.
Show resolved Hide resolved

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;