| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useParams } from 'common'
- import { MoreVertical, Redo2, Trash } from 'lucide-react'
- import { useState } from 'react'
- import { toast } from 'sonner'
- import {
- Button,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- } from 'ui'
- import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
- import { LeaveTeamButton } from './LeaveTeamButton'
- import { useGetRolesManagementPermissions } from './TeamSettings.utils'
- import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
- import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
- import { useOrganizationDeleteInvitationMutation } from '@/data/organization-members/organization-invitation-delete-mutation'
- import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
- import { useOrganizationMemberDeleteMutation } from '@/data/organizations/organization-member-delete-mutation'
- import {
- useOrganizationMembersQuery,
- type OrganizationMember,
- } from '@/data/organizations/organization-members-query'
- import { usePermissionsQuery } from '@/data/permissions/permissions-query'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useProfile } from '@/lib/profile'
- interface MemberActionsProps {
- member: OrganizationMember
- }
- export const MemberActions = ({ member }: MemberActionsProps) => {
- const { slug } = useParams()
- const { profile } = useProfile()
- const [showAccessModal, setShowAccessModal] = useState(false)
- const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
- const organizationMembersDeletionEnabled = useIsFeatureEnabled('organization_members:delete')
- const { data: selectedOrganization } = useSelectedOrganizationQuery()
- const { data: permissions } = usePermissionsQuery()
- const { data: allRoles } = useOrganizationRolesV2Query({ slug })
- const { data: members } = useOrganizationMembersQuery({ slug })
- const memberIsUser = member.gotrue_id == profile?.gotrue_id
- const orgScopedRoles = allRoles?.org_scoped_roles ?? []
- const projectScopedRoles = allRoles?.project_scoped_roles ?? []
- const isPendingInviteAcceptance = !!member.invited_id
- const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id)
- const hasOrgRole =
- (userMemberData?.role_ids ?? []).length === 1 &&
- orgScopedRoles.some((r) => r.id === userMemberData?.role_ids[0])
- const { rolesRemovable } = useGetRolesManagementPermissions(
- selectedOrganization?.slug,
- orgScopedRoles.concat(projectScopedRoles),
- permissions ?? []
- )
- const roleId = member.role_ids?.[0] ?? -1
- const canRemoveMember = member.role_ids.every((id) => rolesRemovable.includes(id))
- const { can: canCreateUserInvites } = useAsyncCheckPermissions(
- PermissionAction.CREATE,
- 'user_invites',
- { resource: { role_id: roleId } }
- )
- const canResendInvite = canCreateUserInvites && hasOrgRole
- const { can: canDeleteUserInvites } = useAsyncCheckPermissions(
- PermissionAction.DELETE,
- 'user_invites',
- { resource: { role_id: roleId } }
- )
- const canRevokeInvite = canDeleteUserInvites && hasOrgRole
- const { mutate: deleteOrganizationMember, isPending: isDeletingMember } =
- useOrganizationMemberDeleteMutation({
- onSuccess: () => {
- toast.success(`Successfully removed ${member.primary_email}`)
- setIsDeleteModalOpen(false)
- },
- })
- const { mutate: inviteMember, isPending: isCreatingInvite } =
- useOrganizationCreateInvitationMutation({
- onSuccess: () => {
- toast.success('Resent the invitation.')
- },
- onError: (error) => {
- toast.error(`Failed to resend invitation: ${error.message}`)
- },
- })
- const { mutate: deleteInvitation, isPending: isDeletingInvite } =
- useOrganizationDeleteInvitationMutation()
- const isLoading = isDeletingMember || isDeletingInvite || isCreatingInvite
- const handleMemberDelete = () => {
- if (!slug) return console.error('slug is required')
- if (!member.gotrue_id) return console.error('gotrue_id is required')
- deleteOrganizationMember({ slug, gotrueId: member.gotrue_id })
- }
- const handleResendInvite = (member: OrganizationMember) => {
- const roleId = (member?.role_ids ?? [])[0]
- const invitedId = member.invited_id
- if (!slug) return console.error('Slug is required')
- if (!invitedId) return console.error('Member invited ID is required')
- deleteInvitation(
- { slug, id: invitedId, skipInvalidation: true },
- {
- onSuccess: () => {
- if (!member.primary_email) return toast.error('Email is required')
- const projectScopedRole = projectScopedRoles.find((role) => role.id === roleId)
- if (projectScopedRole !== undefined) {
- const projects = projectScopedRole.projects.map(({ ref }) => ref)
- inviteMember({
- slug,
- emails: [member.primary_email],
- roleId: projectScopedRole.base_role_id,
- projects,
- })
- } else {
- inviteMember({ slug, emails: [member.primary_email], roleId })
- }
- },
- }
- )
- }
- const handleRevokeInvitation = (member: OrganizationMember) => {
- const invitedId = member.invited_id
- if (!slug) return console.error('Slug is required')
- if (!invitedId) return console.error('Member invited ID is required')
- deleteInvitation(
- { slug, id: invitedId },
- { onSuccess: () => toast.success('Successfully canceled the invitation') }
- )
- }
- if (memberIsUser) {
- return (
- <div className="flex items-center justify-end">
- <LeaveTeamButton />
- </div>
- )
- }
- return (
- <>
- <div className="flex items-center justify-end gap-x-2">
- <ButtonTooltip
- type="default"
- disabled={isPendingInviteAcceptance || !canRemoveMember}
- onClick={() => setShowAccessModal(true)}
- tooltip={{
- content: {
- side: 'bottom',
- text: isPendingInviteAcceptance
- ? 'Access can be managed after the invite is accepted'
- : !canRemoveMember
- ? 'You need additional permissions to manage this team member'
- : undefined,
- },
- }}
- >
- Manage access
- </ButtonTooltip>
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- type="text"
- className="px-1.5"
- disabled={isLoading}
- loading={isLoading}
- icon={<MoreVertical />}
- />
- </DropdownMenuTrigger>
- <DropdownMenuContent side="bottom" align="end" className="w-40">
- <>
- {isPendingInviteAcceptance ? (
- <>
- <DropdownMenuItemTooltip
- className="gap-x-2"
- disabled={!canResendInvite}
- onClick={() => handleResendInvite(member)}
- tooltip={{
- content: {
- side: 'left',
- text: 'Additional permissions required to resend invitation',
- },
- }}
- >
- <Redo2 size={14} />
- <p>Resend invitation</p>
- </DropdownMenuItemTooltip>
- <DropdownMenuSeparator />
- <DropdownMenuItemTooltip
- className="gap-x-2"
- disabled={!canRevokeInvite}
- onClick={() => handleRevokeInvitation(member)}
- tooltip={{
- content: {
- side: 'left',
- text: 'Additional permissions required to cancel invitation',
- },
- }}
- >
- <Trash size={14} />
- <p>Cancel invitation</p>
- </DropdownMenuItemTooltip>
- </>
- ) : (
- organizationMembersDeletionEnabled && (
- <DropdownMenuItemTooltip
- className="gap-x-2"
- disabled={!canRemoveMember}
- onClick={() => setIsDeleteModalOpen(true)}
- tooltip={{
- content: {
- side: 'left',
- text: 'Additional permissions required to remove member',
- },
- }}
- >
- <Trash size={12} />
- <p>Remove member</p>
- </DropdownMenuItemTooltip>
- )
- )}
- </>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- <ConfirmationModal
- size="large"
- visible={isDeleteModalOpen}
- loading={isDeletingMember}
- title="Confirm to remove member"
- confirmLabel="Remove"
- variant="warning"
- alert={{
- title: 'All user content from this member will be permanently removed.',
- description: (
- <div>
- Removing a member will delete all of the user's saved content in all projects of this
- organization, which includes:
- <ul className="list-disc pl-4 my-2">
- <li>
- SQL snippets{' '}
- <span className="text-foreground">
- (both <span className="underline">private</span> and{' '}
- <span className="underline">shared</span> snippets)
- </span>
- </li>
- <li>Custom reports</li>
- <li>Log Explorer queries</li>
- </ul>
- <p className="mt-4 text-foreground-lighter">
- If you'd like to retain the member's shared SQL snippets, right click on them and
- "Duplicate query" in the SQL Editor before removing this member.
- </p>
- </div>
- ),
- }}
- onCancel={() => setIsDeleteModalOpen(false)}
- onConfirm={() => {
- handleMemberDelete()
- }}
- >
- <p className="text-sm text-foreground-light">
- Are you sure you want to remove{' '}
- <span className="text-foreground">{member.primary_email}</span> from{' '}
- <span className="text-foreground">{selectedOrganization?.name}</span>?
- </p>
- </ConfirmationModal>
- <UpdateRolesPanel
- visible={showAccessModal}
- member={member}
- onClose={() => setShowAccessModal(false)}
- />
- </>
- )
- }
|