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