Add password check to users.destroy and organizations.destroy

This commit is contained in:
Constantin Graf
2026-06-17 15:51:25 +02:00
committed by Constantin Graf
parent 24c94af952
commit 6a197f7f34
16 changed files with 352 additions and 57 deletions

View File

@@ -1,15 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue';
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';
import { useDeleteUserMutation } from '@/utils/useUserQuery';
import { getCurrentUserId } from '@/utils/useUser';
const { user } = useUserQuery();
const deleteUserMutation = useDeleteUserMutation();
const confirmingUserDeletion = ref(false);
@@ -24,26 +23,26 @@ function confirmUserDeletion() {
}
async function deleteUser() {
if (!user.value || processing.value) return;
if (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);
await deleteUserMutation.mutateAsync({
userId: getCurrentUserId(),
body: { password: password.value },
});
window.location.href = '/';
} catch {
} catch (error) {
if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } }
| undefined;
if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
}
}
processing.value = false;
passwordInput.value?.focus();
}
}

View File

@@ -2,9 +2,11 @@
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.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 { useOrganizationStore } from '@/utils/useOrganization';
const props = defineProps<{
@@ -12,26 +14,46 @@ const props = defineProps<{
}>();
const confirmingTeamDeletion = ref(false);
const passwordInput = ref<HTMLInputElement | null>(null);
const password = ref('');
const passwordError = ref('');
const processing = ref(false);
const organizationStore = useOrganizationStore();
const confirmTeamDeletion = () => {
confirmingTeamDeletion.value = true;
setTimeout(() => passwordInput.value?.focus(), 250);
};
const deleteTeam = async () => {
if (processing.value) return;
processing.value = true;
passwordError.value = '';
try {
await organizationStore.deleteOrganization(props.team.id);
await organizationStore.deleteOrganization(props.team.id, { password: password.value });
// The backend reassigns the user's current organization after deletion,
// so flush the prefetch cache and reload into the dashboard.
router.flushAll();
router.visit(route('dashboard'));
} catch {
// Request errors are surfaced as notifications by the store.
} catch (error) {
if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } }
| undefined;
if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
}
}
processing.value = false;
passwordInput.value?.focus();
}
};
const closeModal = () => {
confirmingTeamDeletion.value = false;
password.value = '';
passwordError.value = '';
};
</script>
<template>
@@ -52,20 +74,30 @@ const deleteTeam = async () => {
</div>
<!-- Delete Organization Confirmation Modal -->
<ConfirmationModal
:show="confirmingTeamDeletion"
@close="confirmingTeamDeletion = false">
<DialogModal :show="confirmingTeamDeletion" @close="closeModal">
<template #title> Delete Organization </template>
<template #content>
Are you sure you want to delete this organization? Once a organization is
deleted, all of its resources and data will be permanently deleted.
deleted, all of its resources and data will be permanently deleted. Please enter
your password to confirm you would like to permanently delete this organization.
<Field class="mt-4">
<TextInput
ref="passwordInput"
v-model="password"
type="password"
class="block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="deleteTeam" />
<FieldError v-if="passwordError">{{ passwordError }}</FieldError>
</Field>
</template>
<template #footer>
<SecondaryButton @click="confirmingTeamDeletion = false">
Cancel
</SecondaryButton>
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
@@ -75,7 +107,7 @@ const deleteTeam = async () => {
Delete Organization
</DangerButton>
</template>
</ConfirmationModal>
</DialogModal>
</template>
</ActionSection>
</template>

View File

@@ -126,6 +126,8 @@ export type UpdateInvoiceBody = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoice'>
export type User = ZodiosResponseByAlias<SolidTimeApi, 'getMe'>['data'];
export type UpdateUserBody = ZodiosBodyByAlias<SolidTimeApi, 'updateUser'>;
export type DeleteUserBody = ZodiosBodyByAlias<SolidTimeApi, 'deleteUser'>;
export type DeleteOrganizationBody = ZodiosBodyByAlias<SolidTimeApi, 'deleteOrganization'>;
const api = createApiClient('/api', { validate: 'none' });

View File

@@ -37,6 +37,7 @@ const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passth
const ClientUpdateRequest = z
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
.passthrough();
const DestroyWithPasswordRequest = z.object({ password: z.string() }).passthrough();
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
const InvitationResource = z
.object({ id: z.string(), email: z.string(), role: z.string() })
@@ -917,6 +918,11 @@ const endpoints = makeApi([
alias: 'deleteOrganization',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: DestroyWithPasswordRequest,
},
{
name: 'organization',
type: 'Path',
@@ -4642,6 +4648,11 @@ the organization.`,
description: `This endpoint is independent of the organization.`,
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: DestroyWithPasswordRequest,
},
{
name: 'user',
type: 'Path',

View File

@@ -2,9 +2,11 @@ import { router } from '@inertiajs/vue3';
import { initializeStores } from '@/utils/init';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import axios from 'axios';
import type {
Organization,
OrganizationResponse,
DeleteOrganizationBody,
UpdateOrganizationBody,
} from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
@@ -38,7 +40,7 @@ export async function switchOrganization(organizationId: string) {
export const useOrganizationStore = defineStore('organization', () => {
const organizationResponse = ref<OrganizationResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
const { addNotification, handleApiRequestNotifications } = useNotificationsStore();
async function fetchOrganization() {
const organization = getCurrentOrganizationId();
@@ -78,17 +80,26 @@ export const useOrganizationStore = defineStore('organization', () => {
return response?.data ?? null;
}
async function deleteOrganization(organizationId: string) {
await handleApiRequestNotifications(
() =>
api.deleteOrganization(undefined, {
params: {
organization: organizationId,
},
}),
'Organization deleted successfully',
'Failed to delete organization'
);
async function deleteOrganization(organizationId: string, body: DeleteOrganizationBody) {
try {
await api.deleteOrganization(body, {
params: {
organization: organizationId,
},
});
addNotification('success', 'Organization deleted successfully');
} catch (error) {
if (!axios.isAxiosError(error) || error.response?.status !== 422) {
addNotification(
'error',
'Failed to delete organization',
axios.isAxiosError(error)
? (error.response?.data?.message ?? 'Please try again later.')
: 'Please try again later.'
);
}
throw error;
}
}
const organization = computed<Organization | null>(() => {

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { computed } from 'vue';
import axios from 'axios';
import { api, type UpdateUserBody, type User } from '@/packages/api/src';
import { api, type DeleteUserBody, type UpdateUserBody, type User } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
const ME_QUERY_KEY = ['me'] as const;
@@ -61,9 +61,9 @@ export function useDeleteUserMutation() {
const { addNotification } = useNotificationsStore();
return useMutation({
mutationFn: async (userId: string) => {
mutationFn: async ({ userId, body }: { userId: string; body: DeleteUserBody }) => {
try {
await api.deleteUser(undefined, { params: { user: userId } });
await api.deleteUser(body, { params: { user: userId } });
} catch (error) {
if (!axios.isAxiosError(error) || error.response?.status !== 422) {
addNotification(