MemberActions.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useParams } from 'common'
  3. import { MoreVertical, Redo2, Trash } from 'lucide-react'
  4. import { useState } from 'react'
  5. import { toast } from 'sonner'
  6. import {
  7. Button,
  8. DropdownMenu,
  9. DropdownMenuContent,
  10. DropdownMenuSeparator,
  11. DropdownMenuTrigger,
  12. } from 'ui'
  13. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  14. import { LeaveTeamButton } from './LeaveTeamButton'
  15. import { useGetRolesManagementPermissions } from './TeamSettings.utils'
  16. import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
  17. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  18. import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
  19. import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
  20. import { useOrganizationDeleteInvitationMutation } from '@/data/organization-members/organization-invitation-delete-mutation'
  21. import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
  22. import { useOrganizationMemberDeleteMutation } from '@/data/organizations/organization-member-delete-mutation'
  23. import {
  24. useOrganizationMembersQuery,
  25. type OrganizationMember,
  26. } from '@/data/organizations/organization-members-query'
  27. import { usePermissionsQuery } from '@/data/permissions/permissions-query'
  28. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  29. import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
  30. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  31. import { useProfile } from '@/lib/profile'
  32. interface MemberActionsProps {
  33. member: OrganizationMember
  34. }
  35. export const MemberActions = ({ member }: MemberActionsProps) => {
  36. const { slug } = useParams()
  37. const { profile } = useProfile()
  38. const [showAccessModal, setShowAccessModal] = useState(false)
  39. const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
  40. const organizationMembersDeletionEnabled = useIsFeatureEnabled('organization_members:delete')
  41. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  42. const { data: permissions } = usePermissionsQuery()
  43. const { data: allRoles } = useOrganizationRolesV2Query({ slug })
  44. const { data: members } = useOrganizationMembersQuery({ slug })
  45. const memberIsUser = member.gotrue_id == profile?.gotrue_id
  46. const orgScopedRoles = allRoles?.org_scoped_roles ?? []
  47. const projectScopedRoles = allRoles?.project_scoped_roles ?? []
  48. const isPendingInviteAcceptance = !!member.invited_id
  49. const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id)
  50. const hasOrgRole =
  51. (userMemberData?.role_ids ?? []).length === 1 &&
  52. orgScopedRoles.some((r) => r.id === userMemberData?.role_ids[0])
  53. const { rolesRemovable } = useGetRolesManagementPermissions(
  54. selectedOrganization?.slug,
  55. orgScopedRoles.concat(projectScopedRoles),
  56. permissions ?? []
  57. )
  58. const roleId = member.role_ids?.[0] ?? -1
  59. const canRemoveMember = member.role_ids.every((id) => rolesRemovable.includes(id))
  60. const { can: canCreateUserInvites } = useAsyncCheckPermissions(
  61. PermissionAction.CREATE,
  62. 'user_invites',
  63. { resource: { role_id: roleId } }
  64. )
  65. const canResendInvite = canCreateUserInvites && hasOrgRole
  66. const { can: canDeleteUserInvites } = useAsyncCheckPermissions(
  67. PermissionAction.DELETE,
  68. 'user_invites',
  69. { resource: { role_id: roleId } }
  70. )
  71. const canRevokeInvite = canDeleteUserInvites && hasOrgRole
  72. const { mutate: deleteOrganizationMember, isPending: isDeletingMember } =
  73. useOrganizationMemberDeleteMutation({
  74. onSuccess: () => {
  75. toast.success(`Successfully removed ${member.primary_email}`)
  76. setIsDeleteModalOpen(false)
  77. },
  78. })
  79. const { mutate: inviteMember, isPending: isCreatingInvite } =
  80. useOrganizationCreateInvitationMutation({
  81. onSuccess: () => {
  82. toast.success('Resent the invitation.')
  83. },
  84. onError: (error) => {
  85. toast.error(`Failed to resend invitation: ${error.message}`)
  86. },
  87. })
  88. const { mutate: deleteInvitation, isPending: isDeletingInvite } =
  89. useOrganizationDeleteInvitationMutation()
  90. const isLoading = isDeletingMember || isDeletingInvite || isCreatingInvite
  91. const handleMemberDelete = () => {
  92. if (!slug) return console.error('slug is required')
  93. if (!member.gotrue_id) return console.error('gotrue_id is required')
  94. deleteOrganizationMember({ slug, gotrueId: member.gotrue_id })
  95. }
  96. const handleResendInvite = (member: OrganizationMember) => {
  97. const roleId = (member?.role_ids ?? [])[0]
  98. const invitedId = member.invited_id
  99. if (!slug) return console.error('Slug is required')
  100. if (!invitedId) return console.error('Member invited ID is required')
  101. deleteInvitation(
  102. { slug, id: invitedId, skipInvalidation: true },
  103. {
  104. onSuccess: () => {
  105. if (!member.primary_email) return toast.error('Email is required')
  106. const projectScopedRole = projectScopedRoles.find((role) => role.id === roleId)
  107. if (projectScopedRole !== undefined) {
  108. const projects = projectScopedRole.projects.map(({ ref }) => ref)
  109. inviteMember({
  110. slug,
  111. emails: [member.primary_email],
  112. roleId: projectScopedRole.base_role_id,
  113. projects,
  114. })
  115. } else {
  116. inviteMember({ slug, emails: [member.primary_email], roleId })
  117. }
  118. },
  119. }
  120. )
  121. }
  122. const handleRevokeInvitation = (member: OrganizationMember) => {
  123. const invitedId = member.invited_id
  124. if (!slug) return console.error('Slug is required')
  125. if (!invitedId) return console.error('Member invited ID is required')
  126. deleteInvitation(
  127. { slug, id: invitedId },
  128. { onSuccess: () => toast.success('Successfully canceled the invitation') }
  129. )
  130. }
  131. if (memberIsUser) {
  132. return (
  133. <div className="flex items-center justify-end">
  134. <LeaveTeamButton />
  135. </div>
  136. )
  137. }
  138. return (
  139. <>
  140. <div className="flex items-center justify-end gap-x-2">
  141. <ButtonTooltip
  142. type="default"
  143. disabled={isPendingInviteAcceptance || !canRemoveMember}
  144. onClick={() => setShowAccessModal(true)}
  145. tooltip={{
  146. content: {
  147. side: 'bottom',
  148. text: isPendingInviteAcceptance
  149. ? 'Access can be managed after the invite is accepted'
  150. : !canRemoveMember
  151. ? 'You need additional permissions to manage this team member'
  152. : undefined,
  153. },
  154. }}
  155. >
  156. Manage access
  157. </ButtonTooltip>
  158. <DropdownMenu>
  159. <DropdownMenuTrigger asChild>
  160. <Button
  161. type="text"
  162. className="px-1.5"
  163. disabled={isLoading}
  164. loading={isLoading}
  165. icon={<MoreVertical />}
  166. />
  167. </DropdownMenuTrigger>
  168. <DropdownMenuContent side="bottom" align="end" className="w-40">
  169. <>
  170. {isPendingInviteAcceptance ? (
  171. <>
  172. <DropdownMenuItemTooltip
  173. className="gap-x-2"
  174. disabled={!canResendInvite}
  175. onClick={() => handleResendInvite(member)}
  176. tooltip={{
  177. content: {
  178. side: 'left',
  179. text: 'Additional permissions required to resend invitation',
  180. },
  181. }}
  182. >
  183. <Redo2 size={14} />
  184. <p>Resend invitation</p>
  185. </DropdownMenuItemTooltip>
  186. <DropdownMenuSeparator />
  187. <DropdownMenuItemTooltip
  188. className="gap-x-2"
  189. disabled={!canRevokeInvite}
  190. onClick={() => handleRevokeInvitation(member)}
  191. tooltip={{
  192. content: {
  193. side: 'left',
  194. text: 'Additional permissions required to cancel invitation',
  195. },
  196. }}
  197. >
  198. <Trash size={14} />
  199. <p>Cancel invitation</p>
  200. </DropdownMenuItemTooltip>
  201. </>
  202. ) : (
  203. organizationMembersDeletionEnabled && (
  204. <DropdownMenuItemTooltip
  205. className="gap-x-2"
  206. disabled={!canRemoveMember}
  207. onClick={() => setIsDeleteModalOpen(true)}
  208. tooltip={{
  209. content: {
  210. side: 'left',
  211. text: 'Additional permissions required to remove member',
  212. },
  213. }}
  214. >
  215. <Trash size={12} />
  216. <p>Remove member</p>
  217. </DropdownMenuItemTooltip>
  218. )
  219. )}
  220. </>
  221. </DropdownMenuContent>
  222. </DropdownMenu>
  223. </div>
  224. <ConfirmationModal
  225. size="large"
  226. visible={isDeleteModalOpen}
  227. loading={isDeletingMember}
  228. title="Confirm to remove member"
  229. confirmLabel="Remove"
  230. variant="warning"
  231. alert={{
  232. title: 'All user content from this member will be permanently removed.',
  233. description: (
  234. <div>
  235. Removing a member will delete all of the user's saved content in all projects of this
  236. organization, which includes:
  237. <ul className="list-disc pl-4 my-2">
  238. <li>
  239. SQL snippets{' '}
  240. <span className="text-foreground">
  241. (both <span className="underline">private</span> and{' '}
  242. <span className="underline">shared</span> snippets)
  243. </span>
  244. </li>
  245. <li>Custom reports</li>
  246. <li>Log Explorer queries</li>
  247. </ul>
  248. <p className="mt-4 text-foreground-lighter">
  249. If you'd like to retain the member's shared SQL snippets, right click on them and
  250. "Duplicate query" in the SQL Editor before removing this member.
  251. </p>
  252. </div>
  253. ),
  254. }}
  255. onCancel={() => setIsDeleteModalOpen(false)}
  256. onConfirm={() => {
  257. handleMemberDelete()
  258. }}
  259. >
  260. <p className="text-sm text-foreground-light">
  261. Are you sure you want to remove{' '}
  262. <span className="text-foreground">{member.primary_email}</span> from{' '}
  263. <span className="text-foreground">{selectedOrganization?.name}</span>?
  264. </p>
  265. </ConfirmationModal>
  266. <UpdateRolesPanel
  267. visible={showAccessModal}
  268. member={member}
  269. onClose={() => setShowAccessModal(false)}
  270. />
  271. </>
  272. )
  273. }