move user delete to api endpoint

This commit is contained in:
Gregor Vostrak
2026-05-27 18:56:57 +02:00
committed by Constantin Graf
parent b2a5b7a8c1
commit 10a66fa065
4 changed files with 141 additions and 27 deletions

View File

@@ -297,6 +297,43 @@ test('visiting the verification link while logged out redirects to login', async
}
});
test('delete account shows an error when the password is wrong', async ({ page }) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Delete Account' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill('not-the-real-password');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/confirm-password') &&
response.request().method() === 'POST' &&
response.status() === 422
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),
]);
await expect(dialog.getByRole('alert')).toBeVisible();
await expect(dialog).toBeVisible();
});
test('delete account succeeds with the correct password and logs the user out', async ({
page,
}) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Delete Account' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),
]);
await page.waitForURL(/\/login/);
});
async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([

View File

@@ -1,40 +1,57 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import axios from 'axios';
import ActionSection from '@/Components/ActionSection.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { Field, FieldError } from '@/packages/ui/src/field';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { useDeleteUserMutation, useUserQuery } from '@/utils/useUserQuery';
const { user } = useUserQuery();
const deleteUserMutation = useDeleteUserMutation();
const confirmingUserDeletion = ref(false);
const passwordInput = ref<HTMLElement | null>(null);
const passwordInput = ref<HTMLInputElement | null>(null);
const password = ref('');
const passwordError = ref('');
const processing = ref(false);
const form = useForm({
password: '',
});
const confirmUserDeletion = () => {
function confirmUserDeletion() {
confirmingUserDeletion.value = true;
setTimeout(() => passwordInput.value?.focus(), 250);
};
}
const deleteUser = () => {
form.delete(route('current-user.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.value?.focus(),
onFinish: () => form.reset(),
});
};
async function deleteUser() {
if (!user.value || processing.value) return;
processing.value = true;
passwordError.value = '';
try {
await axios.post(route('password.confirm'), { password: password.value });
} catch (error) {
processing.value = false;
if (axios.isAxiosError(error) && error.response?.status === 422) {
passwordError.value = error.response.data?.errors?.password?.[0] ?? 'Invalid password.';
} else {
passwordError.value = 'Could not confirm password. Please try again.';
}
passwordInput.value?.focus();
return;
}
try {
await deleteUserMutation.mutateAsync(user.value.id);
window.location.href = '/';
} catch {
processing.value = false;
}
}
const closeModal = () => {
function closeModal() {
confirmingUserDeletion.value = false;
form.reset();
};
password.value = '';
passwordError.value = '';
}
</script>
<template>
@@ -66,16 +83,14 @@ const closeModal = () => {
<Field class="mt-4">
<TextInput
ref="passwordInput"
v-model="form.password"
v-model="password"
type="password"
class="block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="deleteUser" />
<FieldError v-if="form.errors.password">{{
form.errors.password
}}</FieldError>
<FieldError v-if="passwordError">{{ passwordError }}</FieldError>
</Field>
</template>
@@ -84,8 +99,8 @@ const closeModal = () => {
<DangerButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
:class="{ 'opacity-25': processing }"
:disabled="processing"
@click="deleteUser">
Delete Account
</DangerButton>

View File

@@ -4534,6 +4534,45 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
},
],
},
{
method: 'delete',
path: '/v1/users/:user',
alias: 'deleteUser',
description: `This endpoint is independent of the organization.`,
requestFormat: 'json',
parameters: [
{
name: 'user',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'post',
path: '/v1/users/:user/resend-email-verification',

View File

@@ -57,6 +57,29 @@ export function useUpdateUserMutation() {
});
}
export function useDeleteUserMutation() {
const { addNotification } = useNotificationsStore();
return useMutation({
mutationFn: async (userId: string) => {
try {
await api.deleteUser(undefined, { params: { user: userId } });
} catch (error) {
if (!axios.isAxiosError(error) || error.response?.status !== 422) {
addNotification(
'error',
'Failed to delete account',
axios.isAxiosError(error)
? (error.response?.data?.message ?? 'Please try again later.')
: 'Please try again later.'
);
}
throw error;
}
},
});
}
export function useResendUserEmailVerificationMutation() {
const { handleApiRequestNotifications } = useNotificationsStore();