| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useParams } from 'common'
- import { UserPlus } from 'lucide-react'
- import { useEffect, useState } from 'react'
- import { useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogSection,
- DialogSectionSeparator,
- DialogTitle,
- DialogTrigger,
- ExpandingTextArea,
- Form,
- FormControl,
- FormField,
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
- Switch,
- } from 'ui'
- import { Admonition } from 'ui-patterns/admonition'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import * as z from 'zod'
- import {
- BatchInvitationResult,
- buildProjectPayload,
- buildSsoPayload,
- categorizeInviteEmails,
- emailSchema,
- parseEmails,
- } from './InviteMemberButton.utils'
- import { useGetRolesManagementPermissions } from './TeamSettings.utils'
- import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { DocsButton } from '@/components/ui/DocsButton'
- import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
- import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
- import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
- import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
- import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
- import { useOrgSSOConfigQuery } from '@/data/sso/sso-config-query'
- import { useHasAccessToProjectLevelPermissions } from '@/data/subscriptions/org-subscription-query'
- import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
- import { doPermissionsCheck, useGetPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
- import { DOCS_URL } from '@/lib/constants'
- import { MANAGED_BY } from '@/lib/constants/infrastructure'
- import { useProfile } from '@/lib/profile'
- export const InviteMemberButton = () => {
- const { slug } = useParams()
- const { profile } = useProfile()
- const { data: organization } = useSelectedOrganizationQuery()
- const { permissions: permissions } = useGetPermissions()
- const { organizationMembersCreate: organizationMembersCreationEnabled } = useIsFeatureEnabled([
- 'organization_members:create',
- ])
- const [isOpen, setIsOpen] = useState(false)
- const [projectDropdownOpen, setProjectDropdownOpen] = useState(false)
- const { data: members } = useOrganizationMembersQuery({ slug })
- const { data: allRoles, isSuccess } = useOrganizationRolesV2Query({ slug })
- const orgScopedRoles = allRoles?.org_scoped_roles ?? []
- const { data: ssoConfig } = useOrgSSOConfigQuery({ orgSlug: slug })
- const hasSsoProvider = !!ssoConfig && ssoConfig !== null
- const defaultValues = {
- email: '',
- role: orgScopedRoles.find((role) => role.name === 'Developer')?.id.toString() ?? '',
- applyToOrg: true,
- projectRef: '',
- requireSso: 'auto' as const,
- }
- const { hasAccess: hasAccessToSso } = useCheckEntitlements('auth.platform.sso')
- const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string)
- 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 isStripeProjectsOrg = organization?.managed_by === MANAGED_BY.STRIPE_PROJECTS
- const { rolesAddable } = useGetRolesManagementPermissions(
- organization?.slug,
- orgScopedRoles,
- permissions ?? []
- )
- const canInviteMembers =
- hasOrgRole &&
- rolesAddable.length > 0 &&
- orgScopedRoles.some(({ id: role_id }) =>
- doPermissionsCheck(
- permissions,
- PermissionAction.CREATE,
- 'user_invites',
- { resource: { role_id } },
- organization?.slug
- )
- )
- const { mutateAsync: inviteMemberAsync, isPending: isInviting } =
- useOrganizationCreateInvitationMutation()
- const FormSchema = z
- .object({
- email: emailSchema,
- role: z.string().min(1, 'Role is required'),
- applyToOrg: z.boolean(),
- projectRef: z.string(),
- requireSso: z.enum(['auto', 'sso', 'non-sso']),
- })
- .superRefine((data, ctx) => {
- if (!data.applyToOrg && !data.projectRef) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'A project must be selected',
- path: ['projectRef'],
- })
- }
- })
- const form = useForm<z.infer<typeof FormSchema>>({
- mode: 'onSubmit',
- reValidateMode: 'onChange',
- resolver: zodResolver(FormSchema as any),
- defaultValues,
- })
- const { applyToOrg, projectRef, email } = form.watch()
- const emailCount = parseEmails(email ?? '').length
- const onInviteMember = async (values: z.infer<typeof FormSchema>) => {
- if (!slug) return console.error('Slug is required')
- if (profile?.id === undefined) return console.error('Profile ID required')
- const emails = parseEmails(values.email).map((e) => e.toLowerCase())
- const { alreadyInvited, alreadyMembers, toInvite } = categorizeInviteEmails(
- emails,
- members ?? []
- )
- if (alreadyInvited.length > 0) {
- toast.error(
- alreadyInvited.length === 1
- ? `${alreadyInvited[0]} has already been invited to this organization`
- : `${alreadyInvited.length} emails have already been invited to this organization`
- )
- }
- if (alreadyMembers.length > 0) {
- toast.error(
- alreadyMembers.length === 1
- ? `${alreadyMembers[0]} is already in this organization`
- : `${alreadyMembers.length} emails are already in this organization`
- )
- }
- if (alreadyInvited.length > 0 || alreadyMembers.length > 0) {
- if (toInvite.length === 0) return
- }
- const projectPayload = buildProjectPayload(values.applyToOrg, values.projectRef)
- const ssoPayload = buildSsoPayload(values.requireSso)
- let result: BatchInvitationResult
- try {
- result = (await inviteMemberAsync({
- slug,
- emails: toInvite,
- roleId: Number(values.role),
- ...projectPayload,
- ...ssoPayload,
- })) as BatchInvitationResult
- } catch {
- return // onError callback already showed the toast
- }
- const { succeeded, failed } = result
- if (succeeded.length > 0) {
- toast.success(
- succeeded.length === 1
- ? 'Successfully sent invitation to new member'
- : `Successfully sent invitations to ${succeeded.length} new members`
- )
- }
- for (const { email, error } of failed) {
- toast.error(`Failed to invite ${email}: ${error}`)
- }
- if (succeeded.length > 0) {
- closeInviteDialog()
- }
- }
- useEffect(() => {
- if (isSuccess && isOpen) {
- const developerRoleId = orgScopedRoles
- .find((role) => role.name === 'Developer')
- ?.id.toString()
- if (developerRoleId !== undefined && form.getValues('role') === '') {
- form.setValue('role', developerRoleId, { shouldDirty: false })
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isSuccess, isOpen])
- const hasUnsavedChanges = form.formState.isDirty
- const closeInviteDialog = () => {
- setProjectDropdownOpen(false)
- setIsOpen(false)
- form.reset(defaultValues)
- }
- const {
- confirmOnClose,
- handleOpenChange,
- modalProps: discardChangesModalProps,
- } = useConfirmOnClose({
- checkIsDirty: () => hasUnsavedChanges,
- onClose: closeInviteDialog,
- })
- return (
- <Dialog open={isOpen} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- <ButtonTooltip
- type="primary"
- disabled={!canInviteMembers}
- icon={<UserPlus size={14} />}
- className="pointer-events-auto grow md:grow-0"
- onClick={() => setIsOpen(true)}
- tooltip={{
- content: {
- side: 'bottom',
- text: !organizationMembersCreationEnabled
- ? 'Inviting members is currently disabled'
- : !canInviteMembers
- ? 'You need additional permissions to invite members to this organization'
- : undefined,
- },
- }}
- >
- Invite members
- </ButtonTooltip>
- </DialogTrigger>
- <DialogContent size="medium">
- <DialogHeader>
- <DialogTitle>Invite team members</DialogTitle>
- </DialogHeader>
- <DialogSectionSeparator />
- <Admonition
- type="note"
- showIcon={false}
- title="Single Sign-On (SSO) available"
- layout={!hasAccessToSso ? 'vertical' : 'horizontal'}
- className="rounded-none border-t-0 border-x-0 px-5"
- description="Enforce login via your company identity provider for added security and access control. Available on Team plan and above."
- actions={
- <>
- <DocsButton href={`${DOCS_URL}/guides/platform/sso`} />
- {!hasAccessToSso && (
- <UpgradePlanButton
- plan="Team"
- source="inviteMemberSSO"
- featureProposition="enable Single Sign-on (SSO)"
- />
- )}
- </>
- }
- />
- <Form {...form}>
- <form
- id="organization-invitation"
- className="flex flex-col gap-y-4"
- onSubmit={form.handleSubmit(onInviteMember)}
- >
- <DialogSection className="flex flex-col gap-y-4 pb-2">
- <FormField
- name="role"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout label="Role">
- <FormControl>
- <Select value={field.value} onValueChange={field.onChange}>
- <SelectTrigger className="text-sm capitalize">
- {orgScopedRoles.find((role) => role.id === Number(field.value))?.name ??
- 'Unknown'}
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- {orgScopedRoles.map((role) => {
- const canAssignRole = rolesAddable.includes(role.id)
- const isOwnerRole = role.name === 'Owner'
- const disabledForStripe = isStripeProjectsOrg && isOwnerRole
- const disabled = !canAssignRole || disabledForStripe
- const disabledReason = disabledForStripe
- ? 'Cannot be assigned in Stripe Projects organizations'
- : !canAssignRole
- ? 'Additional permissions required to assign role'
- : undefined
- return (
- <SelectItem
- key={role.id}
- value={role.id.toString()}
- className="text-sm"
- disabled={disabled}
- >
- <div className="flex flex-col gap-0.5">
- <span>{role.name}</span>
- {disabledReason && (
- <span className="text-xs text-foreground-lighter">
- {disabledReason}
- </span>
- )}
- </div>
- </SelectItem>
- )
- })}
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- </FormItemLayout>
- )}
- />
- {hasSsoProvider && (
- <FormField
- name="requireSso"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout
- label="Invitation type"
- description="Choose how the invitee should authenticate"
- >
- <FormControl>
- <Select value={field.value} onValueChange={field.onChange}>
- <SelectTrigger>
- <SelectValue placeholder="Automatic (based on your account)" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="auto">
- Automatic (based on your account)
- </SelectItem>
- <SelectItem value="sso">Require SSO authentication</SelectItem>
- <SelectItem value="non-sso">Email/password authentication</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- </FormItemLayout>
- )}
- />
- )}
- {hasAccessToProjectLevelPermissions && (
- <FormField
- name="applyToOrg"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout layout="flex" label="Grant this role on all projects">
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItemLayout>
- )}
- />
- )}
- {!applyToOrg && (
- <FormField
- name="projectRef"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout
- label="Select a project"
- description="Project access can be adjusted after the user joins"
- >
- <FormControl>
- <OrganizationProjectSelector
- fetchOnMount
- sameWidthAsTrigger
- checkPosition="left"
- selectedRef={projectRef}
- open={projectDropdownOpen}
- setOpen={setProjectDropdownOpen}
- searchPlaceholder="Search project..."
- onSelect={(project) => field.onChange(project.ref)}
- onInitialLoad={(projects) => field.onChange(projects[0]?.ref ?? '')}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- )}
- <FormField
- name="email"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout label="Email addresses">
- <FormControl>
- <ExpandingTextArea
- autoFocus
- {...field}
- autoComplete="off"
- disabled={isInviting}
- placeholder="name@example.com, name2@example.com, ..."
- className="max-h-48"
- data-1p-ignore
- data-lpignore="true"
- data-form-type="other"
- data-bwignore
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </DialogSection>
- <DialogFooter className="justify-between!">
- <Button type="default" onClick={confirmOnClose}>
- Cancel
- </Button>
- <Button type="primary" htmlType="submit" loading={isInviting}>
- {emailCount >= 2 ? 'Send invitations' : 'Send invitation'}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- <DiscardChangesConfirmationDialog
- {...discardChangesModalProps}
- description="Are you sure you want to discard your changes? Your invitation will not be sent."
- />
- </Dialog>
- )
- }
|