MemberActions.tsx302 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { MoreVertical, Redo2, Trash } from 'lucide-react'
4import { useState } from 'react'
5import { toast } from 'sonner'
6import {
7 Button,
8 DropdownMenu,
9 DropdownMenuContent,
10 DropdownMenuSeparator,
11 DropdownMenuTrigger,
12} from 'ui'
13import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
14
15import { LeaveTeamButton } from './LeaveTeamButton'
16import { useGetRolesManagementPermissions } from './TeamSettings.utils'
17import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
18import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
19import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
20import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
21import { useOrganizationDeleteInvitationMutation } from '@/data/organization-members/organization-invitation-delete-mutation'
22import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
23import { useOrganizationMemberDeleteMutation } from '@/data/organizations/organization-member-delete-mutation'
24import {
25 useOrganizationMembersQuery,
26 type OrganizationMember,
27} from '@/data/organizations/organization-members-query'
28import { usePermissionsQuery } from '@/data/permissions/permissions-query'
29import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
30import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
31import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
32import { useProfile } from '@/lib/profile'
33
34interface MemberActionsProps {
35 member: OrganizationMember
36}
37
38export 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}