Files
supabase/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx
Monica Khoury d91b3474fd Studio: prevent organization deletion without explicit project confirmation (#43898)
## Release Notes

* **New Features**
* Added project acknowledgement flow when deleting organizations. Users
with 10 or fewer projects must confirm each project individually; users
with more than 10 projects can confirm all at once.
* Organizations now display project counts and compute sizes during
deletion confirmation to help users understand what will be deleted.

## What kind of change does this PR introduce?

**Improvements to the delete organization workflow:**

* Added a checklist that requires users to acknowledge each project
before deleting organizations with up to 10 projects, and a single bulk
acknowledgement for organizations with more than 10 projects. This helps
prevent accidental deletion of projects.
* Extracted acknowledgement UI into dedicated sub-components
(`DeleteOrganizationButton.ListAck.tsx` and
`DeleteOrganizationButton.SingleAck.tsx`).

**Error handling and validation:**

* Acknowledgement state (`checkedProjects`/`acknowledgedAll`) is reset
synchronously in the button's click handler before the modal opens,
preventing any stale checked state from briefly appearing on re-open.
* Acknowledgement state is also reset when the organization changes
(`orgSlug` effect), ensuring a fresh confirmation when switching orgs.
* Deletion is blocked while projects are loading **or fetching**
(including background refetches triggered by `refetchOnMount:
'always'`), preventing deletion from proceeding on stale or incomplete
project data.
* Added error handling for project loading failures, and prevented
deletion if projects are not fully acknowledged or if there is a
loading/error/fetching state.

## What is the current behavior?

No explicit confirmation from the user to acknowledge all the projects:
<img
src="https://github.com/user-attachments/assets/68840781-467b-41ef-a821-50e471e93cc5"
width="500">

## What is the new behavior?

Small org (≤10): all projects load and checklist works
<img
src="https://github.com/user-attachments/assets/9869e94c-6a6f-4d65-8550-55da58a388f2"
width="500">

Large org (>10): show one checkbox
<img
src="https://github.com/user-attachments/assets/8420dfd1-814a-4656-acad-7fa00b088e83"
width="500">

## Additional context

* Projects are fetched lazily (`enabled: isOpen`) so no unnecessary
network requests occur until the delete modal is opened.
* The delete guard now checks both `isLoading`, `isProjectsDataPending`
and `isFetching` to cover background refetch scenarios where cached data
may be stale.
* Added explicit handling for the “pending but not loading” case to
avoid misleading users with acknowledgement errors before projects are
loaded.
* Limited project fetch size to MAX_PROJECT_ACKNOWLEDGEMENTS + 1 to
reduce payload and improve modal load performance.
* Acknowledgement state is reset both on modal open and when the
organization changes (orgSlug), ensuring consistent behavior across org
switches.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-01 13:10:05 +00:00

207 lines
6.3 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { LOCAL_STORAGE_KEYS } from 'common'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { DeleteOrganizationButtonListAck } from './DeleteOrganizationButton.ListAck'
import { DeleteOrganizationButtonSingleAck } from './DeleteOrganizationButton.SingleAck'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
import { useOrganizationDeleteMutation } from '@/data/organizations/organization-delete-mutation'
import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
const MAX_PROJECT_ACKNOWLEDGEMENTS = 10
export const DeleteOrganizationButton = () => {
const router = useRouter()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { slug: orgSlug, name: orgName } = selectedOrganization ?? {}
const [checkedProjects, setCheckedProjects] = useState<Record<string, boolean>>({})
const [acknowledgedAll, setAcknowledgedAll] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
setCheckedProjects({})
setAcknowledgedAll(false)
}, [orgSlug])
const {
data: projectsData,
isLoading,
isFetching,
isError,
} = useOrgProjectsInfiniteQuery(
{
slug: orgSlug,
limit: MAX_PROJECT_ACKNOWLEDGEMENTS + 1,
},
{
enabled: isOpen,
refetchOnMount: 'always',
}
)
// When an organization slug is present but the projects query has not yet
// produced any data (and hasn't errored), treat this as a "pending" state
// rather than as "no projects". This avoids interpreting lack of data as
// an empty list, which could allow deletion to proceed without any project
// acknowledgement.
const isProjectsDataPending = orgSlug !== undefined && projectsData === undefined && !isError
const projects =
!isProjectsDataPending && projectsData !== undefined
? projectsData.pages.flatMap((page) => page.projects ?? [])
: undefined
const shouldRenderChecklist =
projects !== undefined && projects.length > 0 && projects.length <= MAX_PROJECT_ACKNOWLEDGEMENTS
const exceedsLimit = projects !== undefined && projects.length > MAX_PROJECT_ACKNOWLEDGEMENTS
const toggleProject = (ref: string, checked?: boolean | 'indeterminate') => {
setCheckedProjects((prev) => ({
...prev,
[ref]: checked === undefined ? !prev[ref] : checked === true,
}))
}
const isDeletionConfirmed = () => {
// While project data is pending or unavailable, treat deletion as not confirmed
if (!projects) return false
if (projects.length === 0) return true
if (shouldRenderChecklist) {
return projects.every((p) => checkedProjects[p.ref])
}
if (exceedsLimit) {
return acknowledgedAll
}
return false
}
const allChecked = isDeletionConfirmed()
const [_, setLastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
)
const { can: canDeleteOrganization } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'organizations'
)
const { mutate: deleteOrganization, isPending: isDeleting } = useOrganizationDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted ${orgName}`)
setLastVisitedOrganization('')
router.push('/organizations')
},
})
const onConfirmDelete = () => {
if (!canDeleteOrganization) {
toast.error('You do not have permission to delete this organization')
return
}
if (!orgSlug) {
console.error('Org slug is required')
return
}
if (isLoading || isFetching || isProjectsDataPending) {
toast.error('Projects are still loading, please wait')
return
}
if (isError) {
toast.error('Failed to load projects')
return
}
if (!allChecked) {
toast.error('Please acknowledge all projects before deleting the organization')
return
}
deleteOrganization({ slug: orgSlug })
}
return (
<>
<div className="mt-2">
<ButtonTooltip
type="danger"
disabled={!canDeleteOrganization || !orgSlug}
loading={!orgSlug}
onClick={() => {
setCheckedProjects({})
setAcknowledgedAll(false)
setIsOpen(true)
}}
tooltip={{
content: {
side: 'bottom',
text: !canDeleteOrganization
? 'You need additional permissions to delete this organization'
: undefined,
},
}}
>
Delete organization
</ButtonTooltip>
</div>
<TextConfirmModal
visible={isOpen}
size="small"
variant="destructive"
title="Delete organization"
loading={isDeleting}
confirmString={orgSlug ?? ''}
confirmPlaceholder="Enter the string above"
confirmLabel="I understand, delete this organization"
onConfirm={onConfirmDelete}
onCancel={() => setIsOpen(false)}
>
{/* ≤ MAX → checklist */}
{shouldRenderChecklist && (
<DeleteOrganizationButtonListAck
projects={projects}
checkedProjects={checkedProjects}
toggleProject={toggleProject}
/>
)}
{/* > MAX → single confirmation */}
{exceedsLimit && (
<DeleteOrganizationButtonSingleAck
acknowledgedAll={acknowledgedAll}
setAcknowledgedAll={setAcknowledgedAll}
max={MAX_PROJECT_ACKNOWLEDGEMENTS}
/>
)}
{/* Final warning */}
<p
className={`text-sm text-foreground-lighter ${(projects?.length ?? 0) > 0 ? 'mt-4' : ''}`}
>
This action <span className="text-foreground">cannot</span> be undone. This will
permanently delete the <span className="text-foreground">{orgName}</span> organization and
remove all of its projects.
</p>
</TextConfirmModal>
</>
)
}