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>
This commit is contained in:
authored and GitHub committed 2026-03-16 09:56:06 -07:00
1 parent 5528817602
commit 25120657db
5 files changed
+319 -125

No files matched your search

@@ -104,10 +104,10 @@ export const AttributeMapping = ({
function applyPreset(preset: ProviderPreset) {
const keys = preset.attributeMapping.keys
// Set each field if present in the preset, otherwise clear
form.setValue(emailField, [{ value: keys.email?.name ?? '' }])
form.setValue(userNameField, [{ value: keys.user_name?.name ?? '' }])
form.setValue(firstNameField, [{ value: keys.first_name?.name ?? '' }])
form.setValue(lastNameField, [{ value: keys.last_name?.name ?? '' }])
form.setValue(emailField, [{ value: keys.email?.name ?? '' }], { shouldDirty: true })
form.setValue(userNameField, [{ value: keys.user_name?.name ?? '' }], { shouldDirty: true })
form.setValue(firstNameField, [{ value: keys.first_name?.name ?? '' }], { shouldDirty: true })
form.setValue(lastNameField, [{ value: keys.last_name?.name ?? '' }], { shouldDirty: true })
}
return (
@@ -1,34 +1,39 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import { Trash } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import z from 'zod'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { InlineLink } from 'components/ui/InlineLink'
import { UpgradeToPro } from 'components/ui/UpgradeToPro'
import { useSSOConfigCreateMutation } from 'data/sso/sso-config-create-mutation'
import { useOrgSSOConfigQuery } from 'data/sso/sso-config-query'
import { useSSOConfigUpdateMutation } from 'data/sso/sso-config-update-mutation'
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { DOCS_URL } from 'lib/constants'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardFooter,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import z from 'zod'
import { AttributeMapping } from './AttributeMapping'
import { JoinOrganizationOnSignup } from './JoinOrganizationOnSignup'
import { SSODomains } from './SSODomains'
import { SSOMetadata } from './SSOMetadata'
import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
import AlertError from '@/components/ui/AlertError'
import { InlineLink } from '@/components/ui/InlineLink'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
import { useSSOConfigCreateMutation } from '@/data/sso/sso-config-create-mutation'
import { useSSOConfigDeleteMutation } from '@/data/sso/sso-config-delete-mutation'
import { useOrgSSOConfigQuery } from '@/data/sso/sso-config-query'
import { useSSOConfigUpdateMutation } from '@/data/sso/sso-config-update-mutation'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { DOCS_URL } from '@/lib/constants'
const FormSchema = z
.object({
@@ -61,6 +66,19 @@ const FormSchema = z
export type SSOConfigFormSchema = z.infer<typeof FormSchema>
const defaultValues = {
enabled: false,
domains: [{ value: '' }],
metadataXmlUrl: '',
metadataXmlFile: '',
emailMapping: [{ value: '' }],
userNameMapping: [{ value: '' }],
firstNameMapping: [{ value: '' }],
lastNameMapping: [{ value: '' }],
joinOrgOnSignup: false,
roleOnJoin: 'Developer',
}
export const SSOConfig = () => {
const FORM_ID = 'sso-config-form'
@@ -76,22 +94,14 @@ export const SSOConfig = () => {
error: configError,
} = useOrgSSOConfigQuery({ orgSlug: organization?.slug }, { enabled: !!organization })
const { data: members = [] } = useOrganizationMembersQuery({ slug: organization?.slug })
const ssoMemberCount = members.filter((m) => m.is_sso_user === true).length
const isSSOProviderNotFound = ssoConfig === null
const form = useForm<SSOConfigFormSchema>({
resolver: zodResolver(FormSchema),
defaultValues: {
enabled: false,
domains: [{ value: '' }],
metadataXmlUrl: '',
metadataXmlFile: '',
emailMapping: [{ value: '' }],
userNameMapping: [{ value: '' }],
firstNameMapping: [{ value: '' }],
lastNameMapping: [{ value: '' }],
joinOrgOnSignup: false,
roleOnJoin: 'Developer',
},
defaultValues,
})
const isSSOEnabled = form.watch('enabled')
@@ -104,6 +114,16 @@ export const SSOConfig = () => {
onSuccess: () => form.reset(),
})
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false)
const { mutate: deleteSSOConfig, isPending: isDeleting } = useSSOConfigDeleteMutation({
onSuccess: () => {
toast.success('Successfully deleted SSO configuration')
setIsDeleteModalVisible(false)
form.reset(defaultValues)
},
})
const onSubmit: SubmitHandler<SSOConfigFormSchema> = (values) => {
const roleOnJoin = (values.roleOnJoin || 'Developer') as
| 'Administrator'
@@ -135,6 +155,11 @@ export const SSOConfig = () => {
}
}
const onDeleteSSOConfig = () => {
if (!organization?.slug) return
deleteSSOConfig({ slug: organization.slug })
}
useEffect(() => {
if (ssoConfig) {
form.reset({
@@ -172,90 +197,153 @@ export const SSOConfig = () => {
featureProposition="enable Single Sign-on (SSO)"
/>
) : isSuccess || isSSOProviderNotFound ? (
<Form_Shadcn_ {...form}>
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="enabled"
render={({ field }) => (
<FormItemLayout
layout="flex"
label="Enable Single Sign-On"
description={
<>
Enable and configure SSO for your organization. Learn more about SSO{' '}
<InlineLink
className="text-foreground-lighter hover:text-foreground"
href={`${DOCS_URL}/guides/platform/sso`}
>
here
</InlineLink>
.
</>
}
<>
<Form_Shadcn_ {...form}>
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="enabled"
render={({ field }) => (
<FormItemLayout
layout="flex"
label="Enable Single Sign-On"
description={
<>
Enable and configure SSO for your organization. Learn more about SSO{' '}
<InlineLink
className="text-foreground-lighter hover:text-foreground"
href={`${DOCS_URL}/guides/platform/sso`}
>
here
</InlineLink>
.
</>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
size="large"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
{(isSSOEnabled || ssoConfig) && (
<>
<CardContent>
<SSODomains form={form} />
</CardContent>
<CardContent>
<SSOMetadata form={form} />
</CardContent>
<CardContent>
<AttributeMapping
form={form}
emailField="emailMapping"
userNameField="userNameMapping"
firstNameField="firstNameMapping"
lastNameField="lastNameMapping"
/>
</CardContent>
<CardContent>
<JoinOrganizationOnSignup form={form} />
</CardContent>
</>
)}
<CardFooter className="justify-between space-x-2">
<div>
{!!ssoConfig && (
<Button
type="danger"
icon={<Trash />}
onClick={() => setIsDeleteModalVisible(true)}
disabled={isCreating || isUpdating || isDeleting}
>
Delete SSO Provider
</Button>
)}
</div>
<div className="flex space-x-2">
{form.formState.isDirty && (
<Button
type="default"
disabled={isCreating || isUpdating}
onClick={() => form.reset()}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isCreating || isUpdating}
disabled={!form.formState.isDirty || isCreating || isUpdating}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
size="large"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
Save changes
</Button>
</div>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
{(isSSOEnabled || ssoConfig) && (
<>
<CardContent>
<SSODomains form={form} />
</CardContent>
<TextConfirmModal
visible={isDeleteModalVisible}
size="small"
variant="destructive"
title="Delete SSO Provider"
loading={isDeleting}
confirmString={ssoConfig?.domains?.[0] ?? ''}
confirmPlaceholder="Type the domain above to confirm"
confirmLabel="I understand, delete SSO provider and members"
onConfirm={onDeleteSSOConfig}
onCancel={() => setIsDeleteModalVisible(false)}
>
<div className="space-y-3">
<p className="text-sm text-foreground-lighter">
You are about to delete the SSO provider for{' '}
<span className="text-foreground font-semibold">{ssoConfig?.domains?.[0]}</span>.
</p>
<CardContent>
<SSOMetadata form={form} />
</CardContent>
<CardContent>
<AttributeMapping
form={form}
emailField="emailMapping"
userNameField="userNameMapping"
firstNameField="firstNameMapping"
lastNameField="lastNameMapping"
/>
</CardContent>
<CardContent>
<JoinOrganizationOnSignup form={form} />
</CardContent>
</>
{ssoMemberCount > 0 && (
<div className="rounded-md bg-destructive/10 border border-destructive/30 p-3">
<p className="text-sm text-foreground">
<span className="font-semibold">
{ssoMemberCount} organization member{ssoMemberCount !== 1 ? 's' : ''}
</span>{' '}
who authenticate via SSO will be{' '}
<span className="font-semibold">permanently removed</span> from this
organization.
</p>
</div>
)}
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button
type="default"
disabled={isCreating || isUpdating}
onClick={() => form.reset()}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isCreating || isUpdating}
disabled={!form.formState.isDirty || isCreating || isUpdating}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
<p className="text-sm text-foreground-lighter">This action will:</p>
<ul className="text-sm text-foreground-lighter list-disc list-inside space-y-1 ml-2">
<li>Disable SSO authentication for this organization</li>
<li>Remove all members who signed up using SSO</li>
<li>Prevent future SSO-based sign-ins</li>
</ul>
<p className="text-sm text-foreground-lighter">
<span className="text-foreground font-semibold">
This action cannot be undone.
</span>{' '}
Members will need to be re-invited if you wish to restore their access.
</p>
</div>
</TextConfirmModal>
</>
) : null}
</ScaffoldSection>
</ScaffoldContainer>
@@ -1,21 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
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 { 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'
import { UserPlus } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
@@ -39,14 +24,31 @@ import {
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns'
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
@@ -72,11 +74,15 @@ export const InviteMemberButton = () => {
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')
@@ -134,6 +140,7 @@ export const InviteMemberButton = () => {
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>>({
@@ -189,6 +196,15 @@ export const InviteMemberButton = () => {
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({
@@ -196,6 +212,7 @@ export const InviteMemberButton = () => {
email: emailAddress,
roleId: Number(values.role),
...projectPayload,
...ssoPayload,
})
)
)
@@ -342,6 +359,39 @@ export const InviteMemberButton = () => {
</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"
@@ -12,6 +12,7 @@ export type OrganizationCreateInvitationVariables = {
email: string
roleId: number
projects?: string[]
requireSso?: boolean
}
export async function createOrganizationInvitation({
@@ -19,9 +20,11 @@ export async function createOrganizationInvitation({
email,
roleId,
projects,
requireSso,
}: OrganizationCreateInvitationVariables) {
const payload: components['schemas']['CreateInvitationBody'] = { email, role_id: roleId }
if (projects !== undefined) payload.role_scoped_projects = projects
if (requireSso !== undefined) payload.require_sso = requireSso
const { data, error } = await post('/platform/organizations/{slug}/members/invitations', {
params: { path: { slug } },
@@ -0,0 +1,53 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, del } from 'data/fetchers'
import { organizationKeys as organizationKeysV1 } from 'data/organizations/keys'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { orgSSOKeys } from './keys'
export type SSOConfigDeleteVariables = {
slug: string
}
export async function deleteSSOConfig({ slug }: SSOConfigDeleteVariables) {
const { data, error } = await del('/platform/organizations/{slug}/sso', {
params: { path: { slug } },
})
if (error) handleError(error)
return data
}
type SSOConfigDeleteData = Awaited<ReturnType<typeof deleteSSOConfig>>
export const useSSOConfigDeleteMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<SSOConfigDeleteData, ResponseError, SSOConfigDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<SSOConfigDeleteData, ResponseError, SSOConfigDeleteVariables>({
mutationFn: (vars) => deleteSSOConfig(vars),
async onSuccess(data, variables, context) {
const { slug } = variables
await Promise.all([
queryClient.invalidateQueries({ queryKey: orgSSOKeys.orgSSOConfig(slug) }),
queryClient.invalidateQueries({ queryKey: organizationKeysV1.members(slug) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to delete SSO configuration: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}