From 9e2b6461ea0bce87bf768e241521796169e4d691 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:16:40 +0200 Subject: [PATCH] chore: migrate protection auth settings form to `zod` --- .../ProtectionAuthSettingsForm.tsx | 150 ++++++++++++------ 1 file changed, 103 insertions(+), 47 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx index 270aae48da2..8950ee60ef6 100644 --- a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx @@ -1,4 +1,4 @@ -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import Link from 'next/link' @@ -31,7 +31,7 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' -import { boolean, number, object, string } from 'yup' +import * as z from 'zod' import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' import AlertError from '@/components/ui/AlertError' @@ -47,31 +47,65 @@ const CAPTCHA_PROVIDERS = [ { key: 'turnstile', label: 'Turnstile by Cloudflare' }, ] -const schema = object({ - DISABLE_SIGNUP: boolean().required(), - EXTERNAL_ANONYMOUS_USERS_ENABLED: boolean().required(), - SECURITY_MANUAL_LINKING_ENABLED: boolean().required(), - SITE_URL: string().required('Must have a Site URL'), - SECURITY_CAPTCHA_ENABLED: boolean().required(), - SECURITY_CAPTCHA_SECRET: string().when('SECURITY_CAPTCHA_ENABLED', { - is: true, - then: (schema) => schema.required('Must have a Captcha secret'), - }), - SECURITY_CAPTCHA_PROVIDER: string().when('SECURITY_CAPTCHA_ENABLED', { - is: true, - then: (schema) => - schema - .oneOf(['hcaptcha', 'turnstile']) - .required('Captcha provider must be either hcaptcha or turnstile'), - }), - SESSIONS_TIMEBOX: number().min(0, 'Must be a positive number'), - SESSIONS_INACTIVITY_TIMEOUT: number().min(0, 'Must be a positive number'), - SESSIONS_SINGLE_PER_USER: boolean(), - PASSWORD_MIN_LENGTH: number().min(6, 'Must be greater or equal to 6.'), - PASSWORD_REQUIRED_CHARACTERS: string(), - PASSWORD_HIBP_ENABLED: boolean(), +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 + export const ProtectionAuthSettingsForm = () => { const { ref: projectRef } = useParams() const { @@ -98,8 +132,8 @@ export const ProtectionAuthSettingsForm = () => { 'custom_config_gotrue' ) - const protectionForm = useForm({ - resolver: yupResolver(schema), + const protectionForm = useForm({ + resolver: zodResolver(formSchema), defaultValues: { DISABLE_SIGNUP: true, EXTERNAL_ANONYMOUS_USERS_ENABLED: false, @@ -117,24 +151,48 @@ export const ProtectionAuthSettingsForm = () => { }, }) + const { isDirty } = protectionForm.formState + useEffect(() => { if (authConfig && !isUpdatingConfig) { - 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 || false, - SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '', - SECURITY_CAPTCHA_PROVIDER: authConfig.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha', - 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, - }) + if (authConfig.SECURITY_CAPTCHA_ENABLED) { + const SECURITY_CAPTCHA_PROVIDER = (authConfig.SECURITY_CAPTCHA_PROVIDER || + 'hcaptcha') as CaptchaProviders + + 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: 'hcaptcha', + 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]) @@ -304,7 +362,7 @@ export const ProtectionAuthSettingsForm = () => { - {protectionForm.formState.isDirty && ( + {isDirty && ( @@ -312,9 +370,7 @@ export const ProtectionAuthSettingsForm = () => {