ProtectionAuthSettingsForm.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { useParams } from 'common'
  4. import Link from 'next/link'
  5. import { useEffect } from 'react'
  6. import { useForm, useWatch } from 'react-hook-form'
  7. import { toast } from 'sonner'
  8. import {
  9. Badge,
  10. Button,
  11. Card,
  12. CardContent,
  13. CardFooter,
  14. Form,
  15. FormControl,
  16. FormField,
  17. Select,
  18. SelectContent,
  19. SelectItem,
  20. SelectTrigger,
  21. SelectValue,
  22. Switch,
  23. } from 'ui'
  24. import { GenericSkeletonLoader } from 'ui-patterns'
  25. import { Input } from 'ui-patterns/DataInputs/Input'
  26. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  27. import {
  28. PageSection,
  29. PageSectionContent,
  30. PageSectionMeta,
  31. PageSectionSummary,
  32. PageSectionTitle,
  33. } from 'ui-patterns/PageSection'
  34. import * as z from 'zod'
  35. import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
  36. import AlertError from '@/components/ui/AlertError'
  37. import { InlineLink } from '@/components/ui/InlineLink'
  38. import NoPermission from '@/components/ui/NoPermission'
  39. import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
  40. import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
  41. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  42. import { DOCS_URL } from '@/lib/constants'
  43. const CAPTCHA_PROVIDERS = [
  44. { key: 'hcaptcha', label: 'hCaptcha' },
  45. { key: 'turnstile', label: 'Turnstile by Cloudflare' },
  46. ]
  47. type CaptchaProviders = 'hcaptcha' | 'turnstile'
  48. const baseSchema = z.object({
  49. DISABLE_SIGNUP: z.boolean(),
  50. EXTERNAL_ANONYMOUS_USERS_ENABLED: z.boolean(),
  51. SECURITY_MANUAL_LINKING_ENABLED: z.boolean(),
  52. SITE_URL: z.string().min(1, 'Must have a Site URL'),
  53. SESSIONS_TIMEBOX: z
  54. .preprocess(
  55. (val) => (val === '' || val == null ? undefined : val),
  56. z.coerce
  57. .number({
  58. required_error: 'Must have a sessions timebox',
  59. invalid_type_error: 'Must have a sessions timebox',
  60. })
  61. .min(0, 'Must be greater than or equal to 0.')
  62. )
  63. .optional(),
  64. SESSIONS_INACTIVITY_TIMEOUT: z.number().min(0, 'Must be greater than or equal to 0').optional(),
  65. SESSIONS_SINGLE_PER_USER: z.boolean().optional(),
  66. PASSWORD_MIN_LENGTH: z
  67. .preprocess(
  68. (val) => (val === '' || val == null ? undefined : val),
  69. z.coerce
  70. .number({
  71. required_error: 'Must have a password min length',
  72. invalid_type_error: 'Must have a password min length',
  73. })
  74. .min(6, 'Must be greater or equal to 6.')
  75. )
  76. .optional(),
  77. PASSWORD_REQUIRED_CHARACTERS: z.string().optional(),
  78. PASSWORD_HIBP_ENABLED: z.boolean().optional(),
  79. })
  80. const captchaEnabledSchema = z
  81. .object({
  82. SECURITY_CAPTCHA_ENABLED: z.literal(true),
  83. SECURITY_CAPTCHA_SECRET: z.string().min(1, 'Must have a Captcha secret'),
  84. SECURITY_CAPTCHA_PROVIDER: z.enum(['hcaptcha', 'turnstile'], {
  85. required_error: 'Captcha provider must be either hcaptcha or turnstile',
  86. }),
  87. })
  88. .merge(baseSchema)
  89. const captchaDisabledSchema = z
  90. .object({
  91. SECURITY_CAPTCHA_ENABLED: z.literal(false),
  92. SECURITY_CAPTCHA_SECRET: z.string().optional(),
  93. SECURITY_CAPTCHA_PROVIDER: z.string().optional(),
  94. })
  95. .merge(baseSchema)
  96. const formSchema = z.discriminatedUnion('SECURITY_CAPTCHA_ENABLED', [
  97. captchaEnabledSchema,
  98. captchaDisabledSchema,
  99. ])
  100. type FormSchema = z.infer<typeof formSchema>
  101. export const ProtectionAuthSettingsForm = () => {
  102. const { ref: projectRef } = useParams()
  103. const {
  104. data: authConfig,
  105. error: authConfigError,
  106. isError,
  107. isPending: isLoading,
  108. } = useAuthConfigQuery({ projectRef })
  109. const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation({
  110. onError: (error) => {
  111. toast.error(`Failed to update settings: ${error?.message}`)
  112. },
  113. onSuccess: () => {
  114. toast.success('Successfully updated settings')
  115. },
  116. })
  117. const { can: canReadConfig } = useAsyncCheckPermissions(
  118. PermissionAction.READ,
  119. 'custom_config_gotrue'
  120. )
  121. const { can: canUpdateConfig } = useAsyncCheckPermissions(
  122. PermissionAction.UPDATE,
  123. 'custom_config_gotrue'
  124. )
  125. const protectionForm = useForm<FormSchema>({
  126. resolver: zodResolver(formSchema as any),
  127. defaultValues: {
  128. DISABLE_SIGNUP: true,
  129. EXTERNAL_ANONYMOUS_USERS_ENABLED: false,
  130. SECURITY_MANUAL_LINKING_ENABLED: false,
  131. SITE_URL: '',
  132. SECURITY_CAPTCHA_ENABLED: false,
  133. SECURITY_CAPTCHA_SECRET: '',
  134. SECURITY_CAPTCHA_PROVIDER: 'hcaptcha',
  135. SESSIONS_TIMEBOX: 0,
  136. SESSIONS_INACTIVITY_TIMEOUT: 0,
  137. SESSIONS_SINGLE_PER_USER: false,
  138. PASSWORD_MIN_LENGTH: 6,
  139. PASSWORD_REQUIRED_CHARACTERS: NO_REQUIRED_CHARACTERS,
  140. PASSWORD_HIBP_ENABLED: false,
  141. },
  142. })
  143. const { isDirty } = protectionForm.formState
  144. useEffect(() => {
  145. if (authConfig && !isUpdatingConfig) {
  146. const SECURITY_CAPTCHA_PROVIDER = (authConfig.SECURITY_CAPTCHA_PROVIDER ||
  147. 'hcaptcha') as CaptchaProviders
  148. if (authConfig.SECURITY_CAPTCHA_ENABLED) {
  149. protectionForm.reset({
  150. DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP,
  151. EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED || false,
  152. SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false,
  153. SITE_URL: authConfig.SITE_URL || '',
  154. SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED,
  155. SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
  156. SECURITY_CAPTCHA_PROVIDER,
  157. SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
  158. SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
  159. SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
  160. PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
  161. PASSWORD_REQUIRED_CHARACTERS:
  162. authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
  163. PASSWORD_HIBP_ENABLED: authConfig.PASSWORD_HIBP_ENABLED || false,
  164. })
  165. } else {
  166. protectionForm.reset({
  167. DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP,
  168. EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED || false,
  169. SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false,
  170. SITE_URL: authConfig.SITE_URL || '',
  171. SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED,
  172. SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
  173. SECURITY_CAPTCHA_PROVIDER,
  174. SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
  175. SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
  176. SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
  177. PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
  178. PASSWORD_REQUIRED_CHARACTERS:
  179. authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
  180. PASSWORD_HIBP_ENABLED: authConfig.PASSWORD_HIBP_ENABLED || false,
  181. })
  182. }
  183. }
  184. }, [authConfig, isUpdatingConfig])
  185. const onSubmitProtection = (values: any) => {
  186. const payload = { ...values }
  187. payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP
  188. // The backend uses empty string to represent no required characters in the password
  189. if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) {
  190. payload.PASSWORD_REQUIRED_CHARACTERS = ''
  191. }
  192. updateAuthConfig({ projectRef: projectRef!, config: payload })
  193. }
  194. const SECURITY_CAPTCHA_ENABLED = useWatch({
  195. name: 'SECURITY_CAPTCHA_ENABLED',
  196. control: protectionForm.control,
  197. })
  198. if (isError) {
  199. return (
  200. <PageSection>
  201. <PageSectionContent>
  202. <AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
  203. </PageSectionContent>
  204. </PageSection>
  205. )
  206. }
  207. if (!canReadConfig) {
  208. return (
  209. <PageSection>
  210. <PageSectionContent>
  211. <NoPermission resourceText="view auth configuration settings" />
  212. </PageSectionContent>
  213. </PageSection>
  214. )
  215. }
  216. if (isLoading) {
  217. return (
  218. <PageSection>
  219. <PageSectionContent>
  220. <GenericSkeletonLoader />
  221. </PageSectionContent>
  222. </PageSection>
  223. )
  224. }
  225. return (
  226. <PageSection>
  227. <PageSectionMeta>
  228. <PageSectionSummary>
  229. <PageSectionTitle>Bot and Abuse Protection</PageSectionTitle>
  230. </PageSectionSummary>
  231. </PageSectionMeta>
  232. <PageSectionContent>
  233. <Form {...protectionForm}>
  234. <form onSubmit={protectionForm.handleSubmit(onSubmitProtection)} className="space-y-4">
  235. <Card>
  236. <CardContent>
  237. <FormField
  238. control={protectionForm.control}
  239. name="SECURITY_CAPTCHA_ENABLED"
  240. render={({ field }) => (
  241. <FormItemLayout
  242. layout="flex-row-reverse"
  243. label="Enable Captcha protection"
  244. description="Protect authentication endpoints from bots and abuse."
  245. >
  246. <FormControl>
  247. <Switch
  248. checked={field.value}
  249. onCheckedChange={field.onChange}
  250. disabled={!canUpdateConfig}
  251. />
  252. </FormControl>
  253. </FormItemLayout>
  254. )}
  255. />
  256. </CardContent>
  257. {SECURITY_CAPTCHA_ENABLED && (
  258. <>
  259. <CardContent>
  260. <FormField
  261. control={protectionForm.control}
  262. name="SECURITY_CAPTCHA_PROVIDER"
  263. render={({ field }) => {
  264. const selectedProvider = CAPTCHA_PROVIDERS.find(
  265. (x) => x.key === field.value
  266. )
  267. return (
  268. <FormItemLayout layout="flex-row-reverse" label="Choose Captcha Provider">
  269. <FormControl>
  270. <Select
  271. value={field.value}
  272. onValueChange={field.onChange}
  273. disabled={!canUpdateConfig}
  274. >
  275. <SelectTrigger>
  276. <SelectValue placeholder="Select provider" />
  277. </SelectTrigger>
  278. <SelectContent align="end">
  279. {CAPTCHA_PROVIDERS.map((x) => (
  280. <SelectItem key={x.key} value={x.key}>
  281. {x.label}
  282. </SelectItem>
  283. ))}
  284. </SelectContent>
  285. </Select>
  286. </FormControl>
  287. <InlineLink
  288. href={
  289. field.value === 'hcaptcha'
  290. ? `${DOCS_URL}/guides/auth/auth-captcha?queryGroups=captcha-method&captcha-method=hcaptcha-1`
  291. : field.value === 'turnstile'
  292. ? `${DOCS_URL}/guides/auth/auth-captcha?queryGroups=captcha-method&captcha-method=turnstile-1`
  293. : '/'
  294. }
  295. className="mt-2 text-xs text-foreground-light hover:text-foreground no-underline"
  296. >
  297. How to set up {selectedProvider?.label}?
  298. </InlineLink>
  299. </FormItemLayout>
  300. )
  301. }}
  302. />
  303. </CardContent>
  304. <CardContent>
  305. <FormField
  306. control={protectionForm.control}
  307. name="SECURITY_CAPTCHA_SECRET"
  308. render={({ field }) => (
  309. <FormItemLayout
  310. layout="flex-row-reverse"
  311. label="Captcha secret"
  312. description="Obtain this secret from the provider."
  313. >
  314. <FormControl>
  315. <Input {...field} reveal copy disabled={!canUpdateConfig} />
  316. </FormControl>
  317. </FormItemLayout>
  318. )}
  319. />
  320. </CardContent>
  321. </>
  322. )}
  323. <CardContent>
  324. <FormField
  325. control={protectionForm.control}
  326. name="PASSWORD_HIBP_ENABLED"
  327. render={({ field }) => (
  328. <FormItemLayout
  329. layout="flex-row-reverse"
  330. label="Prevent use of leaked passwords"
  331. description="Rejects the use of known or easy to guess passwords on sign up or password change. "
  332. >
  333. <div className="flex items-center justify-end gap-2">
  334. <Badge variant={field.value ? 'success' : 'default'}>
  335. {field.value ? 'Enabled' : 'Disabled'}
  336. </Badge>
  337. <Link href={`/project/${projectRef}/auth/providers?provider=Email`}>
  338. <Button type="default">Configure in email provider</Button>
  339. </Link>
  340. </div>
  341. </FormItemLayout>
  342. )}
  343. />
  344. </CardContent>
  345. <CardFooter className="justify-end space-x-2">
  346. {isDirty && (
  347. <Button type="default" onClick={() => protectionForm.reset()}>
  348. Cancel
  349. </Button>
  350. )}
  351. <Button
  352. type="primary"
  353. htmlType="submit"
  354. disabled={!canUpdateConfig || isUpdatingConfig || !isDirty}
  355. loading={isUpdatingConfig}
  356. >
  357. Save changes
  358. </Button>
  359. </CardFooter>
  360. </Card>
  361. </form>
  362. </Form>
  363. </PageSectionContent>
  364. </PageSection>
  365. )
  366. }