Files
supabase/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx
T
Ignacio Dobronich 07d75d4e79 feat: add TaxDisclaimer for addons (#45235)
## Summary

Adds a reusable `TaxDisclaimer` component ("Prices shown do not include
applicable taxes.") and places it on surfaces where users see a price
before confirming a billable action.

## Where it appears

- **Disk resize review
dialog** — `DiskManagementReviewAndSubmitDialog` (below the before/after
price comparison)
- **Add-on side panels** — PITR, Custom Domain, IPv4 (below the price
options)
- **Log drain destination form** — stacked under "See full pricing
breakdown here" in the footer
- **SMS MFA confirmation modal** — below the $75/$10 billing copy
- **Read replica pricing dialog** — at the end of the cost breakdown
- **Create branch modal** — below the disk/compute cost estimates

## Test plan

- [ ]  Open disk/compute resize review dialog — disclaimer appears below
the before/after panel
- [ ]  Open each add-on side panel (PITR / Custom Domain / IPv4) —
disclaimer appears below the price options
- [ ]  Open log drain destination sheet — disclaimer stacks under the
pricing breakdown link in the footer
- [ ]  Trigger SMS MFA confirmation — disclaimer appears below the
billing copy
- [ ]  Open read replica pricing dialog ("Learn more" from deploy
replica) — disclaimer at the bottom
- [ ]  Open create branch modal — disclaimer appears after the compute
cost block

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added tax disclaimers across multiple billing and pricing interfaces
throughout the platform. Users will now see notices regarding applicable
taxes displayed in various authentication settings, branch creation
workflows, database disk management dialogs, database replica pricing
screens, log drain configuration panels, custom domain settings, IPv4
address configuration, and Point-in-Time Recovery options.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-24 16:58:23 -03:00

684 lines
24 KiB
TypeScript

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_Shadcn_,
AlertTitle_Shadcn_,
Button,
Card,
CardContent,
CardFooter,
Form,
FormControl,
FormField,
FormInputGroupInput,
Input_Shadcn_,
InputGroup,
InputGroupAddon,
InputGroupText,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_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 * 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<typeof totpSchema>
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<typeof phoneSchema>
const securitySchema = z.object({
MFA_ALLOW_LOW_AAL: z.boolean({ required_error: 'Required' }),
})
type SecurityFormValues = z.infer<typeof securitySchema>
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<TotpFormValues>({
resolver: zodResolver(totpSchema),
defaultValues: {
MFA_TOTP: 'Enabled',
MFA_MAX_ENROLLED_FACTORS: 10,
},
})
const { reset: resetTotpForm } = totpForm
const phoneForm = useForm<PhoneFormValues>({
resolver: zodResolver(phoneSchema),
defaultValues: {
MFA_PHONE: 'Disabled',
MFA_PHONE_OTP_LENGTH: 6,
MFA_PHONE_TEMPLATE: 'Your code is {{ .Code }}',
},
})
const { reset: resetPhoneForm } = phoneForm
const securityForm = useForm<SecurityFormValues>({
resolver: zodResolver(securitySchema),
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<TotpFormValues> = (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<SecurityFormValues> = (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<PhoneFormValues> = (values) => {
let payload: Record<string, string | number | boolean> = {
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 (
<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 || isLoadingEntitlement || isLoadingEntitlementEnhanceSecurity) {
return (
<PageSection>
<PageSectionContent>
<GenericSkeletonLoader />
</PageSectionContent>
</PageSection>
)
}
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 (
<>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Multi-Factor Authentication (MFA)</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form {...totpForm}>
<form onSubmit={totpForm.handleSubmit(onSubmitTotpForm)} className="space-y-4">
<Card>
<CardContent>
<FormField
control={totpForm.control}
name="MFA_TOTP"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="TOTP (App Authenticator)"
description="Control use of TOTP (App Authenticator) factors"
>
<FormControl>
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
disabled={!canUpdateConfig}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select status" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{MFAFactorSelectionOptions.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField
control={totpForm.control}
name="MFA_MAX_ENROLLED_FACTORS"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Maximum number of per-user MFA factors"
description="How many MFA factors can be enrolled at once per user."
>
<FormControl>
<InputGroup>
<FormInputGroupInput
type="number"
min={0}
max={30}
{...field}
disabled={!canUpdateConfig}
data-1p-ignore // 1Password
data-lpignore="true" // LastPass
data-form-type="other" // Dashlane
data-bwignore // Bitwarden
/>
<InputGroupAddon align="inline-end">
<InputGroupText>factors</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end space-x-2">
{totpForm.formState.isDirty && (
<Button type="default" onClick={() => totpForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isUpdatingTotpForm || !totpForm.formState.isDirty}
loading={isUpdatingTotpForm}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>SMS MFA</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form {...phoneForm}>
<form
onSubmit={(e) => {
e.preventDefault()
maybeConfirmPhoneMFAOrSubmit()
}}
>
<Card>
<CardContent>
<FormField
control={phoneForm.control}
name="MFA_PHONE"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Phone"
description="Control use of phone factors"
>
<FormControl>
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
disabled={!canUpdateConfig || !hasAccessToMFA}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select status" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{MFAFactorSelectionOptions.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl>
</FormItemLayout>
)}
/>
{!hasValidMFAProvider && phoneMFAIsEnabled && (
<Alert_Shadcn_ variant="warning" className="mt-3">
<WarningIcon />
<AlertTitle_Shadcn_>
To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
)}
</CardContent>
<CardContent>
<FormField
control={phoneForm.control}
name="MFA_PHONE_OTP_LENGTH"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Phone OTP Length"
description="Number of digits in OTP"
>
<FormControl>
<InputGroup>
<FormInputGroupInput
type="number"
min={6}
max={30}
{...field}
disabled={!canUpdateConfig || !hasAccessToMFA}
data-1p-ignore // 1Password
data-lpignore="true" // LastPass
data-form-type="other" // Dashlane
data-bwignore // Bitwarden
/>
<InputGroupAddon align="inline-end">
<InputGroupText>digits</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField
control={phoneForm.control}
name="MFA_PHONE_TEMPLATE"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Phone verification message"
description="To format the OTP code use `{{ .Code }}`"
>
<FormControl>
<Input_Shadcn_
type="text"
{...field}
disabled={!canUpdateConfig || !hasAccessToMFA}
data-1p-ignore // 1Password
data-lpignore="true" // LastPass
data-form-type="other" // Dashlane
data-bwignore // Bitwarden
/>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{promptProPlanUpgrade && (
<UpgradeToPro
fullWidth
source="authSmsMfa"
featureProposition="configure settings for SMS MFA"
primaryText="SMS MFA is only available on the Pro Plan and above"
secondaryText="Upgrade to the Pro plan to configure settings for SMS MFA."
/>
)}
<CardFooter className="justify-end space-x-2">
{phoneForm.formState.isDirty && (
<Button type="default" onClick={() => phoneForm.reset()}>
Cancel
</Button>
)}
<Button
type={promptProPlanUpgrade ? 'default' : 'primary'}
htmlType="submit"
disabled={
!canUpdateConfig ||
isUpdatingPhoneForm ||
!phoneForm.formState.isDirty ||
!hasAccessToMFA
}
loading={isUpdatingPhoneForm}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form>
</PageSectionContent>
</PageSection>
<ConfirmationModal
visible={isConfirmationModalVisible}
title="Confirm SMS MFA"
confirmLabel="Confirm and save"
onCancel={() => setIsConfirmationModalVisible(false)}
onConfirm={() => {
setIsConfirmationModalVisible(false)
phoneForm.handleSubmit(onSubmitPhoneForm)()
}}
variant="warning"
>
Enabling SMS MFA will result in an additional charge of <span translate="no">$75</span> per
month for the first project in the organization and an additional{' '}
<span translate="no">$10</span> per month for additional projects.
<p className="mt-2">
Billing will start immediately upon enabling this add-on, regardless of whether your
customers are using SMS MFA.
</p>
<TaxDisclaimer className="mt-2" />
</ConfirmationModal>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Enhanced MFA Security</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form {...securityForm}>
<form onSubmit={securityForm.handleSubmit(onSubmitSecurityForm)}>
<Card>
<CardContent>
<FormField
control={securityForm.control}
name="MFA_ALLOW_LOW_AAL"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Limit duration of AAL1 sessions"
description="A user's session will be terminated unless they verify one of their factors within 15 minutes of initial sign in. Recommendation: ON"
>
<FormControl>
<Switch
checked={!field.value}
onCheckedChange={(value) => field.onChange(!value)}
disabled={!canUpdateConfig || !hasAccessToEnhanceSecurity}
/>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{promptEnhancedSecurityUpgrade && (
<UpgradeToPro
fullWidth
source="authEnhancedSecurity"
featureProposition="configure settings for Enhanced MFA Security"
primaryText="Enhanced MFA Security is not available on your plan"
secondaryText="Upgrade your plan to configure settings for Enhanced MFA Security"
buttonText="Upgrade"
/>
)}
<CardFooter className="justify-end space-x-2">
{securityForm.formState.isDirty && (
<Button type="default" onClick={() => securityForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig || isUpdatingSecurityForm || !securityForm.formState.isDirty
}
loading={isUpdatingSecurityForm}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form>
</PageSectionContent>
</PageSection>
</>
)
}