mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## 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>
475 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|