Files
supabase/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx
Joshen Lim fcfb0f0222 Refactor all usage of form.watch to either useWatch or subscribe (#48436)
## Context

Replaces all usage of `form.watch()` to use `useWatch` instead + follows
the "name what you watch" convention as specified in the react-hook-form
skills.

There's also a small refactor in `SmtpForm.tsx` which removes the
unnecessary use of a `useState` to track if SMTP is enabled or not

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

* **Improvements**
* Updated many Studio forms to watch specific fields more precisely,
improving live UI updates for previews, warnings, conditional sections,
and validation messages.
* Enhanced responsiveness across settings, authentication, billing,
storage, integrations, and support flows while keeping save/update
behavior the same.
* **Refined Experiences**
* Improved the analytics table creation flow with tighter, enum-based
column type validation and structured, type-specific column options.
* **Preserved Behavior**
* Maintained existing permission checks, submission flows, and
account-management workflows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 11:45:40 +08: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, useWatch } 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 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 { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
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 mfaPhoneValue = useWatch({ control: phoneForm.control, name: 'MFA_PHONE' })
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 = mfaPhoneValue === 'Enabled' || mfaPhoneValue === '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
value={field.value}
onValueChange={field.onChange}
disabled={!canUpdateConfig}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{MFAFactorSelectionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</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 variant="default" onClick={() => totpForm.reset()}>
Cancel
</Button>
)}
<Button
variant="primary"
type="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
value={field.value}
onValueChange={field.onChange}
disabled={!canUpdateConfig || !hasAccessToMFA}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{MFAFactorSelectionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
{!hasValidMFAProvider && phoneMFAIsEnabled && (
<Alert variant="warning" className="mt-3">
<WarningIcon />
<AlertTitle>
To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
</AlertTitle>
</Alert>
)}
</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
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 variant="default" onClick={() => phoneForm.reset()}>
Cancel
</Button>
)}
<Button
variant={promptProPlanUpgrade ? 'default' : 'primary'}
type="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 variant="default" onClick={() => securityForm.reset()}>
Cancel
</Button>
)}
<Button
variant="primary"
type="submit"
disabled={
!canUpdateConfig || isUpdatingSecurityForm || !securityForm.formState.isDirty
}
loading={isUpdatingSecurityForm}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form>
</PageSectionContent>
</PageSection>
</>
)
}