| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useParams } from 'common'
- import Link from 'next/link'
- import { useEffect } from 'react'
- import { useForm, useWatch } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Badge,
- Button,
- Card,
- CardContent,
- CardFooter,
- Form,
- FormControl,
- FormField,
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- Switch,
- } from 'ui'
- import { GenericSkeletonLoader } from 'ui-patterns'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import {
- PageSection,
- PageSectionContent,
- PageSectionMeta,
- PageSectionSummary,
- PageSectionTitle,
- } from 'ui-patterns/PageSection'
- import * as z from 'zod'
- import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
- import AlertError from '@/components/ui/AlertError'
- import { InlineLink } from '@/components/ui/InlineLink'
- import NoPermission from '@/components/ui/NoPermission'
- import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
- import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { DOCS_URL } from '@/lib/constants'
- const CAPTCHA_PROVIDERS = [
- { key: 'hcaptcha', label: 'hCaptcha' },
- { key: 'turnstile', label: 'Turnstile by Cloudflare' },
- ]
- type CaptchaProviders = 'hcaptcha' | 'turnstile'
- const baseSchema = z.object({
- DISABLE_SIGNUP: z.boolean(),
- EXTERNAL_ANONYMOUS_USERS_ENABLED: z.boolean(),
- SECURITY_MANUAL_LINKING_ENABLED: z.boolean(),
- SITE_URL: z.string().min(1, 'Must have a Site URL'),
- SESSIONS_TIMEBOX: z
- .preprocess(
- (val) => (val === '' || val == null ? undefined : val),
- z.coerce
- .number({
- required_error: 'Must have a sessions timebox',
- invalid_type_error: 'Must have a sessions timebox',
- })
- .min(0, 'Must be greater than or equal to 0.')
- )
- .optional(),
- SESSIONS_INACTIVITY_TIMEOUT: z.number().min(0, 'Must be greater than or equal to 0').optional(),
- SESSIONS_SINGLE_PER_USER: z.boolean().optional(),
- PASSWORD_MIN_LENGTH: z
- .preprocess(
- (val) => (val === '' || val == null ? undefined : val),
- z.coerce
- .number({
- required_error: 'Must have a password min length',
- invalid_type_error: 'Must have a password min length',
- })
- .min(6, 'Must be greater or equal to 6.')
- )
- .optional(),
- PASSWORD_REQUIRED_CHARACTERS: z.string().optional(),
- PASSWORD_HIBP_ENABLED: z.boolean().optional(),
- })
- const captchaEnabledSchema = z
- .object({
- SECURITY_CAPTCHA_ENABLED: z.literal(true),
- SECURITY_CAPTCHA_SECRET: z.string().min(1, 'Must have a Captcha secret'),
- SECURITY_CAPTCHA_PROVIDER: z.enum(['hcaptcha', 'turnstile'], {
- required_error: 'Captcha provider must be either hcaptcha or turnstile',
- }),
- })
- .merge(baseSchema)
- const captchaDisabledSchema = z
- .object({
- SECURITY_CAPTCHA_ENABLED: z.literal(false),
- SECURITY_CAPTCHA_SECRET: z.string().optional(),
- SECURITY_CAPTCHA_PROVIDER: z.string().optional(),
- })
- .merge(baseSchema)
- const formSchema = z.discriminatedUnion('SECURITY_CAPTCHA_ENABLED', [
- captchaEnabledSchema,
- captchaDisabledSchema,
- ])
- type FormSchema = z.infer<typeof formSchema>
- export const ProtectionAuthSettingsForm = () => {
- const { ref: projectRef } = useParams()
- const {
- data: authConfig,
- error: authConfigError,
- isError,
- isPending: isLoading,
- } = useAuthConfigQuery({ projectRef })
- const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation({
- onError: (error) => {
- toast.error(`Failed to update settings: ${error?.message}`)
- },
- onSuccess: () => {
- toast.success('Successfully updated settings')
- },
- })
- const { can: canReadConfig } = useAsyncCheckPermissions(
- PermissionAction.READ,
- 'custom_config_gotrue'
- )
- const { can: canUpdateConfig } = useAsyncCheckPermissions(
- PermissionAction.UPDATE,
- 'custom_config_gotrue'
- )
- const protectionForm = useForm<FormSchema>({
- resolver: zodResolver(formSchema as any),
- defaultValues: {
- DISABLE_SIGNUP: true,
- EXTERNAL_ANONYMOUS_USERS_ENABLED: false,
- SECURITY_MANUAL_LINKING_ENABLED: false,
- SITE_URL: '',
- SECURITY_CAPTCHA_ENABLED: false,
- SECURITY_CAPTCHA_SECRET: '',
- SECURITY_CAPTCHA_PROVIDER: 'hcaptcha',
- SESSIONS_TIMEBOX: 0,
- SESSIONS_INACTIVITY_TIMEOUT: 0,
- SESSIONS_SINGLE_PER_USER: false,
- PASSWORD_MIN_LENGTH: 6,
- PASSWORD_REQUIRED_CHARACTERS: NO_REQUIRED_CHARACTERS,
- PASSWORD_HIBP_ENABLED: false,
- },
- })
- const { isDirty } = protectionForm.formState
- useEffect(() => {
- if (authConfig && !isUpdatingConfig) {
- const SECURITY_CAPTCHA_PROVIDER = (authConfig.SECURITY_CAPTCHA_PROVIDER ||
- 'hcaptcha') as CaptchaProviders
- if (authConfig.SECURITY_CAPTCHA_ENABLED) {
- protectionForm.reset({
- DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP,
- EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED || false,
- SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false,
- SITE_URL: authConfig.SITE_URL || '',
- SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED,
- SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
- SECURITY_CAPTCHA_PROVIDER,
- SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
- SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
- SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
- PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
- PASSWORD_REQUIRED_CHARACTERS:
- authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
- PASSWORD_HIBP_ENABLED: authConfig.PASSWORD_HIBP_ENABLED || false,
- })
- } else {
- protectionForm.reset({
- DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP,
- EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED || false,
- SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false,
- SITE_URL: authConfig.SITE_URL || '',
- SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED,
- SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
- SECURITY_CAPTCHA_PROVIDER,
- SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
- SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
- SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
- PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
- PASSWORD_REQUIRED_CHARACTERS:
- authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
- PASSWORD_HIBP_ENABLED: authConfig.PASSWORD_HIBP_ENABLED || false,
- })
- }
- }
- }, [authConfig, isUpdatingConfig])
- const onSubmitProtection = (values: any) => {
- const payload = { ...values }
- payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP
- // The backend uses empty string to represent no required characters in the password
- if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) {
- payload.PASSWORD_REQUIRED_CHARACTERS = ''
- }
- updateAuthConfig({ projectRef: projectRef!, config: payload })
- }
- const SECURITY_CAPTCHA_ENABLED = useWatch({
- name: 'SECURITY_CAPTCHA_ENABLED',
- control: protectionForm.control,
- })
- if (isError) {
- return (
- <PageSection>
- <PageSectionContent>
- <AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
- </PageSectionContent>
- </PageSection>
- )
- }
- if (!canReadConfig) {
- return (
- <PageSection>
- <PageSectionContent>
- <NoPermission resourceText="view auth configuration settings" />
- </PageSectionContent>
- </PageSection>
- )
- }
- if (isLoading) {
- return (
- <PageSection>
- <PageSectionContent>
- <GenericSkeletonLoader />
- </PageSectionContent>
- </PageSection>
- )
- }
- return (
- <PageSection>
- <PageSectionMeta>
- <PageSectionSummary>
- <PageSectionTitle>Bot and Abuse Protection</PageSectionTitle>
- </PageSectionSummary>
- </PageSectionMeta>
- <PageSectionContent>
- <Form {...protectionForm}>
- <form onSubmit={protectionForm.handleSubmit(onSubmitProtection)} className="space-y-4">
- <Card>
- <CardContent>
- <FormField
- control={protectionForm.control}
- name="SECURITY_CAPTCHA_ENABLED"
- render={({ field }) => (
- <FormItemLayout
- layout="flex-row-reverse"
- label="Enable Captcha protection"
- description="Protect authentication endpoints from bots and abuse."
- >
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- disabled={!canUpdateConfig}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </CardContent>
- {SECURITY_CAPTCHA_ENABLED && (
- <>
- <CardContent>
- <FormField
- control={protectionForm.control}
- name="SECURITY_CAPTCHA_PROVIDER"
- render={({ field }) => {
- const selectedProvider = CAPTCHA_PROVIDERS.find(
- (x) => x.key === field.value
- )
- return (
- <FormItemLayout layout="flex-row-reverse" label="Choose Captcha Provider">
- <FormControl>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={!canUpdateConfig}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select provider" />
- </SelectTrigger>
- <SelectContent align="end">
- {CAPTCHA_PROVIDERS.map((x) => (
- <SelectItem key={x.key} value={x.key}>
- {x.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormControl>
- <InlineLink
- href={
- field.value === 'hcaptcha'
- ? `${DOCS_URL}/guides/auth/auth-captcha?queryGroups=captcha-method&captcha-method=hcaptcha-1`
- : field.value === 'turnstile'
- ? `${DOCS_URL}/guides/auth/auth-captcha?queryGroups=captcha-method&captcha-method=turnstile-1`
- : '/'
- }
- className="mt-2 text-xs text-foreground-light hover:text-foreground no-underline"
- >
- How to set up {selectedProvider?.label}?
- </InlineLink>
- </FormItemLayout>
- )
- }}
- />
- </CardContent>
- <CardContent>
- <FormField
- control={protectionForm.control}
- name="SECURITY_CAPTCHA_SECRET"
- render={({ field }) => (
- <FormItemLayout
- layout="flex-row-reverse"
- label="Captcha secret"
- description="Obtain this secret from the provider."
- >
- <FormControl>
- <Input {...field} reveal copy disabled={!canUpdateConfig} />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </CardContent>
- </>
- )}
- <CardContent>
- <FormField
- control={protectionForm.control}
- name="PASSWORD_HIBP_ENABLED"
- render={({ field }) => (
- <FormItemLayout
- layout="flex-row-reverse"
- label="Prevent use of leaked passwords"
- description="Rejects the use of known or easy to guess passwords on sign up or password change. "
- >
- <div className="flex items-center justify-end gap-2">
- <Badge variant={field.value ? 'success' : 'default'}>
- {field.value ? 'Enabled' : 'Disabled'}
- </Badge>
- <Link href={`/project/${projectRef}/auth/providers?provider=Email`}>
- <Button type="default">Configure in email provider</Button>
- </Link>
- </div>
- </FormItemLayout>
- )}
- />
- </CardContent>
- <CardFooter className="justify-end space-x-2">
- {isDirty && (
- <Button type="default" onClick={() => protectionForm.reset()}>
- Cancel
- </Button>
- )}
- <Button
- type="primary"
- htmlType="submit"
- disabled={!canUpdateConfig || isUpdatingConfig || !isDirty}
- loading={isUpdatingConfig}
- >
- Save changes
- </Button>
- </CardFooter>
- </Card>
- </form>
- </Form>
- </PageSectionContent>
- </PageSection>
- )
- }
|