CreateOrUpdateCustomProviderSheet.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import type { CustomOAuthProvider } from '@supabase/auth-js'
  3. import { useParams } from 'common'
  4. import { X } from 'lucide-react'
  5. import { useEffect } from 'react'
  6. import { useForm } from 'react-hook-form'
  7. import { toast } from 'sonner'
  8. import {
  9. Button,
  10. cn,
  11. Form,
  12. FormControl,
  13. FormField,
  14. FormInputGroupInput,
  15. Input,
  16. InputGroup,
  17. InputGroupAddon,
  18. InputGroupText,
  19. RadioGroupStacked,
  20. RadioGroupStackedItem,
  21. Separator,
  22. Sheet,
  23. SheetClose,
  24. SheetContent,
  25. SheetFooter,
  26. SheetHeader,
  27. SheetSection,
  28. SheetTitle,
  29. Switch,
  30. useWatch,
  31. } from 'ui'
  32. import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
  33. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  34. import * as z from 'zod'
  35. import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
  36. import { FormSectionLabel } from '@/components/ui/Forms/FormSection'
  37. import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
  38. import { useOAuthCustomProviderCreateMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-create-mutation'
  39. import {
  40. useOAuthCustomProviderUpdateMutation,
  41. type OAuthCustomProviderUpdateVariables,
  42. } from '@/data/oauth-custom-providers/oauth-custom-provider-update-mutation'
  43. import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
  44. interface CreateOrUpdateCustomProviderSheetProps {
  45. visible: boolean
  46. providerToEdit?: CustomOAuthProvider
  47. onClose: () => void
  48. }
  49. const SharedFormSchema = z.object({
  50. identifier: z
  51. .string()
  52. .min(1, 'Please provide an identifier')
  53. .regex(
  54. /^[a-zA-Z0-9_-]+$/,
  55. 'Identifier can only contain letters, numbers, hyphens, and underscores'
  56. ),
  57. name: z
  58. .string()
  59. .min(1, 'Please provide a name for your custom provider')
  60. .max(100, 'Name must be less than 100 characters'),
  61. provider_type: z.enum(['oidc', 'oauth2']).default('oidc'),
  62. client_id: z.string().min(1, 'Please provide a client ID').trim(),
  63. client_secret: z.string().min(1, 'Please provide a client secret').trim(),
  64. email_optional: z.boolean().default(false),
  65. issuer: z.string().url('Please provide a valid URL').trim(),
  66. // comma-separated scopes in the form, will be transformed to array when sending
  67. scopes: z.string().default(''),
  68. })
  69. const OidcSchema = SharedFormSchema.extend({
  70. provider_type: z.literal('oidc'),
  71. discovery_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
  72. })
  73. const OAuth2Schema = SharedFormSchema.extend({
  74. provider_type: z.literal('oauth2'),
  75. authorization_url: z
  76. .union([z.string().url('Please provide a valid URL'), z.literal('')])
  77. .default(''),
  78. token_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
  79. userinfo_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
  80. jwks_uri: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
  81. })
  82. const FormSchema = z.discriminatedUnion('provider_type', [OidcSchema, OAuth2Schema])
  83. const FORM_ID = 'create-or-update-custom-provider-form'
  84. const initialValues = {
  85. name: '',
  86. identifier: '',
  87. provider_type: 'oidc' as const,
  88. issuer: '',
  89. authorization_url: '',
  90. token_url: '',
  91. userinfo_url: '',
  92. jwks_uri: '',
  93. discovery_url: '',
  94. scopes: '',
  95. client_id: '',
  96. client_secret: '',
  97. email_optional: false,
  98. }
  99. /** Mock autodiscovery endpoint: simulates success or error (random for demo) */
  100. export const CreateOrUpdateCustomProviderSheet = ({
  101. visible,
  102. providerToEdit,
  103. onClose,
  104. }: CreateOrUpdateCustomProviderSheetProps) => {
  105. const isEditMode = !!providerToEdit
  106. const { ref: projectRef } = useParams()
  107. const { hostEndpoint: endpointData } = useProjectApiUrl({ projectRef })
  108. const form = useForm<z.infer<typeof FormSchema>>({
  109. resolver: zodResolver(FormSchema as any),
  110. defaultValues: initialValues,
  111. })
  112. useEffect(() => {
  113. if (visible) {
  114. if (providerToEdit) {
  115. if (providerToEdit.provider_type === 'oidc') {
  116. form.reset({
  117. name: providerToEdit.name,
  118. identifier: providerToEdit.identifier.replace('custom:', ''),
  119. provider_type: providerToEdit.provider_type,
  120. client_id: providerToEdit.client_id,
  121. client_secret: 'placeholder',
  122. email_optional: providerToEdit.email_optional,
  123. issuer: providerToEdit.issuer,
  124. discovery_url: providerToEdit.discovery_url,
  125. scopes: (providerToEdit.scopes || []).join(', '),
  126. })
  127. } else {
  128. form.reset({
  129. name: providerToEdit.name,
  130. identifier: providerToEdit.identifier.replace('custom:', ''),
  131. provider_type: providerToEdit.provider_type,
  132. client_id: providerToEdit.client_id,
  133. client_secret: 'placeholder',
  134. email_optional: providerToEdit.email_optional,
  135. issuer: providerToEdit.issuer,
  136. authorization_url: providerToEdit.authorization_url,
  137. token_url: providerToEdit.token_url,
  138. userinfo_url: providerToEdit.userinfo_url,
  139. jwks_uri: providerToEdit.jwks_uri,
  140. scopes: (providerToEdit.scopes || []).join(', '),
  141. })
  142. }
  143. } else {
  144. form.reset(initialValues)
  145. }
  146. }
  147. }, [visible, providerToEdit, form])
  148. const { mutate: createCustomProvider, isPending: isCreating } =
  149. useOAuthCustomProviderCreateMutation({
  150. onSuccess: () => {
  151. toast.success('Custom provider created successfully')
  152. onClose()
  153. },
  154. })
  155. const { mutate: updateCustomProvider, isPending: isUpdating } =
  156. useOAuthCustomProviderUpdateMutation({
  157. onSuccess: () => {
  158. toast.success('Custom provider updated successfully')
  159. onClose()
  160. },
  161. })
  162. const onSubmit = async (values: z.infer<typeof FormSchema>) => {
  163. const identifierValue = (values.identifier || '').replace(/^custom:/i, '').trim()
  164. const identifier = identifierValue ? `custom:${identifierValue}` : ''
  165. let payload: Partial<OAuthCustomProviderUpdateVariables> = {}
  166. if (values.provider_type === 'oidc') {
  167. payload = {
  168. skip_nonce_check: false,
  169. discovery_url:
  170. values.discovery_url ||
  171. `${values.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
  172. }
  173. } else {
  174. const issuer = values.issuer
  175. payload = {
  176. authorization_url:
  177. values.authorization_url || `${issuer.replace(/\/$/, '')}/oauth/authorize`,
  178. token_url: values.token_url || `${issuer.replace(/\/$/, '')}/oauth/token`,
  179. userinfo_url: values.userinfo_url || `${issuer.replace(/\/$/, '')}/oauth/userinfo`,
  180. jwks_uri: values.jwks_uri || `${issuer.replace(/\/$/, '')}/.well-known/jwks.json`,
  181. }
  182. }
  183. if (isEditMode) {
  184. // only include the client secret if it was changed, otherwise keep existing secret
  185. if (values.client_secret !== 'placeholder') {
  186. payload.client_secret = values.client_secret
  187. }
  188. updateCustomProvider({
  189. identifier,
  190. projectRef,
  191. clientEndpoint: endpointData,
  192. name: values.name,
  193. client_id: values.client_id,
  194. scopes: values.scopes.split(',').map((s) => s.trim()),
  195. issuer: values.issuer,
  196. pkce_enabled: true,
  197. email_optional: values.email_optional,
  198. ...payload,
  199. })
  200. } else {
  201. createCustomProvider({
  202. identifier,
  203. projectRef,
  204. clientEndpoint: endpointData,
  205. provider_type: values.provider_type,
  206. name: values.name,
  207. client_id: values.client_id,
  208. client_secret: values.client_secret,
  209. scopes: values.scopes.split(',').map((s) => s.trim()),
  210. issuer: values.issuer,
  211. pkce_enabled: true,
  212. enabled: true,
  213. email_optional: values.email_optional,
  214. ...payload,
  215. })
  216. }
  217. }
  218. const isManualConfiguration =
  219. useWatch({ control: form.control, name: 'provider_type' }) === 'oauth2'
  220. const {
  221. confirmOnClose,
  222. handleOpenChange,
  223. modalProps: closeConfirmationModalProps,
  224. } = useConfirmOnClose({
  225. checkIsDirty: () => form.formState.isDirty,
  226. onClose: () => {
  227. form.reset(initialValues)
  228. onClose()
  229. },
  230. })
  231. const issuerUrlValue = useWatch({ control: form.control, name: 'issuer' })
  232. return (
  233. <Sheet open={visible} onOpenChange={handleOpenChange}>
  234. <SheetContent
  235. size="lg"
  236. showClose={false}
  237. className="flex flex-col gap-0"
  238. tabIndex={undefined}
  239. >
  240. <SheetHeader>
  241. <div className="flex flex-row gap-3 items-center">
  242. <SheetClose
  243. className={cn(
  244. 'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
  245. 'focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
  246. 'disabled:pointer-events-none data-[state=open]:bg-secondary',
  247. 'transition'
  248. )}
  249. >
  250. <X className="h-3 w-3" />
  251. <span className="sr-only">Close</span>
  252. </SheetClose>
  253. <SheetTitle className="truncate">
  254. {isEditMode ? 'Update Custom Auth Provider' : 'Create Custom Auth Provider'}
  255. </SheetTitle>
  256. </div>
  257. </SheetHeader>
  258. <Form {...form}>
  259. <form className="grow overflow-auto" onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
  260. <SheetSection className="grow px-5 space-y-4">
  261. <FormField
  262. control={form.control}
  263. name="identifier"
  264. render={({ field }) => (
  265. <FormItemLayout
  266. layout="horizontal"
  267. label="Provider Identifier"
  268. description="Lowercase letters, numbers, and hyphens only. Used in SDK: signInWithOAuth({ provider: 'custom:my-company' })"
  269. >
  270. <FormControl>
  271. <InputGroup>
  272. <InputGroupAddon align="inline-start">
  273. <InputGroupText>custom:</InputGroupText>
  274. </InputGroupAddon>
  275. <FormInputGroupInput
  276. {...field}
  277. placeholder="my-company"
  278. disabled={isEditMode}
  279. onChange={(e) => {
  280. const raw = e.target.value
  281. const userValue = raw.replace(/^custom:/i, '').trimStart()
  282. field.onChange(userValue)
  283. }}
  284. />
  285. </InputGroup>
  286. </FormControl>
  287. </FormItemLayout>
  288. )}
  289. />
  290. <FormField
  291. control={form.control}
  292. name="name"
  293. render={({ field }) => (
  294. <FormItemLayout layout="horizontal" label="Display Name">
  295. <FormControl>
  296. <Input {...field} placeholder="Provider name" />
  297. </FormControl>
  298. </FormItemLayout>
  299. )}
  300. />
  301. <FormField
  302. control={form.control}
  303. name="provider_type"
  304. render={({ field }) => (
  305. <FormItemLayout layout="horizontal" label="Configuration Method">
  306. <RadioGroupStacked value={field.value} onValueChange={field.onChange}>
  307. <RadioGroupStackedItem
  308. className="[&>div]:px-3"
  309. value="oidc"
  310. label="Auto-discovery (Recommended)"
  311. description="Automatically fetch OAuth endpoints"
  312. />
  313. <RadioGroupStackedItem
  314. className="[&>div]:px-3"
  315. value="oauth2"
  316. label="Manual configuration"
  317. description="Enter endpoints myself"
  318. />
  319. </RadioGroupStacked>
  320. </FormItemLayout>
  321. )}
  322. />
  323. </SheetSection>
  324. <Separator />
  325. <SheetSection className="grow px-5 space-y-4">
  326. <FormSectionLabel>OAuth Endpoints</FormSectionLabel>
  327. <FormField
  328. control={form.control}
  329. name="issuer"
  330. render={({ field }) => (
  331. <FormItemLayout
  332. layout="horizontal"
  333. label="Issuer URL"
  334. description="Base URL of your OAuth provider. Discovery runs when you save."
  335. >
  336. <FormControl>
  337. <Input {...field} placeholder="https://auth.company.com" />
  338. </FormControl>
  339. </FormItemLayout>
  340. )}
  341. />
  342. </SheetSection>
  343. {isManualConfiguration ? (
  344. <SheetSection className="grow px-5 pt-0 space-y-4" key="manual-config">
  345. <FormField
  346. control={form.control}
  347. name="authorization_url"
  348. render={({ field }) => (
  349. <FormItemLayout layout="horizontal" label="Authorization URL">
  350. <FormControl>
  351. <Input {...field} placeholder="https://auth.company.com/oauth/authorize" />
  352. </FormControl>
  353. </FormItemLayout>
  354. )}
  355. />
  356. <FormField
  357. control={form.control}
  358. name="token_url"
  359. render={({ field }) => (
  360. <FormItemLayout layout="horizontal" label="Token URL">
  361. <FormControl>
  362. <Input {...field} placeholder="https://auth.company.com/oauth/token" />
  363. </FormControl>
  364. </FormItemLayout>
  365. )}
  366. />
  367. <FormField
  368. control={form.control}
  369. name="userinfo_url"
  370. render={({ field }) => (
  371. <FormItemLayout layout="horizontal" label="Userinfo URL">
  372. <FormControl>
  373. <Input {...field} placeholder="https://auth.company.com/oauth/userinfo" />
  374. </FormControl>
  375. </FormItemLayout>
  376. )}
  377. />
  378. <FormField
  379. control={form.control}
  380. name="jwks_uri"
  381. render={({ field }) => (
  382. <FormItemLayout
  383. layout="horizontal"
  384. label="JWKS URI"
  385. description="Required for ID token verification"
  386. >
  387. <FormControl>
  388. <Input
  389. {...field}
  390. placeholder="https://auth.company.com/.well-known/jwks.json"
  391. />
  392. </FormControl>
  393. </FormItemLayout>
  394. )}
  395. />
  396. </SheetSection>
  397. ) : (
  398. <SheetSection className="grow px-5 pt-0 space-y-4" key="discovery-config">
  399. <FormField
  400. control={form.control}
  401. name="discovery_url"
  402. render={({ field }) => (
  403. <FormItemLayout
  404. layout="horizontal"
  405. label="Discovery URL"
  406. description="Leave empty to use standard path: {issuer}/.well-known/openid-configuration. Only needed if your provider uses a non-standard discovery path. Discovery runs when you save."
  407. >
  408. <FormControl>
  409. <Input
  410. {...field}
  411. placeholder={
  412. issuerUrlValue
  413. ? `${issuerUrlValue}/.well-known/openid-configuration`
  414. : 'https://github.company.com/.well-known/openid-configuration'
  415. }
  416. />
  417. </FormControl>
  418. </FormItemLayout>
  419. )}
  420. />
  421. </SheetSection>
  422. )}
  423. <Separator />
  424. <SheetSection className="grow px-5 space-y-4">
  425. <FormField
  426. control={form.control}
  427. name="client_id"
  428. render={({ field }) => (
  429. <FormItemLayout layout="horizontal" label="Client ID">
  430. <FormControl>
  431. <Input {...field} placeholder="Client ID" />
  432. </FormControl>
  433. </FormItemLayout>
  434. )}
  435. />
  436. <FormField
  437. control={form.control}
  438. name="client_secret"
  439. render={({ field }) => (
  440. <FormItemLayout layout="horizontal" label="Client Secret">
  441. <FormControl>
  442. <Input {...field} type="password" placeholder="Client secret" />
  443. </FormControl>
  444. </FormItemLayout>
  445. )}
  446. />
  447. </SheetSection>
  448. <Separator />
  449. <SheetSection className="grow px-5 space-y-4">
  450. <FormField
  451. control={form.control}
  452. name="scopes"
  453. render={({ field }) => (
  454. <FormItemLayout
  455. layout="horizontal"
  456. label="Scopes"
  457. description="Comma-separated list. Common: openid, email, profile"
  458. >
  459. <FormControl>
  460. <Input {...field} placeholder="openid, email, profile" />
  461. </FormControl>
  462. </FormItemLayout>
  463. )}
  464. />
  465. <FormField
  466. control={form.control}
  467. name="email_optional"
  468. render={({ field }) => (
  469. <FormItemLayout
  470. layout="horizontal"
  471. label="Allow users without email"
  472. description="Allows the user to successfully authenticate when the provider does not return an email address."
  473. >
  474. <FormControl>
  475. <Switch checked={field.value} onCheckedChange={field.onChange} />
  476. </FormControl>
  477. </FormItemLayout>
  478. )}
  479. />
  480. </SheetSection>
  481. <Separator />
  482. <SheetSection className="grow px-5 space-y-4">
  483. <FormItemLayout
  484. layout="horizontal"
  485. label="Callback URL"
  486. description="Configure this in your OAuth provider's settings."
  487. >
  488. <PasswordInput
  489. copy
  490. readOnly
  491. disabled
  492. value={`${endpointData}/auth/v1/callback`}
  493. placeholder={`${endpointData}/auth/v1/callback`}
  494. />
  495. </FormItemLayout>
  496. </SheetSection>
  497. </form>
  498. </Form>
  499. <SheetFooter>
  500. <Button type="default" onClick={confirmOnClose}>
  501. Cancel
  502. </Button>
  503. <Button htmlType="submit" form={FORM_ID} loading={isCreating || isUpdating}>
  504. {isEditMode ? 'Update provider' : 'Create and enable provider'}
  505. </Button>
  506. </SheetFooter>
  507. </SheetContent>
  508. <DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
  509. </Sheet>
  510. )
  511. }