Files
Tanun Turbo Chalermsinsuwan 77e3b4382f feat(role): Allow eligible organizations to invite users as 'No-access' base role (#50922)
## Problem

As the API has allow inviting users into `None / No-access` role for
team, enterprise, and platform tier organization, we need to update the
documentation and descriptions for this new role on the invitation form.

## Solution

1. Updated `apps/docs/content/guides/platform/access-control.mdx` to
include the role
2. Added the role description on
`apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx`
3. Add the roles into the proper sorting order at
`apps/studio/data/organization-members/organization-roles-query.ts`
4. Add logic to invitation components to disable the role when inviting
user into project(s), as the backend does not allow it.

## Testing and verification steps
The UI:
https://studio-staging-aa8is1m07-supabase.vercel.app/dashboard/org
Documentation:
https://docs-kht98bi78-supabase.vercel.app/docs/guides/platform/access-control

<!--
## Preview links

If relevant, include links to changed pages for easy review access.

Copy the preview base URL from the Vercel bot comment on this PR. Use
the following table as an example template.

| Site | Live | Preview | Search for |
| -------------- |
-------------------------------------------------------------------------
|
------------------------------------------------------------------------------------------------------------
| ----------------------------- |
| WWW | [/blog/your-post](https://supabase.com/blog/your-post) |
[/blog/your-post](https://zone-www-dot-com-git-branch-name-supabase.vercel.app/blog/your-post)
| unique phrase from the change |
| Docs |
[/docs/guides/your-page](https://supabase.com/docs/guides/your-page) |
[/docs/guides/your-page](https://docs-git-branch-name-supabase.vercel.app/docs/guides/your-page)
| unique phrase from the change |
| Studio | [/dashboard](https://supabase.com/dashboard) |
[/dashboard](https://studio-git-branch-name-supabase.vercel.app/dashboard)
| unique phrase from the change |
| Design system | [/design-system](https://supabase.com/design-system) |
[/design-system](https://design-system-git-branch-name-supabase.vercel.app/design-system)
| unique phrase from the change |
| UI library | [/library](https://supabase.com/library) |
[/library](https://ui-library-git-branch-name-supabase.vercel.app/library)
| unique phrase from the change |
| Knowledge base |
[/kb/guides/your-page](https://supabase.com/kb/guides/your-page) |
[/kb/guides/your-page](https://kb-git-branch-name-supabase.vercel.app/kb/guides/your-page)
| unique phrase from the change |
-->

<!-- ## Additional context

Optionally add any other context or screenshots.

-->


## Checklist

Check all before review:

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [x] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which references
[WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md)
and the docs
[CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md)
guide


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

## Summary

* **Updates**
* The **None** role is labeled **No-access** and describes the lack of
organization and project resource access.
* **None** is included after **Read-only** in the role list. When
inviting a member with project-only access, **None** is disabled with an
explanation.
* **Documentation**
* Clarified plan coverage for **Read-Only** and **No access**, and added
guidance on assigning **No access** at the organization level before
granting project-specific roles.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-05 19:54:55 +07:00

536 lines
20 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, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
ExpandingTextArea,
Form,
FormControl,
FormField,
FormItem,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
SheetTrigger,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import {
BatchInvitationResult,
buildProjectPayload,
buildSsoPayload,
categorizeInviteEmails,
emailSchema,
parseEmails,
} from './InviteMemberButton.utils'
import { ROLE_DESCRIPTIONS } from './Roles.constants'
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 { InlineLink } from '@/components/ui/InlineLink'
import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
import { Shortcut } from '@/components/ui/Shortcut'
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'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
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 [pendingInvite, setPendingInvite] = useState<z.infer<typeof FormSchema>>()
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 inviteDisabledReason = !organizationMembersCreationEnabled
? 'Inviting members is currently disabled'
: !canInviteMembers
? 'You need additional permissions to invite members to this organization'
: undefined
const isInviteDisabled = inviteDisabledReason !== undefined
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] = useWatch({
control: form.control,
name: ['applyToOrg', 'projectRef', 'email'],
})
const emailCount = parseEmails(email ?? '').length
const getRoleName = (roleId?: string) =>
orgScopedRoles.find((role) => role.id.toString() === roleId)?.name
const pendingRoleName = getRoleName(pendingInvite?.role)
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) {
closeInviteSheet()
}
}
const handleInviteSubmit = (values: z.infer<typeof FormSchema>) => {
const roleName = getRoleName(values.role)
const needsConfirmation = roleName === 'Owner' || roleName === 'Administrator'
if (needsConfirmation) setPendingInvite(values)
else onInviteMember(values)
}
const handleConfirmInvite = async () => {
if (pendingInvite) await onInviteMember(pendingInvite)
setPendingInvite(undefined)
}
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 closeInviteSheet = () => {
setProjectDropdownOpen(false)
setIsOpen(false)
form.reset(defaultValues)
}
const {
confirmOnClose,
handleOpenChange,
modalProps: discardChangesModalProps,
} = useConfirmOnClose({
checkIsDirty: () => hasUnsavedChanges,
onClose: closeInviteSheet,
})
return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>
<Shortcut
id={SHORTCUT_IDS.ORG_TEAM_INVITE}
onTrigger={() => setIsOpen(true)}
options={{ enabled: !isInviteDisabled }}
side="bottom"
tooltipOpen={isOpen || isInviteDisabled ? false : undefined}
>
<ButtonTooltip
variant="primary"
disabled={isInviteDisabled}
icon={<UserPlus size={14} />}
className="pointer-events-auto grow md:grow-0"
onClick={() => setIsOpen(true)}
tooltip={{
content: {
side: 'bottom',
text: inviteDisabledReason,
},
}}
>
Invite members
</ButtonTooltip>
</Shortcut>
</SheetTrigger>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Invite team members</SheetTitle>
<SheetDescription>
Send invitations and choose the access each new team member receives.
</SheetDescription>
</SheetHeader>
<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)"
/>
)}
</>
}
/>
<SheetSection className="grow overflow-auto">
<Form {...form}>
<form
id="organization-invitation"
className="flex flex-col gap-y-4"
onSubmit={form.handleSubmit(handleInviteSubmit)}
>
<FormField
name="role"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Role"
description={
<>
Learn more about{' '}
<InlineLink href={`${DOCS_URL}/guides/platform/access-control`}>
roles and permissions
</InlineLink>
</>
}
>
<FormControl className="col-span-6">
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
{orgScopedRoles.map((role) => {
const canAssignRole = rolesAddable.includes(role.id)
const isOwnerRole = role.name === 'Owner'
const disabledForStripe = isStripeProjectsOrg && isOwnerRole
const isNoAccessRole = role.name === 'No-access'
const isApplyingProjectRole = !applyToOrg
const disabledForProjectRole = isApplyingProjectRole && isNoAccessRole
const disabled =
!canAssignRole || disabledForStripe || disabledForProjectRole
const disabledReason = disabledForStripe
? 'Cannot be assigned in Stripe Projects organizations'
: disabledForProjectRole
? 'Cannot assign No-access role to project-scoped members'
: !canAssignRole
? 'Additional permissions required to assign role'
: undefined
return (
<FormItem asChild key={role.id}>
<FormControl>
<RadioGroupStackedItem
value={role.id.toString()}
disabled={disabled}
label={role.name}
description={
<>
{ROLE_DESCRIPTIONS[role.name] ??
'Permissions are based on the configured organization role.'}
{disabledReason && ` ${disabledReason}`}
</>
}
/>
</FormControl>
</FormItem>
)
})}
</RadioGroupStacked>
</FormControl>
</FormItemLayout>
)}
/>
{hasSsoProvider && (
<FormField
name="requireSso"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Invitation type"
description="Choose how the invitee should authenticate"
>
<FormControl className="col-span-6">
<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="horizontal"
label="Grant this role on all projects"
description="Apply this role to all current and future projects in the organization"
>
<FormControl className="col-span-6">
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItemLayout>
)}
/>
)}
{!applyToOrg && (
<FormField
name="projectRef"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Select a project"
description="Project access can be adjusted after the user joins"
>
<FormControl className="col-span-6">
<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 layout="horizontal" label="Email addresses">
<FormControl className="col-span-6">
<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>
)}
/>
</form>
</Form>
</SheetSection>
<SheetFooter>
<Button onClick={confirmOnClose}>Cancel</Button>
<Shortcut
id={SHORTCUT_IDS.ORG_TEAM_INVITE_SUBMIT}
onTrigger={() => form.handleSubmit(handleInviteSubmit)()}
options={{ enabled: isOpen && !isInviting }}
side="top"
>
<Button
variant="primary"
form="organization-invitation"
type="submit"
loading={isInviting}
>
{emailCount >= 2 ? 'Send invitations' : 'Send invitation'}
</Button>
</Shortcut>
</SheetFooter>
</SheetContent>
<DiscardChangesConfirmationDialog
{...discardChangesModalProps}
description="Are you sure you want to discard your changes? Your invitation will not be sent."
/>
<ConfirmationModal
variant="warning"
visible={pendingInvite !== undefined}
loading={isInviting}
title={`Invite as ${pendingRoleName}?`}
description={pendingRoleName && ROLE_DESCRIPTIONS[pendingRoleName]}
confirmLabel="Send invitation"
confirmLabelLoading="Sending invitation..."
onCancel={() => setPendingInvite(undefined)}
onConfirm={handleConfirmInvite}
/>
</Sheet>
)
}