Files
supabase/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
Gildas Garcia 5d97339d41 chore: remove <Select> _Shadcn_ suffix (#45988)
## Problem

The `_Shadcn_` suffix isn't needed anymore on `Select` components

## Solution

Remove it. No other changes

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Updated internal component architecture to standardize and simplify
the codebase. These changes improve code maintainability and consistency
across the application without affecting existing functionality or user
experience.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45988)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 16:39:57 +02:00

457 lines
17 KiB
TypeScript

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),
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>
)
}