From 25120657db0d752832d7d6c02a34f49ea97a1736 Mon Sep 17 00:00:00 2001 From: Chris Stockton <180184+cstockton@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:56:06 -0700 Subject: [PATCH] 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 Co-authored-by: Ali Waseem Co-authored-by: Ivan Vasilov --- .../Organization/SSO/AttributeMapping.tsx | 8 +- .../interfaces/Organization/SSO/SSOConfig.tsx | 298 ++++++++++++------ .../TeamSettings/InviteMemberButton.tsx | 82 ++++- ...organization-invitation-create-mutation.ts | 3 + .../data/sso/sso-config-delete-mutation.ts | 53 ++++ 5 files changed, 319 insertions(+), 125 deletions(-) create mode 100644 apps/studio/data/sso/sso-config-delete-mutation.ts diff --git a/apps/studio/components/interfaces/Organization/SSO/AttributeMapping.tsx b/apps/studio/components/interfaces/Organization/SSO/AttributeMapping.tsx index 1f6977824d9..75a23ad76ef 100644 --- a/apps/studio/components/interfaces/Organization/SSO/AttributeMapping.tsx +++ b/apps/studio/components/interfaces/Organization/SSO/AttributeMapping.tsx @@ -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 ( diff --git a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx index 117c62339fd..c39784c9bf6 100644 --- a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx +++ b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx @@ -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 +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({ 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 = (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 ? ( - -
- - - ( - - Enable and configure SSO for your organization. Learn more about SSO{' '} - - here - - . - - } + <> + + + + + ( + + Enable and configure SSO for your organization. Learn more about SSO{' '} + + here + + . + + } + > + + + + + )} + /> + + + {(isSSOEnabled || ssoConfig) && ( + <> + + + + + + + + + + + + + + + + + )} + + +
+ {!!ssoConfig && ( + + )} +
+
+ {form.formState.isDirty && ( + + )} + +
+
+
+ +
- {(isSSOEnabled || ssoConfig) && ( - <> - - - + setIsDeleteModalVisible(false)} + > +
+

+ You are about to delete the SSO provider for{' '} + {ssoConfig?.domains?.[0]}. +

- - - - - - - - - - - - + {ssoMemberCount > 0 && ( +
+

+ + {ssoMemberCount} organization member{ssoMemberCount !== 1 ? 's' : ''} + {' '} + who authenticate via SSO will be{' '} + permanently removed from this + organization. +

+
)} - - {form.formState.isDirty && ( - - )} - - - - - +

This action will:

+
    +
  • Disable SSO authentication for this organization
  • +
  • Remove all members who signed up using SSO
  • +
  • Prevent future SSO-based sign-ins
  • +
+ +

+ + This action cannot be undone. + {' '} + Members will need to be re-invited if you wish to restore their access. +

+
+
+ ) : null} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 36f02cacd1a..0a523935d02 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -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>({ @@ -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 = () => {
)} /> + {hasSsoProvider && ( + ( + + + + + + + + + + Automatic (based on your account) + + + Require SSO authentication + + + Email/password authentication + + + + + + + )} + /> + )} {hasAccessToProjectLevelPermissions && ( > + +export const useSSOConfigDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + 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, + }) +}