DeleteOrganizationButton.tsx 6.3 KB

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