Files
supabase/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
Alaister Young 5f533247e1 Update docs url to env var (#38772)
* Update Supabase docs URLs to use env variable

Co-authored-by: a <a@alaisteryoung.com>

* Refactor: Use DOCS_URL constant for documentation links

This change centralizes documentation links using a new DOCS_URL constant, improving maintainability and consistency.

Co-authored-by: a <a@alaisteryoung.com>

* Refactor: Use DOCS_URL constant for all documentation links

This change replaces hardcoded documentation URLs with a centralized constant, improving maintainability and consistency.

Co-authored-by: a <a@alaisteryoung.com>

* replace more instances

* ci: Autofix updates from GitHub workflow

* remaining instances

* fix duplicate useRouter

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: alaister <10985857+alaister@users.noreply.github.com>
2025-09-26 10:16:33 +00:00

337 lines
13 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
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 { 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 { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query'
import { doPermissionsCheck, useGetPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { DOCS_URL } from 'lib/constants'
import { useProfile } from 'lib/profile'
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
Select_Shadcn_,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
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 currentPlan = organization?.plan
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 { 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 { mutate: inviteMember, isLoading: isInviting } = useOrganizationCreateInvitationMutation()
const FormSchema = z.object({
email: z.string().email('Must be a valid email address').min(1, 'Email is required'),
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',
resolver: zodResolver(FormSchema),
defaultValues: { email: '', role: '', applyToOrg: true, projectRef: '' },
})
const { applyToOrg, projectRef } = form.watch()
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')
} else {
return toast('User is already in this organization')
}
}
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)
form.reset({
email: '',
role: developerRole?.id.toString() ?? '',
applyToOrg: true,
projectRef: '',
})
},
}
)
}
useEffect(() => {
if (isSuccess && isOpen) {
const developerRole = orgScopedRoles.find((role) => role.name === 'Developer')
if (developerRole !== undefined) form.setValue('role', developerRole.id.toString())
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, isOpen])
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<ButtonTooltip
type="primary"
disabled={!canInviteMembers}
className="pointer-events-auto flex-grow md:flex-grow-0"
onClick={() => setIsOpen(true)}
tooltip={{
content: {
side: 'bottom',
text: !organizationMembersCreationEnabled
? 'Inviting members is currently disabled'
: !canInviteMembers
? 'You need additional permissions to invite a member to this organization'
: undefined,
},
}}
>
Invite member
</ButtonTooltip>
</DialogTrigger>
<DialogContent size="medium">
<DialogHeader>
<DialogTitle>Invite a member to this organization</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<Form_Shadcn_ {...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">
{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">
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={(value) => form.setValue('role', value)}
>
<SelectTrigger_Shadcn_ className="text-sm capitalize">
{orgScopedRoles.find((role) => role.id === Number(field.value))?.name ??
'Unknown'}
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{orgScopedRoles.map((role) => {
const canAssignRole = rolesAddable.includes(role.id)
return (
<SelectItem_Shadcn_
key={role.id}
value={role.id.toString()}
className="text-sm [&>span:nth-child(2)]:w-full [&>span:nth-child(2)]:flex [&>span:nth-child(2)]:items-center [&>span:nth-child(2)]:justify-between"
disabled={!canAssignRole}
>
<span>{role.name}</span>
{!canAssignRole && (
<span>Additional permissions required to assign role</span>
)}
</SelectItem_Shadcn_>
)
})}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{!applyToOrg && (
<FormField_Shadcn_
name="projectRef"
control={form.control}
render={({ field }) => (
<FormItemLayout
label="Select a project"
description="You can assign roles to multiple projects once the invite is accepted"
>
<FormControl_Shadcn_>
<OrganizationProjectSelector
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_Shadcn_>
</FormItemLayout>
)}
/>
)}
<FormField_Shadcn_
name="email"
control={form.control}
render={({ field }) => (
<FormItemLayout label="Email address">
<FormControl_Shadcn_>
<Input_Shadcn_
autoFocus
{...field}
autoComplete="off"
disabled={isInviting}
placeholder="Enter email address"
/>
</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') && (
<Button asChild type="default">
<Link
href={`/org/${slug}/billing?panel=subscriptionPlan&source=inviteMemberSSO`}
>
Upgrade to Team
</Link>
</Button>
)}
</div>
</div>
}
/>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="pt-0">
<Button block htmlType="submit" loading={isInviting}>
Send invitation
</Button>
</DialogSection>
</form>
</Form_Shadcn_>
</DialogContent>
</Dialog>
)
}