Files
supabase/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
T
25120657db feat(sso): improve SSO management UX (safe deletion + invitation type selection) (#43615)
## feat(sso): improve SSO management UX (safe deletion + invitation type
selection)

This PR improves the SSO management experience by introducing a safer
deletion flow for SSO providers and allowing explicit control over
invitation authentication type.

## SSO Provider Deletion Improvements

The SSO provider deletion flow has been redesigned to better communicate
the impact of the action and prevent accidental destructive operations.

### UX Improvements

* Replace `ConfirmationModal` with `TextConfirmModal` in `SSOConfig`
* Require typing the SSO domain to confirm deletion
* Display the number of organization members authenticating via SSO who
will be removed
* Add destructive visual styling and clear warnings about irreversible
consequences
* Update confirmation button label to emphasize impact:

  * `I understand, delete SSO provider and members`

### Warning Content

The modal now clearly communicates:

* The domain being deleted
* That SSO authentication will be disabled
* That SSO-authenticated members will be permanently removed
* That those members must be re-invited to regain access

If SSO members exist, a highlighted destructive warning box shows:

```
X organization member(s) who authenticate via SSO will be permanently removed
```

### Implementation Details

* Add `useOrganizationMembersQuery` to fetch organization members
* Calculate SSO members by filtering `is_sso_user === true`
* Only display the member warning when the count > 0
* Modal uses `variant="destructive"` and `size="small"`

This pattern follows the existing **Delete organization** confirmation
flow.

### Initial Delete Support

This PR also introduces the underlying deletion functionality:

* Add `useSSOConfigDeleteMutation`
* Add delete button (trash icon, danger styling) in the SSO config
footer
* Layout mirrors `CustomDomainDelete` pattern:

  * delete button on the left
  * save/cancel actions on the right
* Success toast shown after deletion
* Form resets to explicit default values after deletion

## Invitation Type Selection

Organizations with SSO configured can now explicitly choose the
authentication method when inviting new members.

Previously, invitations always inherited the inviter's authentication
method. This made it difficult to support mixed authentication
organizations.

### New Invitation Options

When SSO is enabled, the invite dialog now shows an **Invitation type**
dropdown:

* **Automatic (based on your account)**
  Default behavior; inherits authentication method from the inviter.

* **Require SSO authentication**
  Sends an SSO invitation.

* **Email/password authentication**
  Sends a non-SSO invitation.

### Implementation Details

* Add `useOrgSSOConfigQuery` to detect if SSO is configured
* Add `requireSso` field to the form schema with enum:

  * `auto`
  * `sso`
  * `non-sso`
* Only display the dropdown when the organization has an SSO provider
* Transform form values before sending to the backend:

```
sso -> { requireSso: true }
non-sso -> { requireSso: false }
auto -> {} (omit parameter)
```

* Update `OrganizationCreateInvitationVariables` to include optional
`requireSso`
* Preserve backward compatibility by only sending the field when
explicitly set

## Bug Fixes

* Attribute mapping preset buttons (Azure, GSuite, Okta) now properly
mark the form as dirty so the save button becomes enabled
* Form reset after deletion now uses explicit default values instead of
the last saved state

## Problems Solved

This PR addresses several UX issues:

1. Deleting an SSO provider previously used a simple confirmation with
no explanation of impact
2. Users could not see how many members would be affected by deletion
3. The destructive and irreversible nature of the action was not
visually emphasized
4. Invitations always inherited the inviter's auth method
5. Organizations could not intentionally mix SSO and non-SSO users

## Types

TypeScript types in `api-types` were updated to support the new
`require_sso` parameter.

---------

Co-authored-by: Chris Stockton <chris.stockton@supabase.io>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-03-16 09:56:06 -07:00

475 lines
18 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_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
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 { useProfile } from '@/lib/profile'
function parseEmails(value: string): string[] {
return value
.split(',')
.map((e) => e.trim())
.filter(Boolean)
}
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 { 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 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: emailSchema,
role: z.string().min(1, 'Role is required'),
applyToOrg: z.boolean(),
projectRef: z.string(),
requireSso: z.enum(['auto', 'sso', 'non-sso']),
})
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: 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 {
toInvite.push(emailAddress)
}
}
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 =
!values.applyToOrg && values.projectRef ? { projects: [values.projectRef] } : {}
// Transform SSO preference to backend format
const ssoPayload =
values.requireSso === 'sso'
? { requireSso: true }
: values.requireSso === 'non-sso'
? { requireSso: false }
: {} // 'auto' - let backend use automatic behavior
const results = await Promise.allSettled(
toInvite.map((emailAddress) =>
inviteMemberAsync({
slug,
email: emailAddress,
roleId: Number(values.role),
...projectPayload,
...ssoPayload,
})
)
)
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`
)
closeInviteDialog()
}
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 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 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 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_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">
<FormField_Shadcn_
name="role"
control={form.control}
render={({ field }) => (
<FormItemLayout label="Role">
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<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>
)}
/>
{hasSsoProvider && (
<FormField_Shadcn_
name="requireSso"
control={form.control}
render={({ field }) => (
<FormItemLayout
label="Invitation type"
description="Choose how the invitee should authenticate"
>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Automatic (based on your account)" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="auto">
Automatic (based on your account)
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="sso">
Require SSO authentication
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="non-sso">
Email/password authentication
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</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={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
{!applyToOrg && (
<FormField_Shadcn_
name="projectRef"
control={form.control}
render={({ field }) => (
<FormItemLayout
label="Select a project"
description="Project access can be adjusted after the user joins"
>
<FormControl_Shadcn_>
<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_Shadcn_>
</FormItemLayout>
)}
/>
)}
<FormField_Shadcn_
name="email"
control={form.control}
render={({ field }) => (
<FormItemLayout label="Email addresses">
<FormControl_Shadcn_>
<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_Shadcn_>
</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_Shadcn_>
</DialogContent>
<DiscardChangesConfirmationDialog
{...discardChangesModalProps}
description="Are you sure you want to discard your changes? Your invitation will not be sent."
/>
</Dialog>
)
}