InviteMemberButton.tsx456 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { useParams } from 'common' |
| 4 | import { UserPlus } from 'lucide-react' |
| 5 | import { useEffect, useState } from 'react' |
| 6 | import { useForm } from 'react-hook-form' |
| 7 | import { toast } from 'sonner' |
| 8 | import { |
| 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' |
| 30 | import { Admonition } from 'ui-patterns/admonition' |
| 31 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 32 | import * as z from 'zod' |
| 33 | |
| 34 | import { |
| 35 | BatchInvitationResult, |
| 36 | buildProjectPayload, |
| 37 | buildSsoPayload, |
| 38 | categorizeInviteEmails, |
| 39 | emailSchema, |
| 40 | parseEmails, |
| 41 | } from './InviteMemberButton.utils' |
| 42 | import { useGetRolesManagementPermissions } from './TeamSettings.utils' |
| 43 | import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' |
| 44 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 45 | import { DocsButton } from '@/components/ui/DocsButton' |
| 46 | import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' |
| 47 | import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' |
| 48 | import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation' |
| 49 | import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query' |
| 50 | import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query' |
| 51 | import { useOrgSSOConfigQuery } from '@/data/sso/sso-config-query' |
| 52 | import { useHasAccessToProjectLevelPermissions } from '@/data/subscriptions/org-subscription-query' |
| 53 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 54 | import { doPermissionsCheck, useGetPermissions } from '@/hooks/misc/useCheckPermissions' |
| 55 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 56 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 57 | import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' |
| 58 | import { DOCS_URL } from '@/lib/constants' |
| 59 | import { MANAGED_BY } from '@/lib/constants/infrastructure' |
| 60 | import { useProfile } from '@/lib/profile' |
| 61 | |
| 62 | export 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 | } |