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