import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Alert, AlertTitle, Button, Card, CardContent, CardFooter, Form, FormControl, FormField, FormInputGroupInput, Input, InputGroup, InputGroupAddon, InputGroupText, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, WarningIcon, } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, PageSectionContent, PageSectionMeta, PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' import * as z from 'zod' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import AlertError from '@/components/ui/AlertError' import NoPermission from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { IS_PLATFORM } from '@/lib/constants' function determineMFAStatus(verifyEnabled: boolean, enrollEnabled: boolean) { return verifyEnabled ? (enrollEnabled ? 'Enabled' : 'Verify Enabled') : 'Disabled' } const MFAFactorSelectionOptions = [ { label: 'Enabled', value: 'Enabled', }, { label: 'Verify Enabled', value: 'Verify Enabled', }, { label: 'Disabled', value: 'Disabled', }, ] const MfaStatusToState = (status: (typeof MFAFactorSelectionOptions)[number]['value']) => { return status === 'Enabled' ? { verifyEnabled: true, enrollEnabled: true } : status === 'Verify Enabled' ? { verifyEnabled: true, enrollEnabled: false } : { verifyEnabled: false, enrollEnabled: false } } const totpSchema = z.object({ MFA_TOTP: z.string().min(1, 'Required'), MFA_MAX_ENROLLED_FACTORS: z.preprocess( (val) => (val === '' || val == null ? undefined : val), z.coerce .number({ required_error: 'Required', invalid_type_error: 'Required' }) .min(0, 'Must be a value 0 or larger') .max(30, 'Must be a value no greater than 30') ), }) type TotpFormValues = z.infer const phoneSchema = z.object({ MFA_PHONE: z.string().min(1, 'Required'), MFA_PHONE_OTP_LENGTH: z.preprocess( (val) => (val === '' || val == null ? undefined : val), z.coerce .number({ required_error: 'Required', invalid_type_error: 'Required' }) .min(6, 'Must be a value 6 or larger') .max(30, 'must be a value no greater than 30') ), MFA_PHONE_TEMPLATE: z.string().min(1, 'Required'), }) type PhoneFormValues = z.infer const securitySchema = z.object({ MFA_ALLOW_LOW_AAL: z.boolean({ required_error: 'Required' }), }) type SecurityFormValues = z.infer export const MfaAuthSettingsForm = () => { const { ref: projectRef } = useParams() const { data: authConfig, error: authConfigError, isError, isPending: isLoading, } = useAuthConfigQuery({ projectRef }) const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation() // Separate loading states for each form const [isUpdatingTotpForm, setIsUpdatingTotpForm] = useState(false) const [isUpdatingPhoneForm, setIsUpdatingPhoneForm] = useState(false) const [isUpdatingSecurityForm, setIsUpdatingSecurityForm] = useState(false) const [isConfirmationModalVisible, setIsConfirmationModalVisible] = useState(false) const { can: canReadConfig } = useAsyncCheckPermissions( PermissionAction.READ, 'custom_config_gotrue' ) const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) const { hasAccess: hasAccessToMFAEntitlement, isLoading: isLoadingEntitlement } = useCheckEntitlements('auth.mfa_phone') const hasAccessToMFA = !IS_PLATFORM || hasAccessToMFAEntitlement const promptProPlanUpgrade = IS_PLATFORM && !hasAccessToMFAEntitlement const { hasAccess: hasAccessToEnhanceSecurityEntitlement, isLoading: isLoadingEntitlementEnhanceSecurity, } = useCheckEntitlements('auth.mfa_enhanced_security') const hasAccessToEnhanceSecurity = !IS_PLATFORM || hasAccessToEnhanceSecurityEntitlement const promptEnhancedSecurityUpgrade = IS_PLATFORM && !hasAccessToEnhanceSecurityEntitlement // For now, we support Twilio and Vonage. Twilio Verify is not supported and the remaining providers are community maintained. const sendSMSHookIsEnabled = authConfig?.HOOK_SEND_SMS_URI !== null && authConfig?.HOOK_SEND_SMS_ENABLED === true const hasValidMFAPhoneProvider = authConfig?.EXTERNAL_PHONE_ENABLED === true const hasValidMFAProvider = hasValidMFAPhoneProvider || sendSMSHookIsEnabled const totpForm = useForm({ resolver: zodResolver(totpSchema as any), defaultValues: { MFA_TOTP: 'Enabled', MFA_MAX_ENROLLED_FACTORS: 10, }, }) const { reset: resetTotpForm } = totpForm const phoneForm = useForm({ resolver: zodResolver(phoneSchema as any), defaultValues: { MFA_PHONE: 'Disabled', MFA_PHONE_OTP_LENGTH: 6, MFA_PHONE_TEMPLATE: 'Your code is {{ .Code }}', }, }) const { reset: resetPhoneForm } = phoneForm const securityForm = useForm({ resolver: zodResolver(securitySchema as any), defaultValues: { MFA_ALLOW_LOW_AAL: false, }, }) const { reset: resetSecurityForm } = securityForm useEffect(() => { if (authConfig) { if (!isUpdatingTotpForm) { resetTotpForm({ MFA_TOTP: determineMFAStatus( authConfig?.MFA_TOTP_VERIFY_ENABLED ?? true, authConfig?.MFA_TOTP_ENROLL_ENABLED ?? true ) || 'Enabled', MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS ?? 10, }) } if (!isUpdatingPhoneForm) { resetPhoneForm({ MFA_PHONE: determineMFAStatus( authConfig?.MFA_PHONE_VERIFY_ENABLED || false, authConfig?.MFA_PHONE_ENROLL_ENABLED || false ) || 'Disabled', MFA_PHONE_OTP_LENGTH: authConfig?.MFA_PHONE_OTP_LENGTH || 6, MFA_PHONE_TEMPLATE: authConfig?.MFA_PHONE_TEMPLATE || 'Your code is {{ .Code }}', }) } if (!isUpdatingSecurityForm) { resetSecurityForm({ MFA_ALLOW_LOW_AAL: authConfig?.MFA_ALLOW_LOW_AAL ?? true, }) } } }, [ authConfig, isUpdatingTotpForm, isUpdatingPhoneForm, isUpdatingSecurityForm, resetTotpForm, resetPhoneForm, resetSecurityForm, ]) const onSubmitTotpForm: SubmitHandler = (values) => { const { verifyEnabled: MFA_TOTP_VERIFY_ENABLED, enrollEnabled: MFA_TOTP_ENROLL_ENABLED } = MfaStatusToState(values.MFA_TOTP) const payload = { MFA_MAX_ENROLLED_FACTORS: values.MFA_MAX_ENROLLED_FACTORS, MFA_TOTP_ENROLL_ENABLED, MFA_TOTP_VERIFY_ENABLED, } setIsUpdatingTotpForm(true) updateAuthConfig( { projectRef: projectRef!, config: payload }, { onError: (error) => { toast.error(`Failed to update TOTP settings: ${error?.message}`) setIsUpdatingTotpForm(false) }, onSuccess: () => { toast.success('Successfully updated TOTP settings') setIsUpdatingTotpForm(false) }, } ) } const onSubmitSecurityForm: SubmitHandler = (values) => { setIsUpdatingSecurityForm(true) updateAuthConfig( { projectRef: projectRef!, config: values }, { onError: (error) => { toast.error(`Failed to update enhanced MFA security settings: ${error?.message}`) setIsUpdatingSecurityForm(false) }, onSuccess: () => { toast.success('Successfully updated enhanced MFA security settings') setIsUpdatingSecurityForm(false) }, } ) } const onSubmitPhoneForm: SubmitHandler = (values) => { let payload: Record = { MFA_PHONE_OTP_LENGTH: values.MFA_PHONE_OTP_LENGTH, MFA_PHONE_TEMPLATE: values.MFA_PHONE_TEMPLATE, } if (hasAccessToMFA) { const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } = MfaStatusToState(values.MFA_PHONE) payload = { MFA_PHONE_OTP_LENGTH: values.MFA_PHONE_OTP_LENGTH, MFA_PHONE_TEMPLATE: values.MFA_PHONE_TEMPLATE, MFA_PHONE_ENROLL_ENABLED, MFA_PHONE_VERIFY_ENABLED, } } setIsUpdatingPhoneForm(true) updateAuthConfig( { projectRef: projectRef!, config: payload }, { onError: (error) => { toast.error(`Failed to update phone MFA settings: ${error?.message}`) setIsUpdatingPhoneForm(false) }, onSuccess: () => { toast.success('Successfully updated phone MFA settings') setIsUpdatingPhoneForm(false) }, } ) } if (isError) { return ( ) } if (!canReadConfig) { return ( ) } if (isLoading || isLoadingEntitlement || isLoadingEntitlementEnhanceSecurity) { return ( ) } const phoneMFAIsEnabled = phoneForm.watch('MFA_PHONE') === 'Enabled' || phoneForm.watch('MFA_PHONE') === 'Verify Enabled' const hasUpgradedPhoneMFA = authConfig && !authConfig.MFA_PHONE_VERIFY_ENABLED && phoneMFAIsEnabled const maybeConfirmPhoneMFAOrSubmit = () => { if (hasUpgradedPhoneMFA) { setIsConfirmationModalVisible(true) } else { phoneForm.handleSubmit(onSubmitPhoneForm)() } } return ( <> Multi-Factor Authentication (MFA)
( )} /> ( factors )} /> {totpForm.formState.isDirty && ( )}
SMS MFA
{ e.preventDefault() maybeConfirmPhoneMFAOrSubmit() }} > ( )} /> {!hasValidMFAProvider && phoneMFAIsEnabled && ( To use MFA with Phone you should set up a Phone provider or Send SMS Hook. )} ( digits )} /> ( )} /> {promptProPlanUpgrade && ( )} {phoneForm.formState.isDirty && ( )}
setIsConfirmationModalVisible(false)} onConfirm={() => { setIsConfirmationModalVisible(false) phoneForm.handleSubmit(onSubmitPhoneForm)() }} variant="warning" > Enabling SMS MFA will result in an additional charge of $75 per month for the first project in the organization and an additional{' '} $10 per month for additional projects.

Billing will start immediately upon enabling this add-on, regardless of whether your customers are using SMS MFA.

Enhanced MFA Security
( field.onChange(!value)} disabled={!canUpdateConfig || !hasAccessToEnhanceSecurity} /> )} /> {promptEnhancedSecurityUpgrade && ( )} {securityForm.formState.isDirty && ( )}
) }