import { yupResolver } from '@hookform/resolvers/yup' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' 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' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { AlertTitle_Shadcn_, Alert_Shadcn_, Button, Card, CardContent, CardFooter, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Input_Shadcn_, PrePostTab, SelectContent_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, Select_Shadcn_, 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 { boolean, number, object, string } from 'yup' 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 = object({ MFA_TOTP: string().required(), MFA_MAX_ENROLLED_FACTORS: number() .min(0, 'Must be a value 0 or larger') .max(30, 'Must be a value no greater than 30'), }) const phoneSchema = object({ MFA_PHONE: string().required(), MFA_PHONE_OTP_LENGTH: number() .min(6, 'Must be a value 6 or larger') .max(30, 'must be a value no greater than 30'), MFA_PHONE_TEMPLATE: string().required('SMS template is required.'), }) const securitySchema = object({ MFA_ALLOW_LOW_AAL: boolean().required(), }) 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: yupResolver(totpSchema), defaultValues: { MFA_TOTP: 'Enabled', MFA_MAX_ENROLLED_FACTORS: 10, }, }) const phoneForm = useForm({ resolver: yupResolver(phoneSchema), defaultValues: { MFA_PHONE: 'Disabled', MFA_PHONE_OTP_LENGTH: 6, MFA_PHONE_TEMPLATE: 'Your code is {{ .Code }}', }, }) const securityForm = useForm({ resolver: yupResolver(securitySchema), defaultValues: { MFA_ALLOW_LOW_AAL: false, }, }) useEffect(() => { if (authConfig) { if (!isUpdatingTotpForm) { totpForm.reset({ 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) { phoneForm.reset({ 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) { securityForm.reset({ MFA_ALLOW_LOW_AAL: authConfig?.MFA_ALLOW_LOW_AAL ?? true, }) } } }, [authConfig, isUpdatingTotpForm, isUpdatingPhoneForm, isUpdatingSecurityForm]) const onSubmitTotpForm = (values: any) => { const { verifyEnabled: MFA_TOTP_VERIFY_ENABLED, enrollEnabled: MFA_TOTP_ENROLL_ENABLED } = MfaStatusToState(values.MFA_TOTP) const payload = { ...values, MFA_TOTP_ENROLL_ENABLED, MFA_TOTP_VERIFY_ENABLED, } delete payload.MFA_TOTP 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 = (values: any) => { const payload = { ...values } setIsUpdatingSecurityForm(true) updateAuthConfig( { projectRef: projectRef!, config: payload }, { onError: (error) => { toast.error(`Failed to update phone MFA settings: ${error?.message}`) setIsUpdatingSecurityForm(false) }, onSuccess: () => { toast.success('Successfully updated phone MFA settings') setIsUpdatingSecurityForm(false) }, } ) } const onSubmitPhoneForm = (values: any) => { let payload = { ...values } if (hasAccessToMFA) { const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } = MfaStatusToState(values.MFA_PHONE) payload = { ...payload, MFA_PHONE_ENROLL_ENABLED, MFA_PHONE_VERIFY_ENABLED, } } delete payload.MFA_PHONE 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)
( {MFAFactorSelectionOptions.map((option) => ( {option.label} ))} )} /> ( )} /> {totpForm.formState.isDirty && ( )}
SMS MFA
{ e.preventDefault() maybeConfirmPhoneMFAOrSubmit() }} > ( {MFAFactorSelectionOptions.map((option) => ( {option.label} ))} )} /> {!hasValidMFAProvider && phoneMFAIsEnabled && ( To use MFA with Phone you should set up a Phone provider or Send SMS Hook. )} ( )} /> ( )} /> {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 && ( )}
) }