Skip to content
Open
25 changes: 24 additions & 1 deletion packages/cypress/src/integration/SignUp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('[User sign-up]', () => {
});

describe('[Update existing auth details]', () => {
it('Updates username and password', () => {
it('Updates email, password, and deletes account', () => {
const user = generateNewUserDetails();
const { email, username, password } = user;
cy.signUpNewUser(user);
Expand Down Expand Up @@ -101,6 +101,29 @@ describe('[User sign-up]', () => {
cy.get('[data-cy="changePasswordContainer"')
.contains(FRIENDLY_MESSAGES['auth/password-changed'])
.should('be.visible');

cy.step('Open delete account section');
cy.get('[data-cy="deleteAccountContainer"]').find('[data-cy="accordionContainer"]').click();

cy.step('Submit with wrong password');
cy.get('[data-cy="deleteAccountPassword"]').type('wrong_password');
cy.get('[data-cy="deleteAccountSubmit"]').click();

cy.step('Confirm deletion');
cy.get('[data-cy="Confirm.modal: Confirm"]').click();

cy.step('Shows error message');
cy.contains('Invalid password').should('be.visible');

cy.step('Clear and submit with correct password');
cy.get('[data-cy="deleteAccountPassword"]').clear().type(newPassword);
cy.get('[data-cy="deleteAccountSubmit"]').click();

cy.step('Confirm deletion');
cy.get('[data-cy="Confirm.modal: Confirm"]').click();

cy.step('Redirected to homepage');
cy.url().should('eq', Cypress.config().baseUrl + '/');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to see what happens to the content of a deleted user:

  • Comments (de-author)
  • Questions (de-author)
  • Research (de-author)
  • Projects (de-author)
  • News (de-author)
  • Map pins (delete)

Copy link
Copy Markdown
Contributor

@mariojsnunes mariojsnunes Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To do that, the best way might be with a seed. Have the profile and associated items which we delete on the test, instead of signing up a new user and creating the content each time.
If that's difficult for some reason, at least this PR must ensure those delete/set null cascades are correct.

});
});
});
15 changes: 3 additions & 12 deletions src/pages/UserSettings/SettingsPageAccount.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { observer } from 'mobx-react';
import { ExternalLink } from 'oa-components';
import { DISCORD_INVITE_URL } from 'src/constants';
import { fields, headings } from 'src/pages/UserSettings/labels';
import { headings } from 'src/pages/UserSettings/labels';
import { Flex, Heading, Text } from 'theme-ui';

import { PatreonIntegration } from './content/fields/PatreonIntegration';
import { ChangeEmailForm } from './content/sections/ChangeEmail.form';
import { ChangePasswordForm } from './content/sections/ChangePassword.form';
import { DeleteAccountForm } from './content/sections/DeleteAccount.form';

export const SettingsPageAccount = observer(() => {
const { description, title } = fields.deleteAccount;

return (
<Flex
sx={{
Expand All @@ -27,13 +24,7 @@ export const SettingsPageAccount = observer(() => {
<PatreonIntegration />
<ChangePasswordForm />
<ChangeEmailForm />

<Text variant="body">
{title}{' '}
<ExternalLink sx={{ ml: 1, textDecoration: 'underline' }} href={DISCORD_INVITE_URL}>
{description}
</ExternalLink>
</Text>
<DeleteAccountForm />
</Flex>
);
});
106 changes: 106 additions & 0 deletions src/pages/UserSettings/content/sections/DeleteAccount.form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Accordion, Button, ConfirmModal, FieldInput } from 'oa-components';
import { useState } from 'react';
import { Form } from 'react-final-form';
import { useNavigate } from 'react-router';
import { PasswordField } from 'src/common/Form/PasswordField';
import { FormFieldWrapper } from 'src/pages/common/FormFields';
import type { SubmitResults } from 'src/pages/User/contact/UserContactError';
import { UserContactError } from 'src/pages/User/contact/UserContactError';
import { buttons, fields } from 'src/pages/UserSettings/labels';
import { Flex } from 'theme-ui';
import { accountService } from '../../services/account.service';

interface IFormValues {
password: string;
}

export const DeleteAccountForm = () => {
const navigate = useNavigate();
const [submitResults, setSubmitResults] = useState<SubmitResults | null>(null);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [pendingPassword, setPendingPassword] = useState('');

const formId = 'deleteAccount';

const onSubmit = (values: IFormValues) => {
setPendingPassword(values.password);
setIsConfirmOpen(true);
};

const onConfirm = async () => {
setIsConfirmOpen(false);

try {
const result = await accountService.deleteAccount(pendingPassword);

if (!result.ok) {
setSubmitResults({
type: 'error',
message: result.statusText || 'Oops, something went wrong!',
});
return;
}

navigate('/');
} catch {
setSubmitResults({
type: 'error',
message: 'Oops, something went wrong!',
});
}
};

return (
<Flex data-cy="deleteAccountContainer" sx={{ flexDirection: 'column', gap: 2 }}>
<UserContactError submitResults={submitResults} />

<Accordion title={fields.deleteAccount.title} subtitle={fields.deleteAccount.description}>
<Form
onSubmit={onSubmit}
id={formId}
render={({ handleSubmit, submitting, values }) => {
const disabled = submitting || !values.password;

return (
<Flex data-cy="deleteAccountForm" sx={{ flexDirection: 'column', gap: 2 }}>
<FormFieldWrapper text={fields.password.title} htmlFor="password" required>
<PasswordField
autoComplete="off"
component={FieldInput}
data-cy="deleteAccountPassword"
name="password"
placeholder="Password"
required
/>
</FormFieldWrapper>

<Button
data-cy="deleteAccountSubmit"
disabled={disabled}
form={formId}
onClick={handleSubmit}
type="submit"
variant="destructive"
sx={{
alignSelf: 'flex-start',
}}
>
{buttons.deleteAccount}
</Button>
</Flex>
);
}}
/>
</Accordion>

<ConfirmModal
isOpen={isConfirmOpen}
message="Are you sure you want to permanently delete your account? This action cannot be undone and all your data will be lost."
confirmButtonText="Delete my account"
handleCancel={() => setIsConfirmOpen(false)}
handleConfirm={onConfirm}
confirmVariant="destructive"
/>
</Flex>
);
};
5 changes: 3 additions & 2 deletions src/pages/UserSettings/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ILabels } from 'src/common/Form/types';
export const buttons = {
changeEmail: 'Change email',
changePassword: 'Change password',
deleteAccount: 'Delete account',
deleteLink: {
message: 'Are you sure you want to delete this link?',
text: 'Delete',
Expand Down Expand Up @@ -56,8 +57,8 @@ export const fields: ILabels = {
title: 'Add an avatar',
},
deleteAccount: {
description: 'Please reach out to support.',
title: 'Would you like to delete your account?',
description: 'Once deleted, your account cannot be recovered.',
title: 'Delete account',
},
displayName: {
title: 'Display Name',
Expand Down
11 changes: 11 additions & 0 deletions src/pages/UserSettings/services/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,18 @@ const changePassword = async (oldPassword: string, newPassword: string) => {
});
};

const deleteAccount = async (password: string) => {
const data = new FormData();
data.append('password', password);

return await fetch('/api/account/delete', {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to double-check, using a POST instead of DELETE because we need to send FormData?

method: 'POST',
body: data,
});
};

export const accountService = {
changeEmail,
changePassword,
deleteAccount,
};
70 changes: 70 additions & 0 deletions src/routes/api.account.delete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ActionFunctionArgs } from 'react-router';
import { createSupabaseServerClient } from 'src/repository/supabase.server';
import { createSupabaseAdminServerClient } from 'src/repository/supabaseAdmin.server';
import { ProfileServiceServer } from 'src/services/profileService.server';

export const action = async ({ request }: ActionFunctionArgs) => {
const { client, headers } = createSupabaseServerClient(request);

try {
const formData = await request.formData();
const password = formData.get('password') as string;

if (!password) {
return Response.json({}, { headers, status: 400, statusText: 'Password is required' });
}

const claims = await client.auth.getClaims();
const authId = claims.data?.claims?.sub;

if (!authId) {
return Response.json({}, { headers, status: 401 });
}

const signInResult = await client.auth.signInWithPassword({
email: claims.data?.claims?.email as string,
password,
});

if (signInResult.error) {
return Response.json({}, { headers, status: 400, statusText: 'Invalid password' });
}

const profileService = new ProfileServiceServer(client);
const profile = await profileService.getByAuthId(authId);

if (!profile) {
return Response.json({}, { headers, status: 400, statusText: 'Profile not found' });
}

const adminClient = createSupabaseAdminServerClient();

const { data: allProfiles } = await adminClient
.from('profiles')
.select('id, tenant_id')
.eq('auth_id', authId);

const hasOtherTenantProfiles = (allProfiles?.length ?? 0) > 1;

if (hasOtherTenantProfiles) {
const { error } = await client.from('profiles').delete().eq('id', profile.id);

if (error) {
throw error;
}
} else {
const { error } = await adminClient.auth.admin.deleteUser(authId);

if (error) {
throw error;
}
}

await client.auth.signOut();

return new Response(null, { headers, status: 204 });
} catch (error) {
console.error(error);
return Response.json({}, { headers, status: 500, statusText: 'Failed to delete account' });
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
alter table "public"."notifications" drop constraint "notifications_triggered_by_id_fkey";
alter table "public"."notifications" add constraint "notifications_triggered_by_id_fkey"
FOREIGN KEY (triggered_by_id) REFERENCES profiles(id)
ON UPDATE CASCADE ON DELETE SET NULL;

alter table "public"."notifications_preferences" drop constraint "notifications_preferences_user_id_fkey";
alter table "public"."notifications_preferences" add constraint "notifications_preferences_user_id_fkey"
FOREIGN KEY (user_id) REFERENCES profiles(id)
ON UPDATE CASCADE ON DELETE CASCADE;
Loading