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) (#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:
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>
|
||||
|
||||
+66
-16
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user