SSOConfig.tsx 19 KB

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