ProviderForm.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { useParams } from 'common'
  4. import { Check } from 'lucide-react'
  5. import { useTheme } from 'next-themes'
  6. import { useQueryState } from 'nuqs'
  7. import { useEffect, useId, useMemo, useState } from 'react'
  8. import { useForm } from 'react-hook-form'
  9. import ReactMarkdown from 'react-markdown'
  10. import { toast } from 'sonner'
  11. import {
  12. Button,
  13. Form,
  14. Sheet,
  15. SheetContent,
  16. SheetFooter,
  17. SheetHeader,
  18. SheetSection,
  19. SheetTitle,
  20. } from 'ui'
  21. import { Admonition } from 'ui-patterns'
  22. import { Input } from 'ui-patterns/DataInputs/Input'
  23. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  24. import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
  25. import { AuthAlert } from './AuthAlert'
  26. import type { Provider } from './AuthProvidersForm.types'
  27. import FormField from './FormField'
  28. import { Markdown } from '@/components/interfaces/Markdown'
  29. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  30. import { DocsButton } from '@/components/ui/DocsButton'
  31. import { ResourceItem } from '@/components/ui/Resource/ResourceItem'
  32. import type { components } from '@/data/api'
  33. import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
  34. import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
  35. import { useHasEntitlementAccess } from '@/hooks/misc/useCheckEntitlements'
  36. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  37. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  38. import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
  39. import { BASE_PATH } from '@/lib/constants'
  40. interface ProviderFormProps {
  41. config: components['schemas']['GoTrueConfigResponse']
  42. provider: Provider
  43. isActive: boolean
  44. }
  45. const doubleNegativeKeys = ['SMS_AUTOCONFIRM']
  46. export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) => {
  47. const { resolvedTheme } = useTheme()
  48. const { ref: projectRef } = useParams()
  49. const { data: organization } = useSelectedOrganizationQuery()
  50. const [urlProvider, setUrlProvider] = useQueryState('provider', { defaultValue: '' })
  51. const [open, setOpen] = useState(false)
  52. const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation()
  53. const { data: endpoint } = useProjectApiUrl({ projectRef })
  54. const { can: canUpdateConfig } = useAsyncCheckPermissions(
  55. PermissionAction.UPDATE,
  56. 'custom_config_gotrue'
  57. )
  58. const shouldDisableField = (field: string): boolean => {
  59. const shouldDisableSmsFields =
  60. config.HOOK_SEND_SMS_ENABLED &&
  61. field.startsWith('SMS_') &&
  62. ![
  63. 'SMS_AUTOCONFIRM',
  64. 'SMS_OTP_EXP',
  65. 'SMS_OTP_LENGTH',
  66. 'SMS_OTP_LENGTH',
  67. 'SMS_TEMPLATE',
  68. 'SMS_TEST_OTP',
  69. 'SMS_TEST_OTP_VALID_UNTIL',
  70. ].includes(field)
  71. return (
  72. ['EXTERNAL_SLACK_CLIENT_ID', 'EXTERNAL_SLACK_SECRET'].includes(field) ||
  73. shouldDisableSmsFields
  74. )
  75. }
  76. const hasEntitlementAccess = useHasEntitlementAccess()
  77. const getValuesForProvider = useStaticEffectEvent(
  78. (config: components['schemas']['GoTrueConfigResponse']) => {
  79. const values: { [x: string]: string | boolean } = {}
  80. Object.keys(provider.properties).forEach((key) => {
  81. // This ensures the default value is visibly selected
  82. if (key === 'PASSWORD_REQUIRED_CHARACTERS' && config.PASSWORD_REQUIRED_CHARACTERS === '') {
  83. values[key] = NO_REQUIRED_CHARACTERS
  84. return
  85. }
  86. const isDoubleNegative = doubleNegativeKeys.includes(key)
  87. if (provider.title === 'SAML 2.0') {
  88. const configValue = (config as any)[key]
  89. values[key] = configValue || (provider.properties[key].type === 'boolean' ? false : '')
  90. } else {
  91. if (isDoubleNegative) {
  92. values[key] = !(config as any)[key]
  93. } else {
  94. const configValue = (config as any)[key]
  95. values[key] = configValue
  96. ? configValue
  97. : provider.properties[key].type === 'boolean'
  98. ? false
  99. : ''
  100. }
  101. }
  102. })
  103. return values
  104. }
  105. )
  106. const INITIAL_VALUES = useMemo(() => {
  107. // This check will always be true but let us avoid adding an eslint disable comment on unused memo dependencies
  108. // which could hide real issues in the future.
  109. // Adding the provider in the memo dependencies ensures the INITIAL_VALUES is properly applied
  110. if (!provider) return
  111. return getValuesForProvider(config)
  112. }, [config, getValuesForProvider, provider])
  113. const onSubmit = (values: any) => {
  114. const payload = { ...values }
  115. Object.keys(values).map((x: string) => {
  116. if (doubleNegativeKeys.includes(x)) payload[x] = !values[x]
  117. if (payload[x] === '') payload[x] = null
  118. })
  119. // The backend uses empty string to represent no required characters in the password
  120. if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) {
  121. payload.PASSWORD_REQUIRED_CHARACTERS = ''
  122. }
  123. updateAuthConfig(
  124. { projectRef: projectRef!, config: payload },
  125. {
  126. onSuccess: (newValues) => {
  127. setOpen(false)
  128. setUrlProvider(null)
  129. form.reset(getValuesForProvider(newValues))
  130. toast.success('Successfully updated settings')
  131. },
  132. }
  133. )
  134. }
  135. // Handle clicking on a provider in the list
  136. const handleProviderClick = () => setUrlProvider(provider.title)
  137. const handleOpenChange = (isOpen: boolean) => {
  138. // Remove provider query param from URL when closed
  139. if (!isOpen) setUrlProvider(null)
  140. }
  141. // Open or close the form based on the query parameter
  142. useEffect(() => {
  143. const isProviderInQuery = urlProvider.toLowerCase() === provider.title.toLowerCase()
  144. setOpen(isProviderInQuery)
  145. }, [urlProvider, provider.title])
  146. const form = useForm({
  147. defaultValues: INITIAL_VALUES,
  148. resolver: zodResolver(provider.validationSchema as any),
  149. shouldUnregister: false,
  150. })
  151. useEffect(() => {
  152. if (open) {
  153. form.reset(INITIAL_VALUES)
  154. }
  155. }, [open, form, INITIAL_VALUES])
  156. const formId = useId()
  157. return (
  158. <>
  159. <ResourceItem
  160. onClick={handleProviderClick}
  161. media={
  162. <img
  163. src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}${provider.misc.hasLightIcon && !resolvedTheme?.includes('dark') ? '-light' : ''}.svg`}
  164. width={18}
  165. height={18}
  166. alt={`${provider.title} auth icon`}
  167. />
  168. }
  169. meta={
  170. isActive ? (
  171. <div className="flex items-center gap-1 rounded-full border border-brand-400 bg-brand-200 py-1 px-1 text-xs text-brand">
  172. <span className="rounded-full bg-brand p-0.5 text-xs text-brand-200">
  173. <Check strokeWidth={2} size={12} />
  174. </span>
  175. <span className="px-1">Enabled</span>
  176. </div>
  177. ) : (
  178. <div className="rounded-md border border-strong bg-surface-100 py-1 px-3 text-xs text-foreground-lighter">
  179. Disabled
  180. </div>
  181. )
  182. }
  183. >
  184. {provider.title}
  185. </ResourceItem>
  186. <Sheet open={open} onOpenChange={handleOpenChange}>
  187. <SheetContent className="flex flex-col gap-0" size="lg">
  188. <SheetHeader className="shrink-0 flex items-center gap-4">
  189. <img
  190. src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}${provider.misc.hasLightIcon && !resolvedTheme?.includes('dark') ? '-light' : ''}.svg`}
  191. width={18}
  192. height={18}
  193. alt={`${provider.title} auth icon`}
  194. />
  195. <SheetTitle>{provider.title}</SheetTitle>
  196. </SheetHeader>
  197. <Form {...form}>
  198. <form
  199. id={formId}
  200. name={formId}
  201. className="overflow-y-auto grow px-0"
  202. onSubmit={form.handleSubmit(onSubmit)}
  203. >
  204. <AuthAlert
  205. title={provider.title}
  206. isHookSendSMSEnabled={config.HOOK_SEND_SMS_ENABLED}
  207. />
  208. {Object.keys(provider.properties).map((x: string) => {
  209. const { entitlementKey } = provider.properties[x]
  210. const hasAccess = entitlementKey == null || hasEntitlementAccess(entitlementKey)
  211. return (
  212. <FormField
  213. key={x}
  214. projectRef={projectRef}
  215. organizationSlug={organization?.slug}
  216. name={x}
  217. properties={provider.properties[x]}
  218. control={form.control}
  219. readOnly={shouldDisableField(x) || !canUpdateConfig}
  220. hasAccess={hasAccess}
  221. />
  222. )
  223. })}
  224. {provider?.misc?.alert && (
  225. <SheetSection>
  226. <Admonition
  227. type="warning"
  228. title={provider.misc.alert.title}
  229. description={<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>}
  230. />
  231. </SheetSection>
  232. )}
  233. {provider.misc.requiresRedirect && (
  234. <SheetSection>
  235. <FormItemLayout
  236. layout="horizontal"
  237. label="Callback URL (for OAuth)"
  238. description={
  239. <Markdown
  240. content={provider.misc.helper}
  241. className="text-foreground-lighter"
  242. />
  243. }
  244. >
  245. <Input copy readOnly value={endpoint ? `${endpoint}/auth/v1/callback` : ''} />
  246. </FormItemLayout>
  247. </SheetSection>
  248. )}
  249. </form>
  250. </Form>
  251. <SheetFooter className="shrink-0">
  252. <div className="flex items-center justify-between w-full">
  253. <DocsButton href={provider.link} />
  254. <div className="flex items-center gap-x-3">
  255. <Button
  256. type="default"
  257. htmlType="reset"
  258. onClick={() => {
  259. setOpen(false)
  260. setUrlProvider(null)
  261. form.reset()
  262. }}
  263. disabled={isUpdatingConfig}
  264. >
  265. Cancel
  266. </Button>
  267. <ButtonTooltip
  268. form={formId}
  269. htmlType="submit"
  270. loading={isUpdatingConfig}
  271. disabled={isUpdatingConfig || !canUpdateConfig || !form.formState.isDirty}
  272. tooltip={{
  273. content: {
  274. side: 'bottom',
  275. text: !canUpdateConfig
  276. ? 'You need additional permissions to update provider settings'
  277. : undefined,
  278. },
  279. }}
  280. >
  281. Save
  282. </ButtonTooltip>
  283. </div>
  284. </div>
  285. </SheetFooter>
  286. </SheetContent>
  287. </Sheet>
  288. </>
  289. )
  290. }