Skip to content

Commit c8623b7

Browse files
committed
move user delete to api endpoint
1 parent 3b17022 commit c8623b7

4 files changed

Lines changed: 143 additions & 29 deletions

File tree

e2e/profile.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,43 @@ test('visiting the verification link while logged out redirects to login', async
297297
}
298298
});
299299

300+
test('delete account shows an error when the password is wrong', async ({ page }) => {
301+
await goToProfilePage(page);
302+
await page.getByRole('button', { name: 'Delete Account' }).click();
303+
const dialog = page.getByRole('dialog');
304+
await dialog.getByPlaceholder('Password').fill('not-the-real-password');
305+
await Promise.all([
306+
page.waitForResponse(
307+
(response) =>
308+
response.url().includes('/user/confirm-password') &&
309+
response.request().method() === 'POST' &&
310+
response.status() === 422
311+
),
312+
dialog.getByRole('button', { name: 'Delete Account' }).click(),
313+
]);
314+
await expect(dialog.getByRole('alert')).toBeVisible();
315+
await expect(dialog).toBeVisible();
316+
});
317+
318+
test('delete account succeeds with the correct password and logs the user out', async ({
319+
page,
320+
}) => {
321+
await goToProfilePage(page);
322+
await page.getByRole('button', { name: 'Delete Account' }).click();
323+
const dialog = page.getByRole('dialog');
324+
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
325+
await Promise.all([
326+
page.waitForResponse(
327+
(response) =>
328+
response.url().includes('/api/v1/users/') &&
329+
response.request().method() === 'DELETE' &&
330+
response.status() === 204
331+
),
332+
dialog.getByRole('button', { name: 'Delete Account' }).click(),
333+
]);
334+
await page.waitForURL(/\/login/);
335+
});
336+
300337
async function createNewApiToken(page) {
301338
await page.getByLabel('API Key Name').fill('NEW API KEY');
302339
await Promise.all([

resources/js/Pages/Profile/Partials/DeleteUserForm.vue

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,57 @@
11
<script setup lang="ts">
22
import { ref } from 'vue';
3-
import { useForm } from '@inertiajs/vue3';
3+
import axios from 'axios';
44
import ActionSection from '@/Components/ActionSection.vue';
55
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
66
import DialogModal from '@/packages/ui/src/DialogModal.vue';
77
import { Field, FieldError } from '@/packages/ui/src/field';
88
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
99
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
10+
import { useDeleteUserMutation, useUserQuery } from '@/utils/useUserQuery';
1011
11-
const confirmingUserDeletion = ref(false);
12-
const passwordInput = ref<HTMLElement | null>(null);
12+
const { user } = useUserQuery();
13+
const deleteUserMutation = useDeleteUserMutation();
1314
14-
const form = useForm({
15-
password: '',
16-
});
15+
const confirmingUserDeletion = ref(false);
16+
const passwordInput = ref<HTMLInputElement | null>(null);
17+
const password = ref('');
18+
const passwordError = ref('');
19+
const processing = ref(false);
1720
18-
const confirmUserDeletion = () => {
21+
function confirmUserDeletion() {
1922
confirmingUserDeletion.value = true;
20-
2123
setTimeout(() => passwordInput.value?.focus(), 250);
22-
};
23-
24-
const deleteUser = () => {
25-
form.delete(route('current-user.destroy'), {
26-
preserveScroll: true,
27-
onSuccess: () => closeModal(),
28-
onError: () => passwordInput.value?.focus(),
29-
onFinish: () => form.reset(),
30-
});
31-
};
32-
33-
const closeModal = () => {
24+
}
25+
26+
async function deleteUser() {
27+
if (!user.value || processing.value) return;
28+
processing.value = true;
29+
passwordError.value = '';
30+
try {
31+
await axios.post(route('password.confirm'), { password: password.value });
32+
} catch (error) {
33+
processing.value = false;
34+
if (axios.isAxiosError(error) && error.response?.status === 422) {
35+
passwordError.value = error.response.data?.errors?.password?.[0] ?? 'Invalid password.';
36+
} else {
37+
passwordError.value = 'Could not confirm password. Please try again.';
38+
}
39+
passwordInput.value?.focus();
40+
return;
41+
}
42+
try {
43+
await deleteUserMutation.mutateAsync(user.value.id);
44+
window.location.href = '/';
45+
} catch {
46+
processing.value = false;
47+
}
48+
}
49+
50+
function closeModal() {
3451
confirmingUserDeletion.value = false;
35-
36-
form.reset();
37-
};
52+
password.value = '';
53+
passwordError.value = '';
54+
}
3855
</script>
3956

4057
<template>
@@ -66,16 +83,14 @@ const closeModal = () => {
6683
<Field class="mt-4">
6784
<TextInput
6885
ref="passwordInput"
69-
v-model="form.password"
86+
v-model="password"
7087
type="password"
7188
class="block w-3/4"
7289
placeholder="Password"
7390
autocomplete="current-password"
7491
@keyup.enter="deleteUser" />
7592

76-
<FieldError v-if="form.errors.password">{{
77-
form.errors.password
78-
}}</FieldError>
93+
<FieldError v-if="passwordError">{{ passwordError }}</FieldError>
7994
</Field>
8095
</template>
8196

@@ -84,8 +99,8 @@ const closeModal = () => {
8499

85100
<DangerButton
86101
class="ms-3"
87-
:class="{ 'opacity-25': form.processing }"
88-
:disabled="form.processing"
102+
:class="{ 'opacity-25': processing }"
103+
:disabled="processing"
89104
@click="deleteUser">
90105
Delete Account
91106
</DangerButton>

resources/js/packages/api/src/openapi.json.client.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4486,6 +4486,45 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
44864486
},
44874487
],
44884488
},
4489+
{
4490+
method: 'delete',
4491+
path: '/v1/users/:user',
4492+
alias: 'deleteUser',
4493+
description: `This endpoint is independent of the organization.`,
4494+
requestFormat: 'json',
4495+
parameters: [
4496+
{
4497+
name: 'user',
4498+
type: 'Path',
4499+
schema: z.string(),
4500+
},
4501+
],
4502+
response: z.void(),
4503+
errors: [
4504+
{
4505+
status: 400,
4506+
description: `API exception`,
4507+
schema: z
4508+
.object({ error: z.boolean(), key: z.string(), message: z.string() })
4509+
.passthrough(),
4510+
},
4511+
{
4512+
status: 401,
4513+
description: `Unauthenticated`,
4514+
schema: z.object({ message: z.string() }).passthrough(),
4515+
},
4516+
{
4517+
status: 403,
4518+
description: `Authorization error`,
4519+
schema: z.object({ message: z.string() }).passthrough(),
4520+
},
4521+
{
4522+
status: 404,
4523+
description: `Not found`,
4524+
schema: z.object({ message: z.string() }).passthrough(),
4525+
},
4526+
],
4527+
},
44894528
{
44904529
method: 'post',
44914530
path: '/v1/users/:user/resend-email-verification',

resources/js/utils/useUserQuery.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,29 @@ export function useUpdateUserMutation() {
5757
});
5858
}
5959

60+
export function useDeleteUserMutation() {
61+
const { addNotification } = useNotificationsStore();
62+
63+
return useMutation({
64+
mutationFn: async (userId: string) => {
65+
try {
66+
await api.deleteUser(undefined, { params: { user: userId } });
67+
} catch (error) {
68+
if (!axios.isAxiosError(error) || error.response?.status !== 422) {
69+
addNotification(
70+
'error',
71+
'Failed to delete account',
72+
axios.isAxiosError(error)
73+
? (error.response?.data?.message ?? 'Please try again later.')
74+
: 'Please try again later.'
75+
);
76+
}
77+
throw error;
78+
}
79+
},
80+
});
81+
}
82+
6083
export function useResendUserEmailVerificationMutation() {
6184
const { handleApiRequestNotifications } = useNotificationsStore();
6285

0 commit comments

Comments
 (0)