InviteMemberButton.tsx456 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useParams } from 'common'
4import { UserPlus } from 'lucide-react'
5import { useEffect, useState } from 'react'
6import { useForm } from 'react-hook-form'
7import { toast } from 'sonner'
8import {
9 Button,
10 Dialog,
11 DialogContent,
12 DialogFooter,
13 DialogHeader,
14 DialogSection,
15 DialogSectionSeparator,
16 DialogTitle,
17 DialogTrigger,
18 ExpandingTextArea,
19 Form,
20 FormControl,
21 FormField,
22 Select,
23 SelectContent,
24 SelectGroup,
25 SelectItem,
26 SelectTrigger,
27 SelectValue,
28 Switch,
29} from 'ui'
30import { Admonition } from 'ui-patterns/admonition'
31import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
32import * as z from 'zod'
33
34import {
35 BatchInvitationResult,
36 buildProjectPayload,
37 buildSsoPayload,
38 categorizeInviteEmails,
39 emailSchema,
40 parseEmails,
41} from './InviteMemberButton.utils'
42import { useGetRolesManagementPermissions } from './TeamSettings.utils'
43import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
44import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
45import { DocsButton } from '@/components/ui/DocsButton'
46import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
47import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
48import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
49import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
50import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
51import { useOrgSSOConfigQuery } from '@/data/sso/sso-config-query'
52import { useHasAccessToProjectLevelPermissions } from '@/data/subscriptions/org-subscription-query'
53import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
54import { doPermissionsCheck, useGetPermissions } from '@/hooks/misc/useCheckPermissions'
55import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
56import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
57import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
58import { DOCS_URL } from '@/lib/constants'
59import { MANAGED_BY } from '@/lib/constants/infrastructure'
60import { useProfile } from '@/lib/profile'
61
62export const InviteMemberButton = () => {
63 const { slug } = useParams()
64 const { profile } = useProfile()
65 const { data: organization } = useSelectedOrganizationQuery()
66 const { permissions: permissions } = useGetPermissions()
67
68 const { organizationMembersCreate: organizationMembersCreationEnabled } = useIsFeatureEnabled([
69 'organization_members:create',
70 ])
71
72 const [isOpen, setIsOpen] = useState(false)
73 const [projectDropdownOpen, setProjectDropdownOpen] = useState(false)
74
75 const { data: members } = useOrganizationMembersQuery({ slug })
76 const { data: allRoles, isSuccess } = useOrganizationRolesV2Query({ slug })
77 const orgScopedRoles = allRoles?.org_scoped_roles ?? []
78
79 const { data: ssoConfig } = useOrgSSOConfigQuery({ orgSlug: slug })
80 const hasSsoProvider = !!ssoConfig && ssoConfig !== null
81
82 const defaultValues = {
83 email: '',
84 role: orgScopedRoles.find((role) => role.name === 'Developer')?.id.toString() ?? '',
85 applyToOrg: true,
86 projectRef: '',
87 requireSso: 'auto' as const,
88 }
89
90 const { hasAccess: hasAccessToSso } = useCheckEntitlements('auth.platform.sso')
91 const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string)
92
93 const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id)
94 const hasOrgRole =
95 (userMemberData?.role_ids ?? []).length === 1 &&
96 orgScopedRoles.some((r) => r.id === userMemberData?.role_ids[0])
97
98 const isStripeProjectsOrg = organization?.managed_by === MANAGED_BY.STRIPE_PROJECTS
99
100 const { rolesAddable } = useGetRolesManagementPermissions(
101 organization?.slug,
102 orgScopedRoles,
103 permissions ?? []
104 )
105
106 const canInviteMembers =
107 hasOrgRole &&
108 rolesAddable.length > 0 &&
109 orgScopedRoles.some(({ id: role_id }) =>
110 doPermissionsCheck(
111 permissions,
112 PermissionAction.CREATE,
113 'user_invites',
114 { resource: { role_id } },
115 organization?.slug
116 )
117 )
118
119 const { mutateAsync: inviteMemberAsync, isPending: isInviting } =
120 useOrganizationCreateInvitationMutation()
121
122 const FormSchema = z
123 .object({
124 email: emailSchema,
125 role: z.string().min(1, 'Role is required'),
126 applyToOrg: z.boolean(),
127 projectRef: z.string(),
128 requireSso: z.enum(['auto', 'sso', 'non-sso']),
129 })
130 .superRefine((data, ctx) => {
131 if (!data.applyToOrg && !data.projectRef) {
132 ctx.addIssue({
133 code: z.ZodIssueCode.custom,
134 message: 'A project must be selected',
135 path: ['projectRef'],
136 })
137 }
138 })
139
140 const form = useForm<z.infer<typeof FormSchema>>({
141 mode: 'onSubmit',
142 reValidateMode: 'onChange',
143 resolver: zodResolver(FormSchema as any),
144 defaultValues,
145 })
146
147 const { applyToOrg, projectRef, email } = form.watch()
148
149 const emailCount = parseEmails(email ?? '').length
150
151 const onInviteMember = async (values: z.infer<typeof FormSchema>) => {
152 if (!slug) return console.error('Slug is required')
153 if (profile?.id === undefined) return console.error('Profile ID required')
154 const emails = parseEmails(values.email).map((e) => e.toLowerCase())
155
156 const { alreadyInvited, alreadyMembers, toInvite } = categorizeInviteEmails(
157 emails,
158 members ?? []
159 )
160
161 if (alreadyInvited.length > 0) {
162 toast.error(
163 alreadyInvited.length === 1
164 ? `${alreadyInvited[0]} has already been invited to this organization`
165 : `${alreadyInvited.length} emails have already been invited to this organization`
166 )
167 }
168 if (alreadyMembers.length > 0) {
169 toast.error(
170 alreadyMembers.length === 1
171 ? `${alreadyMembers[0]} is already in this organization`
172 : `${alreadyMembers.length} emails are already in this organization`
173 )
174 }
175 if (alreadyInvited.length > 0 || alreadyMembers.length > 0) {
176 if (toInvite.length === 0) return
177 }
178
179 const projectPayload = buildProjectPayload(values.applyToOrg, values.projectRef)
180 const ssoPayload = buildSsoPayload(values.requireSso)
181
182 let result: BatchInvitationResult
183 try {
184 result = (await inviteMemberAsync({
185 slug,
186 emails: toInvite,
187 roleId: Number(values.role),
188 ...projectPayload,
189 ...ssoPayload,
190 })) as BatchInvitationResult
191 } catch {
192 return // onError callback already showed the toast
193 }
194
195 const { succeeded, failed } = result
196
197 if (succeeded.length > 0) {
198 toast.success(
199 succeeded.length === 1
200 ? 'Successfully sent invitation to new member'
201 : `Successfully sent invitations to ${succeeded.length} new members`
202 )
203 }
204
205 for (const { email, error } of failed) {
206 toast.error(`Failed to invite ${email}: ${error}`)
207 }
208
209 if (succeeded.length > 0) {
210 closeInviteDialog()
211 }
212 }
213
214 useEffect(() => {
215 if (isSuccess && isOpen) {
216 const developerRoleId = orgScopedRoles
217 .find((role) => role.name === 'Developer')
218 ?.id.toString()
219
220 if (developerRoleId !== undefined && form.getValues('role') === '') {
221 form.setValue('role', developerRoleId, { shouldDirty: false })
222 }
223 }
224 // eslint-disable-next-line react-hooks/exhaustive-deps
225 }, [isSuccess, isOpen])
226
227 const hasUnsavedChanges = form.formState.isDirty
228
229 const closeInviteDialog = () => {
230 setProjectDropdownOpen(false)
231 setIsOpen(false)
232 form.reset(defaultValues)
233 }
234
235 const {
236 confirmOnClose,
237 handleOpenChange,
238 modalProps: discardChangesModalProps,
239 } = useConfirmOnClose({
240 checkIsDirty: () => hasUnsavedChanges,
241 onClose: closeInviteDialog,
242 })
243
244 return (
245 <Dialog open={isOpen} onOpenChange={handleOpenChange}>
246 <DialogTrigger asChild>
247 <ButtonTooltip
248 type="primary"
249 disabled={!canInviteMembers}
250 icon={<UserPlus size={14} />}
251 className="pointer-events-auto grow md:grow-0"
252 onClick={() => setIsOpen(true)}
253 tooltip={{
254 content: {
255 side: 'bottom',
256 text: !organizationMembersCreationEnabled
257 ? 'Inviting members is currently disabled'
258 : !canInviteMembers
259 ? 'You need additional permissions to invite members to this organization'
260 : undefined,
261 },
262 }}
263 >
264 Invite members
265 </ButtonTooltip>
266 </DialogTrigger>
267 <DialogContent size="medium">
268 <DialogHeader>
269 <DialogTitle>Invite team members</DialogTitle>
270 </DialogHeader>
271 <DialogSectionSeparator />
272 <Admonition
273 type="note"
274 showIcon={false}
275 title="Single Sign-On (SSO) available"
276 layout={!hasAccessToSso ? 'vertical' : 'horizontal'}
277 className="rounded-none border-t-0 border-x-0 px-5"
278 description="Enforce login via your company identity provider for added security and access control. Available on Team plan and above."
279 actions={
280 <>
281 <DocsButton href={`${DOCS_URL}/guides/platform/sso`} />
282 {!hasAccessToSso && (
283 <UpgradePlanButton
284 plan="Team"
285 source="inviteMemberSSO"
286 featureProposition="enable Single Sign-on (SSO)"
287 />
288 )}
289 </>
290 }
291 />
292 <Form {...form}>
293 <form
294 id="organization-invitation"
295 className="flex flex-col gap-y-4"
296 onSubmit={form.handleSubmit(onInviteMember)}
297 >
298 <DialogSection className="flex flex-col gap-y-4 pb-2">
299 <FormField
300 name="role"
301 control={form.control}
302 render={({ field }) => (
303 <FormItemLayout label="Role">
304 <FormControl>
305 <Select value={field.value} onValueChange={field.onChange}>
306 <SelectTrigger className="text-sm capitalize">
307 {orgScopedRoles.find((role) => role.id === Number(field.value))?.name ??
308 'Unknown'}
309 </SelectTrigger>
310 <SelectContent>
311 <SelectGroup>
312 {orgScopedRoles.map((role) => {
313 const canAssignRole = rolesAddable.includes(role.id)
314 const isOwnerRole = role.name === 'Owner'
315 const disabledForStripe = isStripeProjectsOrg && isOwnerRole
316 const disabled = !canAssignRole || disabledForStripe
317 const disabledReason = disabledForStripe
318 ? 'Cannot be assigned in Stripe Projects organizations'
319 : !canAssignRole
320 ? 'Additional permissions required to assign role'
321 : undefined
322
323 return (
324 <SelectItem
325 key={role.id}
326 value={role.id.toString()}
327 className="text-sm"
328 disabled={disabled}
329 >
330 <div className="flex flex-col gap-0.5">
331 <span>{role.name}</span>
332 {disabledReason && (
333 <span className="text-xs text-foreground-lighter">
334 {disabledReason}
335 </span>
336 )}
337 </div>
338 </SelectItem>
339 )
340 })}
341 </SelectGroup>
342 </SelectContent>
343 </Select>
344 </FormControl>
345 </FormItemLayout>
346 )}
347 />
348 {hasSsoProvider && (
349 <FormField
350 name="requireSso"
351 control={form.control}
352 render={({ field }) => (
353 <FormItemLayout
354 label="Invitation type"
355 description="Choose how the invitee should authenticate"
356 >
357 <FormControl>
358 <Select value={field.value} onValueChange={field.onChange}>
359 <SelectTrigger>
360 <SelectValue placeholder="Automatic (based on your account)" />
361 </SelectTrigger>
362 <SelectContent>
363 <SelectGroup>
364 <SelectItem value="auto">
365 Automatic (based on your account)
366 </SelectItem>
367 <SelectItem value="sso">Require SSO authentication</SelectItem>
368 <SelectItem value="non-sso">Email/password authentication</SelectItem>
369 </SelectGroup>
370 </SelectContent>
371 </Select>
372 </FormControl>
373 </FormItemLayout>
374 )}
375 />
376 )}
377 {hasAccessToProjectLevelPermissions && (
378 <FormField
379 name="applyToOrg"
380 control={form.control}
381 render={({ field }) => (
382 <FormItemLayout layout="flex" label="Grant this role on all projects">
383 <FormControl>
384 <Switch checked={field.value} onCheckedChange={field.onChange} />
385 </FormControl>
386 </FormItemLayout>
387 )}
388 />
389 )}
390 {!applyToOrg && (
391 <FormField
392 name="projectRef"
393 control={form.control}
394 render={({ field }) => (
395 <FormItemLayout
396 label="Select a project"
397 description="Project access can be adjusted after the user joins"
398 >
399 <FormControl>
400 <OrganizationProjectSelector
401 fetchOnMount
402 sameWidthAsTrigger
403 checkPosition="left"
404 selectedRef={projectRef}
405 open={projectDropdownOpen}
406 setOpen={setProjectDropdownOpen}
407 searchPlaceholder="Search project..."
408 onSelect={(project) => field.onChange(project.ref)}
409 onInitialLoad={(projects) => field.onChange(projects[0]?.ref ?? '')}
410 />
411 </FormControl>
412 </FormItemLayout>
413 )}
414 />
415 )}
416 <FormField
417 name="email"
418 control={form.control}
419 render={({ field }) => (
420 <FormItemLayout label="Email addresses">
421 <FormControl>
422 <ExpandingTextArea
423 autoFocus
424 {...field}
425 autoComplete="off"
426 disabled={isInviting}
427 placeholder="name@example.com, name2@example.com, ..."
428 className="max-h-48"
429 data-1p-ignore
430 data-lpignore="true"
431 data-form-type="other"
432 data-bwignore
433 />
434 </FormControl>
435 </FormItemLayout>
436 )}
437 />
438 </DialogSection>
439 <DialogFooter className="justify-between!">
440 <Button type="default" onClick={confirmOnClose}>
441 Cancel
442 </Button>
443 <Button type="primary" htmlType="submit" loading={isInviting}>
444 {emailCount >= 2 ? 'Send invitations' : 'Send invitation'}
445 </Button>
446 </DialogFooter>
447 </form>
448 </Form>
449 </DialogContent>
450 <DiscardChangesConfirmationDialog
451 {...discardChangesModalProps}
452 description="Are you sure you want to discard your changes? Your invitation will not be sent."
453 />
454 </Dialog>
455 )
456}