From be7cb44e01bfd0900a0993c4bc4aa48b0eb6b8c3 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:27:53 +1100 Subject: [PATCH 1/5] feat(studio): invite multiple team members at once (#42637) ## What kind of change does this PR introduce? - Feature - Resolves DEPR-355 ## What is the current behavior? Only one email address can be invited to an organization at a time. ## What is the new behavior? - Multiple email addresses can be invited (at a single scope) to an organization at one time - List of email addresses detected via comma-separation - Pluralization on fields and labels - Table and copywriting cleanup | Before | After | | --- | --- | | Supabase | 8298 | | Supabase | Supabase | ## Summary by CodeRabbit * **New Features** * Added support for inviting multiple team members simultaneously via comma-separated emails. * **Improvements** * Enhanced member management interface with clearer status indicators (You, Invited, SSO, MFA enabled/disabled). * Improved feedback messages for invitation outcomes and member status changes. * Updated member table layout with summary footer displaying member count. --- .../TeamSettings/InviteMemberButton.tsx | 336 ++++++++++++------ .../TeamSettings/MemberActions.tsx | 4 +- .../Organization/TeamSettings/MemberRow.tsx | 66 ++-- .../Organization/TeamSettings/MembersView.tsx | 54 +-- .../components/ExpandingTextArea/index.tsx | 19 +- 5 files changed, 301 insertions(+), 178 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 4e57195e7f1a6..dbd847a86b025 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -8,7 +8,6 @@ import * as z from 'zod' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import InformationBox from 'components/ui/InformationBox' import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation' @@ -21,6 +20,14 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { DOCS_URL } from 'lib/constants' import { useProfile } from 'lib/profile' import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Button, Dialog, DialogContent, @@ -29,10 +36,11 @@ import { DialogSectionSeparator, DialogTitle, DialogTrigger, + ExpandingTextArea, FormControl_Shadcn_, FormField_Shadcn_, + DialogFooter, Form_Shadcn_, - Input_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, @@ -42,6 +50,15 @@ import { } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { useGetRolesManagementPermissions } from './TeamSettings.utils' +import { UserPlus } from 'lucide-react' +import { Admonition } from 'ui-patterns' + +function parseEmails(value: string): string[] { + return value + .split(',') + .map((e) => e.trim()) + .filter(Boolean) +} export const InviteMemberButton = () => { const { slug } = useParams() @@ -54,6 +71,7 @@ export const InviteMemberButton = () => { ]) const [isOpen, setIsOpen] = useState(false) + const [isDiscardConfirmOpen, setIsDiscardConfirmOpen] = useState(false) const [projectDropdownOpen, setProjectDropdownOpen] = useState(false) const { data: members } = useOrganizationMembersQuery({ slug }) @@ -61,6 +79,7 @@ export const InviteMemberButton = () => { const orgScopedRoles = allRoles?.org_scoped_roles ?? [] const currentPlan = organization?.plan + const isFreeOrProPlan = currentPlan?.id === 'free' || currentPlan?.id === 'pro' const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id) @@ -87,77 +106,183 @@ export const InviteMemberButton = () => { ) ) - const { mutate: inviteMember, isPending: isInviting } = useOrganizationCreateInvitationMutation() + const { mutateAsync: inviteMemberAsync, isPending: isInviting } = + useOrganizationCreateInvitationMutation() + + const emailSchema = z + .string() + .min(1, 'At least one email address is required') + .refine( + (val) => { + const emails = parseEmails(val) + if (emails.length === 0) return false + return emails.every((e) => z.string().email().safeParse(e).success) + }, + (val) => { + const emails = parseEmails(val) + const invalid = emails.find((e) => !z.string().email().safeParse(e).success) + return { + message: invalid + ? `Invalid email address: ${invalid}` + : 'At least one email address is required', + } + } + ) const FormSchema = z.object({ - email: z.string().email('Must be a valid email address').min(1, 'Email is required'), + email: emailSchema, role: z.string().min(1, 'Role is required'), applyToOrg: z.boolean(), projectRef: z.string(), }) const form = useForm>({ - mode: 'onBlur', - reValidateMode: 'onBlur', + mode: 'onSubmit', + reValidateMode: 'onChange', resolver: zodResolver(FormSchema), defaultValues: { email: '', role: '', applyToOrg: true, projectRef: '' }, }) - const { applyToOrg, projectRef } = form.watch() + const { applyToOrg, projectRef, email } = form.watch() + + const emailCount = parseEmails(email ?? '').length const onInviteMember = async (values: z.infer) => { if (!slug) return console.error('Slug is required') if (profile?.id === undefined) return console.error('Profile ID required') const developerRole = orgScopedRoles.find((role) => role.name === 'Developer') - const existingMember = (members ?? []).find( - (member) => member.primary_email === values.email.toLowerCase() - ) - if (existingMember !== undefined) { - if (existingMember.invited_id) { - return toast('User has already been invited to this organization') + const emails = parseEmails(values.email).map((e) => e.toLowerCase()) + + const alreadyInvited: string[] = [] + const alreadyMembers: string[] = [] + const toInvite: string[] = [] + + for (const emailAddress of emails) { + const existingMember = (members ?? []).find((member) => member.primary_email === emailAddress) + if (existingMember !== undefined) { + if (existingMember.invited_id) { + alreadyInvited.push(emailAddress) + } else { + alreadyMembers.push(emailAddress) + } } else { - return toast('User is already in this organization') + toInvite.push(emailAddress) } } - inviteMember( - { - slug, - email: values.email.toLowerCase(), - roleId: Number(values.role), - ...(!values.applyToOrg && values.projectRef ? { projects: [values.projectRef] } : {}), - }, - { - onSuccess: () => { - toast.success('Successfully sent invitation to new member') - setIsOpen(!isOpen) + 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 + } - form.reset({ - email: '', - role: developerRole?.id.toString() ?? '', - applyToOrg: true, - projectRef: '', - }) - }, - } + const projectPayload = + !values.applyToOrg && values.projectRef ? { projects: [values.projectRef] } : {} + const results = await Promise.allSettled( + toInvite.map((emailAddress) => + inviteMemberAsync({ + slug, + email: emailAddress, + roleId: Number(values.role), + ...projectPayload, + }) + ) ) + + const successCount = results.filter((r) => r.status === 'fulfilled').length + const failedEmails = toInvite.filter((_, i) => results[i].status === 'rejected') + + if (successCount > 0) { + toast.success( + successCount === 1 + ? 'Successfully sent invitation to new member' + : `Successfully sent invitations to ${successCount} new members` + ) + setIsOpen(false) + form.reset({ + email: '', + role: developerRole?.id.toString() ?? '', + applyToOrg: true, + projectRef: '', + }) + } + if (failedEmails.length > 0) { + toast.error( + failedEmails.length === 1 + ? `Failed to send invitation to ${failedEmails[0]}` + : `Failed to send invitations to ${failedEmails.length} emails` + ) + } } useEffect(() => { if (isSuccess && isOpen) { const developerRole = orgScopedRoles.find((role) => role.name === 'Developer') - if (developerRole !== undefined) form.setValue('role', developerRole.id.toString()) + if (developerRole !== undefined) { + form.reset({ + ...form.getValues(), + role: developerRole.id.toString(), + }) + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, isOpen]) + const hasUnsavedChanges = form.formState.isDirty + + const handleOpenChange = (open: boolean) => { + if (!open && hasUnsavedChanges) { + setIsDiscardConfirmOpen(true) + } else { + setIsOpen(open) + } + } + + const handleCancel = () => { + if (hasUnsavedChanges) { + setIsDiscardConfirmOpen(true) + } else { + form.reset({ + email: '', + role: '', + applyToOrg: true, + projectRef: '', + }) + setIsOpen(false) + } + } + + const handleDiscardConfirm = () => { + form.reset({ + email: '', + role: '', + applyToOrg: true, + projectRef: '', + }) + setIsDiscardConfirmOpen(false) + setIsOpen(false) + } + return ( - + } className="pointer-events-auto flex-grow md:flex-grow-0" onClick={() => setIsOpen(true)} tooltip={{ @@ -166,19 +291,43 @@ export const InviteMemberButton = () => { text: !organizationMembersCreationEnabled ? 'Inviting members is currently disabled' : !canInviteMembers - ? 'You need additional permissions to invite a member to this organization' + ? 'You need additional permissions to invite members to this organization' : undefined, }, }} > - Invite member + Invite members - Invite a member to this organization + Invite team members + + + {isFreeOrProPlan ? ( + + ) : null} + + } + />
{ onSubmit={form.handleSubmit(onInviteMember)} > - {hasAccessToProjectLevelPermissions && ( - ( - - - form.setValue('applyToOrg', value)} - /> - - - )} - /> - )} ( - + { )} /> + {hasAccessToProjectLevelPermissions && ( + ( + + + form.setValue('applyToOrg', value)} + /> + + + )} + /> + )} {!applyToOrg && ( { render={({ field }) => ( { name="email" control={form.control} render={({ field }) => ( - + - )} /> - -

- Supabase offers single sign-on (SSO) as a login option to provide additional - account security for your team. This allows company administrators to enforce - the use of an identity provider when logging into Supabase. -

-

This is only available for organizations on Team Plan or above.

-
- - {(currentPlan?.id === 'free' || currentPlan?.id === 'pro') && ( - - )} -
- - } - />
- - - - + +
+ + + + Discard changes? + + Are you sure you want to discard your changes? Your invitation will not be sent. + + + + Keep editing + + Discard changes + + + +
) } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 5b9c425d55f94..5daac9c1745ed 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -147,7 +147,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => { deleteInvitation( { slug, id: invitedId }, - { onSuccess: () => toast.success('Successfully revoked the invitation.') } + { onSuccess: () => toast.success('Successfully canceled the invitation') } ) } @@ -170,7 +170,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => { content: { side: 'bottom', text: isPendingInviteAcceptance - ? 'Role can only be changed after the user has accepted the invite' + ? 'Access can be managed after the invite is accepted' : !canRemoveMember ? 'You need additional permissions to manage this team member' : undefined, diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index 5dea2402437de..083253d99fc1f 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Check, User, X } from 'lucide-react' +import { ArrowRight, Check, User, X, ChevronRight } from 'lucide-react' import Link from 'next/link' import { useMemo } from 'react' @@ -72,40 +72,44 @@ export const MemberRow = ({ member }: MemberRowProps) => { } /> -
+

{member.primary_email}

- {member.gotrue_id === profile?.gotrue_id && You} +
+ {member.gotrue_id === profile?.gotrue_id && You} + {isInvitedUser && member.invited_at && ( + + {isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'} + + )} + {member.is_sso_user && SSO} + {(member.metadata as any)?.origin && ( + + )} +
- - {(member.metadata as any)?.origin && ( - - )}
- {isInvitedUser && member.invited_at && ( - - {isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'} - - )} - {member.is_sso_user && SSO} - - - -
+
{member.mfa_enabled ? ( - + <> + Enabled + + ) : ( - + <> + Disabled + + )}
@@ -130,18 +134,18 @@ export const MemberRow = ({ member }: MemberRowProps) => { return (
-

{roleName}

+

{roleName}

{hasProjectScopedRoles && ( <> - + {projectsApplied.length === 1 ? ( - + {projectsApplied[0]} ) : ( - + {role?.projects.length === 0 ? 'Organization' : `${projectsApplied.length} project${projectsApplied.length > 1 ? 's' : ''}`} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 97042788b4c0c..3ccf0d018b0dd 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, + TableFooter, TableHead, TableHeader, TableRow, @@ -62,6 +63,7 @@ const MembersView = ({ searchString }: MembersViewProps) => { member.username.includes(searchString) || member.primary_email?.includes(searchString) ) } + return false }) }, [members, searchString]) @@ -102,30 +104,9 @@ const MembersView = ({ searchString }: MembersViewProps) => { - User - - - Enabled MFA - - - Role - - - - - - How to configure access control? - - - + Member + MFA + Role @@ -138,9 +119,9 @@ const MembersView = ({ searchString }: MembersViewProps) => { , @@ -157,23 +138,24 @@ const MembersView = ({ searchString }: MembersViewProps) => {

- No users matched the search query "{searchString}" + No members matched the search query "{searchString}"

, ] : []), - - -

- {searchString ? `${filteredMembers.length} of ` : ''} - {members.length || '0'} {members.length == 1 ? 'user' : 'users'} -

-
-
, ]} + + + + {searchString + ? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}` + : `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`} + + +
diff --git a/packages/ui/src/components/ExpandingTextArea/index.tsx b/packages/ui/src/components/ExpandingTextArea/index.tsx index 03abc44338dcb..48b8755dd1ff2 100644 --- a/packages/ui/src/components/ExpandingTextArea/index.tsx +++ b/packages/ui/src/components/ExpandingTextArea/index.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { forwardRef, useImperativeHandle, useRef } from 'react' +import React, { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from 'react' import { cn } from '../../lib/utils' import { TextArea } from '../shadcn/ui/text-area' @@ -22,16 +22,17 @@ const ExpandingTextArea = forwardRef { if (!element) return - // Update the height - if (!value) { - element.style.height = 'auto' - element.style.minHeight = '36px' - } else { - element.style.height = 'auto' - element.style.height = element.scrollHeight + 'px' - } + // Match single-line input height (h-10 = 40px) so we don't shrink when typing; grow only when content wraps + const singleLineHeightPx = 40 + element.style.height = 'auto' + const contentHeight = element.scrollHeight + element.style.height = Math.max(singleLineHeightPx, contentHeight) + 'px' } + useLayoutEffect(() => { + updateTextAreaHeight(internalRef.current) + }, [value]) + return (