SSOConfig.tsx460 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { Trash } from 'lucide-react'
3import { useEffect, useState } from 'react'
4import { SubmitHandler, useForm } from 'react-hook-form'
5import { toast } from 'sonner'
6import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui'
7import { Admonition } from 'ui-patterns'
8import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
9import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
10import z from 'zod'
11
12import { AttributeMapping } from './AttributeMapping'
13import { JoinOrganizationOnSignup } from './JoinOrganizationOnSignup'
14import { SSODomains } from './SSODomains'
15import { SSOMetadata } from './SSOMetadata'
16import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
17import AlertError from '@/components/ui/AlertError'
18import { InlineLink } from '@/components/ui/InlineLink'
19import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
20import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
21import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
22import { useSSOConfigCreateMutation } from '@/data/sso/sso-config-create-mutation'
23import { useSSOConfigDeleteMutation } from '@/data/sso/sso-config-delete-mutation'
24import { useOrgSSOConfigQuery } from '@/data/sso/sso-config-query'
25import { useSSOConfigUpdateMutation } from '@/data/sso/sso-config-update-mutation'
26import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
27import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
28import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
29import { DOCS_URL } from '@/lib/constants'
30
31const FormSchema = z
32 .object({
33 enabled: z.boolean(),
34 enableSpInitiated: z.boolean(),
35 domains: z.array(
36 z.object({
37 value: z.string().trim(),
38 })
39 ),
40 metadataXmlUrl: z.string().trim().optional(),
41 metadataXmlFile: z.string().trim().optional(),
42 emailMapping: z.array(z.object({ value: z.string().trim().min(1, 'This field is required') })),
43 userNameMapping: z.array(z.object({ value: z.string().trim() })),
44 firstNameMapping: z.array(z.object({ value: z.string().trim() })),
45 lastNameMapping: z.array(z.object({ value: z.string().trim() })),
46 joinOrgOnSignup: z.boolean(),
47 roleOnJoin: z.string().optional(),
48 })
49 .superRefine((data, ctx) => {
50 if (!data.enableSpInitiated) return
51
52 const hasValidDomain = data.domains?.some((d) => d.value && d.value.trim().length > 0)
53 if (!hasValidDomain) {
54 ctx.addIssue({
55 code: z.ZodIssueCode.custom,
56 message: 'At least one domain is required when SP-initiated login is enabled',
57 path: ['domains'],
58 })
59 }
60
61 data.domains?.forEach((d, idx) => {
62 if (!d.value || d.value.trim().length === 0) {
63 ctx.addIssue({
64 code: z.ZodIssueCode.custom,
65 message: 'Please provide a domain',
66 path: ['domains', idx, 'value'],
67 })
68 }
69 })
70 })
71 // set the error on both fields
72 .refine((data) => data.metadataXmlUrl || data.metadataXmlFile, {
73 message: 'Please provide either a metadata XML URL or upload a metadata XML file',
74 path: ['metadataXmlUrl'],
75 })
76 .refine((data) => data.metadataXmlUrl || data.metadataXmlFile, {
77 message: 'Please provide either a metadata XML URL or upload a metadata XML file',
78 path: ['metadataXmlFile'],
79 })
80
81export type SSOConfigFormSchema = z.infer<typeof FormSchema>
82
83const defaultValues = {
84 enabled: false,
85 enableSpInitiated: false,
86 domains: [{ value: '' }],
87 metadataXmlUrl: '',
88 metadataXmlFile: '',
89 emailMapping: [{ value: '' }],
90 userNameMapping: [{ value: '' }],
91 firstNameMapping: [{ value: '' }],
92 lastNameMapping: [{ value: '' }],
93 joinOrgOnSignup: false,
94 roleOnJoin: 'Developer',
95}
96
97export const SSOConfig = () => {
98 const FORM_ID = 'sso-config-form'
99
100 const { data: organization } = useSelectedOrganizationQuery()
101 const { hasAccess: hasAccessToSso, isLoading: isLoadingEntitlement } =
102 useCheckEntitlements('auth.platform.sso')
103
104 const {
105 data: ssoConfig,
106 isPending: isLoadingSSOConfig,
107 isSuccess,
108 isError,
109 error: configError,
110 } = useOrgSSOConfigQuery({ orgSlug: organization?.slug }, { enabled: !!organization })
111
112 const { data: members = [] } = useOrganizationMembersQuery({ slug: organization?.slug })
113
114 const ssoMemberCount = members.filter((m) => m.is_sso_user === true).length
115 const isSSOProviderNotFound = ssoConfig === null
116
117 const form = useForm<SSOConfigFormSchema>({
118 resolver: zodResolver(FormSchema as any),
119 defaultValues,
120 })
121
122 const isSSOEnabled = form.watch('enabled')
123 const enableSpInitiated = form.watch('enableSpInitiated')
124
125 const { mutate: createSSOConfig, isPending: isCreating } = useSSOConfigCreateMutation({
126 onSuccess: () => {
127 toast.success('Successfully created SSO configuration')
128 // Reset form to current values to mark as clean
129 // This allows useEffect to reset with fresh data when query refetches
130 form.reset(form.getValues())
131 },
132 })
133
134 const { mutate: updateSSOConfig, isPending: isUpdating } = useSSOConfigUpdateMutation({
135 onSuccess: () => {
136 toast.success('Successfully updated SSO configuration')
137 // Reset form to current values to mark as clean
138 // This allows useEffect to reset with fresh data when query refetches
139 form.reset(form.getValues())
140 },
141 })
142
143 const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false)
144
145 const { mutate: deleteSSOConfig, isPending: isDeleting } = useSSOConfigDeleteMutation({
146 onSuccess: () => {
147 toast.success('Successfully deleted SSO configuration')
148 setIsDeleteModalVisible(false)
149 form.reset(defaultValues)
150 },
151 })
152
153 const onSubmit: SubmitHandler<SSOConfigFormSchema> = (values) => {
154 const roleOnJoin = (values.roleOnJoin || 'Developer') as
155 | 'Administrator'
156 | 'Developer'
157 | 'Owner'
158 | 'Read-only'
159 | undefined
160
161 const payload = {
162 slug: organization!.slug,
163 config: {
164 enabled: values.enabled,
165 // Send empty array if SP-initiated is disabled
166 domains: values.enableSpInitiated ? values.domains.map((d) => d.value).filter(Boolean) : [],
167 metadata_xml_file: values.metadataXmlFile!,
168 metadata_xml_url: values.metadataXmlUrl!,
169 email_mapping: values.emailMapping.map((item) => item.value).filter(Boolean),
170 first_name_mapping: values.firstNameMapping.map((item) => item.value).filter(Boolean),
171 last_name_mapping: values.lastNameMapping.map((item) => item.value).filter(Boolean),
172 user_name_mapping: values.userNameMapping.map((item) => item.value).filter(Boolean),
173 join_org_on_signup_enabled: values.joinOrgOnSignup,
174 join_org_on_signup_role: roleOnJoin,
175 },
176 }
177
178 if (!!ssoConfig) {
179 updateSSOConfig(payload)
180 } else {
181 createSSOConfig(payload)
182 }
183 }
184
185 const onDeleteSSOConfig = () => {
186 if (!organization?.slug) return
187 deleteSSOConfig({ slug: organization.slug })
188 }
189
190 const syncFormFromConfig = useStaticEffectEvent(() => {
191 if (!organization?.slug) return
192
193 // Only reset form if it's not dirty (user hasn't made changes)
194 if (ssoConfig && !form.formState.isDirty) {
195 form.reset({
196 enabled: ssoConfig.enabled,
197 // Infer SP-initiated from domains presence
198 enableSpInitiated: ssoConfig.domains && ssoConfig.domains.length > 0,
199 domains: ssoConfig.domains?.map((domain) => ({ value: domain })) || [],
200 metadataXmlUrl: ssoConfig.metadata_xml_url,
201 metadataXmlFile: ssoConfig.metadata_xml_file,
202 emailMapping: ssoConfig.email_mapping.map((email) => ({ value: email })),
203 userNameMapping:
204 ssoConfig.user_name_mapping?.map((userName) => ({ value: userName })) || [],
205 firstNameMapping:
206 ssoConfig.first_name_mapping?.map((firstName) => ({ value: firstName })) || [],
207 lastNameMapping:
208 ssoConfig.last_name_mapping?.map((lastName) => ({ value: lastName })) || [],
209 joinOrgOnSignup: ssoConfig.join_org_on_signup_enabled,
210 roleOnJoin: ssoConfig.join_org_on_signup_role,
211 })
212 }
213 })
214
215 useEffect(() => {
216 syncFormFromConfig()
217 }, [ssoConfig, organization?.slug, syncFormFromConfig])
218
219 // Automatically add an empty domain field when SP-initiated is enabled
220 const ensureDomainField = useStaticEffectEvent(() => {
221 const currentDomains = form.getValues('domains')
222 if (enableSpInitiated && (!currentDomains || currentDomains.length === 0)) {
223 form.setValue('domains', [{ value: '' }], { shouldValidate: false })
224 }
225 })
226
227 useEffect(() => {
228 ensureDomainField()
229 }, [enableSpInitiated, ensureDomainField])
230
231 return (
232 <ScaffoldContainer size="small" className="px-6 xl:px-10">
233 <ScaffoldSection isFullWidth>
234 {isLoadingEntitlement || (hasAccessToSso && isLoadingSSOConfig) ? (
235 <Card>
236 <CardContent>
237 <GenericSkeletonLoader />
238 </CardContent>
239 </Card>
240 ) : isError && !isSSOProviderNotFound ? (
241 <AlertError error={configError} subject="Failed to retrieve SSO configuration" />
242 ) : !hasAccessToSso ? (
243 <UpgradeToPro
244 plan="Team"
245 source="organizationSso"
246 primaryText="Organization Single Sign-on (SSO) is available from Team plan and above"
247 secondaryText="SSO as a login option provides additional account security for your team by enforcing the use of an identity provider when logging into Briven. Upgrade to Team or above to set up SSO for your organization."
248 featureProposition="enable Single Sign-on (SSO)"
249 />
250 ) : isSuccess || isSSOProviderNotFound ? (
251 <>
252 <Form {...form}>
253 <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
254 <Card>
255 <CardContent>
256 <FormField
257 control={form.control}
258 name="enabled"
259 render={({ field }) => (
260 <FormItemLayout
261 layout="flex"
262 label="Enable Single Sign-On"
263 description={
264 <>
265 Enable and configure SSO for your organization. Learn more about SSO{' '}
266 <InlineLink
267 className="text-foreground-lighter hover:text-foreground"
268 href={`${DOCS_URL}/guides/platform/sso`}
269 >
270 here
271 </InlineLink>
272 .
273 </>
274 }
275 >
276 <FormControl>
277 <Switch
278 checked={field.value}
279 onCheckedChange={field.onChange}
280 size="large"
281 />
282 </FormControl>
283 </FormItemLayout>
284 )}
285 />
286 </CardContent>
287
288 {isSSOEnabled && (
289 <>
290 <CardContent>
291 <FormField
292 control={form.control}
293 name="enableSpInitiated"
294 render={({ field }) => (
295 <FormItemLayout
296 layout="flex-row-reverse"
297 label="Enable SP-initiated login"
298 description="Allow users to start the login flow from the Briven dashboard by entering their email address. Requires configuring email domains below."
299 >
300 <FormControl>
301 <Switch checked={field.value} onCheckedChange={field.onChange} />
302 </FormControl>
303 </FormItemLayout>
304 )}
305 />
306
307 {form.watch('enableSpInitiated') && (
308 <Admonition
309 type="note"
310 title="Understanding SSO login flows"
311 className="mt-4"
312 >
313 <div className="space-y-3 text-sm">
314 <div>
315 <strong>SP-initiated (Service Provider):</strong> Users start at
316 supabase.com, enter their email address, and are redirected to your
317 identity provider (Okta, Azure AD, etc.) for authentication.
318 Requires configuring email domains.
319 </div>
320 <div>
321 <strong>IdP-initiated (Identity Provider):</strong> Users click an
322 app tile or bookmark in your identity provider dashboard and are
323 directly authenticated into Briven. Works automatically without
324 domain configuration.
325 </div>
326 <p className="text-foreground-lighter">
327 Most enterprises use IdP-initiated flow for its simplicity. Enable
328 SP-initiated only if you need users to start at supabase.com.{' '}
329 <InlineLink href={`${DOCS_URL}/guides/platform/sso#login-flows`}>
330 Learn more about SSO flows
331 </InlineLink>
332 .
333 </p>
334 </div>
335 </Admonition>
336 )}
337 </CardContent>
338
339 {form.watch('enableSpInitiated') && (
340 <CardContent>
341 <SSODomains form={form} />
342 </CardContent>
343 )}
344
345 <CardContent>
346 <SSOMetadata form={form} />
347 </CardContent>
348
349 <CardContent>
350 <AttributeMapping
351 form={form}
352 emailField="emailMapping"
353 userNameField="userNameMapping"
354 firstNameField="firstNameMapping"
355 lastNameField="lastNameMapping"
356 />
357 </CardContent>
358
359 <CardContent>
360 <JoinOrganizationOnSignup form={form} />
361 </CardContent>
362 </>
363 )}
364
365 <CardFooter className="justify-between space-x-2">
366 <div>
367 {!!ssoConfig && (
368 <Button
369 type="danger"
370 icon={<Trash />}
371 onClick={() => setIsDeleteModalVisible(true)}
372 disabled={isCreating || isUpdating || isDeleting}
373 >
374 Delete SSO Provider
375 </Button>
376 )}
377 </div>
378 <div className="flex space-x-2">
379 {form.formState.isDirty && (
380 <Button
381 type="default"
382 disabled={isCreating || isUpdating}
383 onClick={() => form.reset()}
384 >
385 Cancel
386 </Button>
387 )}
388 <Button
389 type="primary"
390 htmlType="submit"
391 loading={isCreating || isUpdating}
392 disabled={!form.formState.isDirty || isCreating || isUpdating}
393 >
394 Save changes
395 </Button>
396 </div>
397 </CardFooter>
398 </Card>
399 </form>
400 </Form>
401
402 <TextConfirmModal
403 visible={isDeleteModalVisible}
404 size="small"
405 variant="destructive"
406 title="Delete SSO Provider"
407 loading={isDeleting}
408 confirmString={ssoConfig?.domains?.[0] || organization?.slug || ''}
409 confirmPlaceholder={`Type ${ssoConfig?.domains?.[0] ? 'the first domain' : 'the organization slug'} to confirm`}
410 confirmLabel="I understand, delete SSO provider and members"
411 onConfirm={onDeleteSSOConfig}
412 onCancel={() => setIsDeleteModalVisible(false)}
413 >
414 <div className="space-y-3">
415 <p className="text-sm text-foreground-lighter">
416 You are about to delete the SSO provider
417 {ssoConfig?.domains?.[0] && (
418 <>
419 {' '}
420 for{' '}
421 <span className="text-foreground font-semibold">{ssoConfig.domains[0]}</span>
422 </>
423 )}
424 .
425 </p>
426
427 {ssoMemberCount > 0 && (
428 <div className="rounded-md bg-destructive/10 border border-destructive/30 p-3">
429 <p className="text-sm text-foreground">
430 <span className="font-semibold">
431 {ssoMemberCount} organization member{ssoMemberCount !== 1 ? 's' : ''}
432 </span>{' '}
433 who authenticate via SSO will be{' '}
434 <span className="font-semibold">permanently removed</span> from this
435 organization.
436 </p>
437 </div>
438 )}
439
440 <p className="text-sm text-foreground-lighter">This action will:</p>
441 <ul className="text-sm text-foreground-lighter list-disc list-inside space-y-1 ml-2">
442 <li>Disable SSO authentication for this organization</li>
443 <li>Remove all members who signed up using SSO</li>
444 <li>Prevent future SSO-based sign-ins</li>
445 </ul>
446
447 <p className="text-sm text-foreground-lighter">
448 <span className="text-foreground font-semibold">
449 This action cannot be undone.
450 </span>{' '}
451 Members will need to be re-invited if you wish to restore their access.
452 </p>
453 </div>
454 </TextConfirmModal>
455 </>
456 ) : null}
457 </ScaffoldSection>
458 </ScaffoldContainer>
459 )
460}