mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
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 | | --- | --- | | <img width="808" height="691" alt="Supabase" src="https://github.com/user-attachments/assets/f6450c26-968c-4ee8-bb7c-d6f6a3af1209" /> | <img width="808" height="691" alt="8298" src="https://github.com/user-attachments/assets/2d7e3869-b9d4-4a4f-89aa-ae55d67e794b" /> | | <img width="1024" height="560" alt="Supabase" src="https://github.com/user-attachments/assets/9a255167-fcd3-4294-ba3a-9160bd500cff" /> | <img width="1024" height="560" alt="Supabase" src="https://github.com/user-attachments/assets/daba5bb8-eb12-43dc-86de-217e9baf0b72" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
aa1aa73200
commit
be7cb44e01
5 files changed
+301
-178
No files matched your search
+236
-100
@@ -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<z.infer<typeof FormSchema>>({
|
||||
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<typeof FormSchema>) => {
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
disabled={!canInviteMembers}
|
||||
icon={<UserPlus size={14} />}
|
||||
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
|
||||
</ButtonTooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent size="medium">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite a member to this organization</DialogTitle>
|
||||
<DialogTitle>Invite team members</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<Admonition
|
||||
type="note"
|
||||
showIcon={false}
|
||||
title="Single Sign-On (SSO) available"
|
||||
layout={isFreeOrProPlan ? '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={
|
||||
<>
|
||||
<Button asChild type="default">
|
||||
<Link href={`${DOCS_URL}/guides/platform/sso`} target="_blank" rel="noreferrer">
|
||||
Learn more
|
||||
</Link>
|
||||
</Button>
|
||||
{isFreeOrProPlan ? (
|
||||
<UpgradePlanButton
|
||||
plan="Team"
|
||||
source="inviteMemberSSO"
|
||||
featureProposition="enable Single Sign-on (SSO)"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id="organization-invitation"
|
||||
@@ -186,30 +335,11 @@ export const InviteMemberButton = () => {
|
||||
onSubmit={form.handleSubmit(onInviteMember)}
|
||||
>
|
||||
<DialogSection className="flex flex-col gap-y-4 pb-2">
|
||||
{hasAccessToProjectLevelPermissions && (
|
||||
<FormField_Shadcn_
|
||||
name="applyToOrg"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex"
|
||||
label="Apply role to all projects in the organization"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => form.setValue('applyToOrg', value)}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField_Shadcn_
|
||||
name="role"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Member role">
|
||||
<FormItemLayout label="Role">
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_
|
||||
value={field.value}
|
||||
@@ -245,6 +375,22 @@ export const InviteMemberButton = () => {
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{hasAccessToProjectLevelPermissions && (
|
||||
<FormField_Shadcn_
|
||||
name="applyToOrg"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="flex" label="Grant this role on all projects">
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => form.setValue('applyToOrg', value)}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!applyToOrg && (
|
||||
<FormField_Shadcn_
|
||||
name="projectRef"
|
||||
@@ -252,7 +398,7 @@ export const InviteMemberButton = () => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Select a project"
|
||||
description="You can assign roles to multiple projects once the invite is accepted"
|
||||
description="Project access can be adjusted after the user joins"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<OrganizationProjectSelector
|
||||
@@ -275,62 +421,52 @@ export const InviteMemberButton = () => {
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Email address">
|
||||
<FormItemLayout label="Email addresses">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
<ExpandingTextArea
|
||||
autoFocus
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
disabled={isInviting}
|
||||
placeholder="Enter email address"
|
||||
placeholder="name@example.com, name2@example.com, ..."
|
||||
className="max-h-48"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
data-bwignore
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<InformationBox
|
||||
defaultVisibility={false}
|
||||
title="Single Sign-on (SSO) login option available"
|
||||
hideCollapse={false}
|
||||
description={
|
||||
<div className="space-y-4 mb-1">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>This is only available for organizations on Team Plan or above.</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`${DOCS_URL}/guides/platform/sso`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Button>
|
||||
{(currentPlan?.id === 'free' || currentPlan?.id === 'pro') && (
|
||||
<UpgradePlanButton
|
||||
plan="Team"
|
||||
source="inviteMemberSSO"
|
||||
featureProposition="enable Single Sign-on (SSO)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DialogSection>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="pt-0">
|
||||
<Button block htmlType="submit" loading={isInviting}>
|
||||
Send invitation
|
||||
<DialogFooter className="!justify-between">
|
||||
<Button type="default" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogSection>
|
||||
<Button type="primary" htmlType="submit" loading={isInviting}>
|
||||
{emailCount >= 2 ? 'Send invitations' : 'Send invitation'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogContent>
|
||||
<AlertDialog open={isDiscardConfirmOpen} onOpenChange={setIsDiscardConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to discard your changes? Your invitation will not be sent.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||
<AlertDialogAction variant="danger" onClick={handleDiscardConfirm}>
|
||||
Discard changes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex item-center gap-x-2">
|
||||
<div className="flex item-center gap-x-3">
|
||||
<p className="text-foreground-light truncate">{member.primary_email}</p>
|
||||
{member.gotrue_id === profile?.gotrue_id && <Badge>You</Badge>}
|
||||
<div className="flex items-center gap-x-2">
|
||||
{member.gotrue_id === profile?.gotrue_id && <Badge>You</Badge>}
|
||||
{isInvitedUser && member.invited_at && (
|
||||
<Badge variant={isInviteExpired(member.invited_at) ? 'destructive' : 'warning'}>
|
||||
{isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'}
|
||||
</Badge>
|
||||
)}
|
||||
{member.is_sso_user && <Badge variant="default">SSO</Badge>}
|
||||
{(member.metadata as any)?.origin && (
|
||||
<PartnerIcon
|
||||
organization={{
|
||||
managed_by:
|
||||
MEMBER_ORIGIN_TO_MANAGED_BY[
|
||||
(member.metadata as any).origin as keyof typeof MEMBER_ORIGIN_TO_MANAGED_BY
|
||||
] ?? 'supabase',
|
||||
}}
|
||||
tooltipText="Managed by Vercel Marketplace."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(member.metadata as any)?.origin && (
|
||||
<PartnerIcon
|
||||
organization={{
|
||||
managed_by:
|
||||
MEMBER_ORIGIN_TO_MANAGED_BY[
|
||||
(member.metadata as any).origin as keyof typeof MEMBER_ORIGIN_TO_MANAGED_BY
|
||||
] ?? 'supabase',
|
||||
}}
|
||||
tooltipText="This user is managed by Vercel Marketplace."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{isInvitedUser && member.invited_at && (
|
||||
<Badge variant={isInviteExpired(member.invited_at) ? 'destructive' : 'warning'}>
|
||||
{isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'}
|
||||
</Badge>
|
||||
)}
|
||||
{member.is_sso_user && <Badge variant="default">SSO</Badge>}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{member.mfa_enabled ? (
|
||||
<Check className="text-brand" strokeWidth={2} size={20} />
|
||||
<>
|
||||
<span className="text-foreground-lighter">Enabled</span>
|
||||
<Check className="text-brand" strokeWidth={2} size={16} />
|
||||
</>
|
||||
) : (
|
||||
<X className="text-foreground-light" strokeWidth={1.5} size={20} />
|
||||
<>
|
||||
<span className="text-foreground-lighter">Disabled</span>
|
||||
<X className="text-foreground-muted" strokeWidth={1.5} size={16} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -130,18 +134,18 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
|
||||
return (
|
||||
<div key={`role-${id}`} className="flex items-center gap-x-2">
|
||||
<p>{roleName}</p>
|
||||
<p className="text-foreground-light">{roleName}</p>
|
||||
{hasProjectScopedRoles && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<ChevronRight className="text-foreground-muted/50" size={14} />
|
||||
{projectsApplied.length === 1 ? (
|
||||
<span className="text-foreground truncate" title={projectsApplied[0]}>
|
||||
<span className="text-foreground-light truncate" title={projectsApplied[0]}>
|
||||
{projectsApplied[0]}
|
||||
</span>
|
||||
) : (
|
||||
<HoverCard_Shadcn_ openDelay={200}>
|
||||
<HoverCardTrigger_Shadcn_ asChild>
|
||||
<span className="text-foreground">
|
||||
<span className="text-foreground-light">
|
||||
{role?.projects.length === 0
|
||||
? 'Organization'
|
||||
: `${projectsApplied.length} project${projectsApplied.length > 1 ? 's' : ''}`}
|
||||
|
||||
@@ -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) => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="header-user">User</TableHead>
|
||||
<TableHead key="header-status" className="w-24" />
|
||||
<TableHead key="header-mfa" className="text-center w-32">
|
||||
Enabled MFA
|
||||
</TableHead>
|
||||
<TableHead key="header-role" className="flex items-center space-x-1">
|
||||
<span>Role</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild type="text" className="px-1">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`${DOCS_URL}/guides/platform/access-control`}
|
||||
>
|
||||
<HelpCircle size={14} className="text-foreground-light" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
How to configure access control?
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableHead>
|
||||
<TableHead key="header-user">Member</TableHead>
|
||||
<TableHead key="header-mfa">MFA</TableHead>
|
||||
<TableHead key="header-role">Role</TableHead>
|
||||
<TableHead key="header-action" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -138,9 +119,9 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
<TableCell colSpan={12} className="!p-0">
|
||||
<Admonition
|
||||
type="note"
|
||||
title="You are currently assigned with project scoped roles in this organization"
|
||||
description="All other members within the organization will not be visible to you"
|
||||
className="bg-alternative border-0 rounded-none"
|
||||
title="You have limited visibility in this organization"
|
||||
description="Your access is limited to specific projects, so you can’t see all members or settings."
|
||||
className="border-0 rounded-none"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
@@ -157,23 +138,24 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
<div className="flex items-center space-x-3 opacity-75">
|
||||
<AlertCircle size={16} strokeWidth={2} />
|
||||
<p className="text-foreground-light">
|
||||
No users matched the search query "{searchString}"
|
||||
No members matched the search query "{searchString}"
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
]
|
||||
: []),
|
||||
<TableRow key="footer" className="bg-panel-secondary-light">
|
||||
<TableCell colSpan={12}>
|
||||
<p className="text-foreground-light">
|
||||
{searchString ? `${filteredMembers.length} of ` : ''}
|
||||
{members.length || '0'} {members.length == 1 ? 'user' : 'users'}
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
]}
|
||||
</TableBody>
|
||||
<TableFooter className="font-normal">
|
||||
<TableRow className="border-b-0 [&>td]:hover:bg-inherit">
|
||||
<TableCell colSpan={4} className="text-foreground-muted">
|
||||
{searchString
|
||||
? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}`
|
||||
: `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Loading>
|
||||
</Card>
|
||||
|
||||
@@ -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<HTMLTextAreaElement, ExpandingTextAreaProps
|
||||
const updateTextAreaHeight = (element: HTMLTextAreaElement | null) => {
|
||||
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 (
|
||||
<TextArea
|
||||
ref={(element) => {
|
||||
|
||||
Reference in new issue
Block a user