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, + }) +}