DeleteOrganizationButton.tsx206 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { LOCAL_STORAGE_KEYS } from 'common'
3import { useRouter } from 'next/router'
4import { useEffect, useState } from 'react'
5import { toast } from 'sonner'
6
7import { DeleteOrganizationButtonListAck } from './DeleteOrganizationButton.ListAck'
8import { DeleteOrganizationButtonSingleAck } from './DeleteOrganizationButton.SingleAck'
9import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
10import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
11import { useOrganizationDeleteMutation } from '@/data/organizations/organization-delete-mutation'
12import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
13import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
14import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
15import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
16
17const MAX_PROJECT_ACKNOWLEDGEMENTS = 10
18
19export const DeleteOrganizationButton = () => {
20 const router = useRouter()
21
22 const { data: selectedOrganization } = useSelectedOrganizationQuery()
23 const { slug: orgSlug, name: orgName } = selectedOrganization ?? {}
24
25 const [checkedProjects, setCheckedProjects] = useState<Record<string, boolean>>({})
26 const [acknowledgedAll, setAcknowledgedAll] = useState(false)
27 const [isOpen, setIsOpen] = useState(false)
28
29 useEffect(() => {
30 setCheckedProjects({})
31 setAcknowledgedAll(false)
32 }, [orgSlug])
33
34 const {
35 data: projectsData,
36 isLoading,
37 isFetching,
38 isError,
39 } = useOrgProjectsInfiniteQuery(
40 {
41 slug: orgSlug,
42 limit: MAX_PROJECT_ACKNOWLEDGEMENTS + 1,
43 },
44 {
45 enabled: isOpen,
46 refetchOnMount: 'always',
47 }
48 )
49
50 // When an organization slug is present but the projects query has not yet
51 // produced any data (and hasn't errored), treat this as a "pending" state
52 // rather than as "no projects". This avoids interpreting lack of data as
53 // an empty list, which could allow deletion to proceed without any project
54 // acknowledgement.
55 const isProjectsDataPending = orgSlug !== undefined && projectsData === undefined && !isError
56
57 const projects =
58 !isProjectsDataPending && projectsData !== undefined
59 ? projectsData.pages.flatMap((page) => page.projects ?? [])
60 : undefined
61
62 const shouldRenderChecklist =
63 projects !== undefined && projects.length > 0 && projects.length <= MAX_PROJECT_ACKNOWLEDGEMENTS
64
65 const exceedsLimit = projects !== undefined && projects.length > MAX_PROJECT_ACKNOWLEDGEMENTS
66
67 const toggleProject = (ref: string, checked?: boolean | 'indeterminate') => {
68 setCheckedProjects((prev) => ({
69 ...prev,
70 [ref]: checked === undefined ? !prev[ref] : checked === true,
71 }))
72 }
73
74 const isDeletionConfirmed = () => {
75 // While project data is pending or unavailable, treat deletion as not confirmed
76 if (!projects) return false
77
78 if (projects.length === 0) return true
79
80 if (shouldRenderChecklist) {
81 return projects.every((p) => checkedProjects[p.ref])
82 }
83
84 if (exceedsLimit) {
85 return acknowledgedAll
86 }
87
88 return false
89 }
90
91 const allChecked = isDeletionConfirmed()
92
93 const [_, setLastVisitedOrganization] = useLocalStorageQuery(
94 LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
95 ''
96 )
97
98 const { can: canDeleteOrganization } = useAsyncCheckPermissions(
99 PermissionAction.UPDATE,
100 'organizations'
101 )
102
103 const { mutate: deleteOrganization, isPending: isDeleting } = useOrganizationDeleteMutation({
104 onSuccess: () => {
105 toast.success(`Successfully deleted ${orgName}`)
106 setLastVisitedOrganization('')
107 router.push('/organizations')
108 },
109 })
110
111 const onConfirmDelete = () => {
112 if (!canDeleteOrganization) {
113 toast.error('You do not have permission to delete this organization')
114 return
115 }
116
117 if (!orgSlug) {
118 console.error('Org slug is required')
119 return
120 }
121
122 if (isLoading || isFetching || isProjectsDataPending) {
123 toast.error('Projects are still loading, please wait')
124 return
125 }
126
127 if (isError) {
128 toast.error('Failed to load projects')
129 return
130 }
131
132 if (!allChecked) {
133 toast.error('Please acknowledge all projects before deleting the organization')
134 return
135 }
136
137 deleteOrganization({ slug: orgSlug })
138 }
139
140 return (
141 <>
142 <div className="mt-2">
143 <ButtonTooltip
144 type="danger"
145 disabled={!canDeleteOrganization || !orgSlug}
146 loading={!orgSlug}
147 onClick={() => {
148 setCheckedProjects({})
149 setAcknowledgedAll(false)
150 setIsOpen(true)
151 }}
152 tooltip={{
153 content: {
154 side: 'bottom',
155 text: !canDeleteOrganization
156 ? 'You need additional permissions to delete this organization'
157 : undefined,
158 },
159 }}
160 >
161 Delete organization
162 </ButtonTooltip>
163 </div>
164
165 <TextConfirmModal
166 visible={isOpen}
167 size="small"
168 variant="destructive"
169 title="Delete organization"
170 loading={isDeleting}
171 confirmString={orgSlug ?? ''}
172 confirmPlaceholder="Enter the string above"
173 confirmLabel="I understand, delete this organization"
174 onConfirm={onConfirmDelete}
175 onCancel={() => setIsOpen(false)}
176 >
177 {/* ≤ MAX → checklist */}
178 {shouldRenderChecklist && (
179 <DeleteOrganizationButtonListAck
180 projects={projects}
181 checkedProjects={checkedProjects}
182 toggleProject={toggleProject}
183 />
184 )}
185
186 {/* > MAX → single confirmation */}
187 {exceedsLimit && (
188 <DeleteOrganizationButtonSingleAck
189 acknowledgedAll={acknowledgedAll}
190 setAcknowledgedAll={setAcknowledgedAll}
191 max={MAX_PROJECT_ACKNOWLEDGEMENTS}
192 />
193 )}
194
195 {/* Final warning */}
196 <p
197 className={`text-sm text-foreground-lighter ${(projects?.length ?? 0) > 0 ? 'mt-4' : ''}`}
198 >
199 This action <span className="text-foreground">cannot</span> be undone. This will
200 permanently delete the <span className="text-foreground">{orgName}</span> organization and
201 remove all of its projects.
202 </p>
203 </TextConfirmModal>
204 </>
205 )
206}