diff --git a/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx
index d2233eb9054..df39466a5de 100644
--- a/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx
+++ b/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx
@@ -1,13 +1,12 @@
+import { yupResolver } from '@hookform/resolvers/yup'
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { useEffect } from 'react'
+import { useEffect, useState } from 'react'
+import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { number, object, string } from 'yup'
import { useParams } from 'common'
-import { FormActions } from 'components/ui/Forms/FormActions'
-import { FormHeader } from 'components/ui/Forms/FormHeader'
-import { FormPanel } from 'components/ui/Forms/FormPanel'
-import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
+import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
@@ -16,28 +15,27 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
-import FormField from '../AuthProvidersForm/FormField'
-
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
- Form,
- InputNumber,
+ Button,
+ Card,
+ CardContent,
+ CardFooter,
+ FormControl_Shadcn_,
+ FormField_Shadcn_,
+ Form_Shadcn_,
+ Input_Shadcn_,
+ Select_Shadcn_,
+ SelectContent_Shadcn_,
+ SelectItem_Shadcn_,
+ SelectTrigger_Shadcn_,
+ SelectValue_Shadcn_,
WarningIcon,
+ PrePostTab,
} from 'ui'
-
-const schema = object({
- 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.'),
- MFA_MAX_ENROLLED_FACTORS: number()
- .min(0, 'Must be a value 0 or larger')
- .max(30, 'Must be a value no greater than 30'),
- MFA_TOTP: string().required(),
- MFA_PHONE: string().required(),
-})
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
function determineMFAStatus(verifyEnabled: boolean, enrollEnabled: boolean) {
return verifyEnabled ? (enrollEnabled ? 'Enabled' : 'Verify Enabled') : 'Disabled'
@@ -66,6 +64,21 @@ const MfaStatusToState = (status: (typeof MFAFactorSelectionOptions)[number]['va
: { 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 MfaAuthSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
@@ -73,11 +86,13 @@ const MfaAuthSettingsForm = () => {
error: authConfigError,
isLoading,
isError,
- isSuccess,
} = useAuthConfigQuery({ projectRef })
- const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
+ const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
+
+ // Separate loading states for each form
+ const [isUpdatingTotpForm, setIsUpdatingTotpForm] = useState(false)
+ const [isUpdatingPhoneForm, setIsUpdatingPhoneForm] = useState(false)
- const formId = 'auth-config-advanced-form'
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
@@ -94,34 +109,87 @@ const MfaAuthSettingsForm = () => {
(addon) => addon.type === 'auth_mfa_phone'
)
- const INITIAL_VALUES = {
- MFA_PHONE_OTP_LENGTH: authConfig?.MFA_PHONE_OTP_LENGTH || 6,
- MFA_PHONE_TEMPLATE: authConfig?.MFA_PHONE_TEMPLATE || 'Your code is {{ .Code }}',
- MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS ?? 10,
- MFA_TOTP:
- determineMFAStatus(
- authConfig?.MFA_TOTP_VERIFY_ENABLED ?? true,
- authConfig?.MFA_TOTP_ENROLL_ENABLED ?? true
- ) || 'Enabled',
- MFA_PHONE:
- determineMFAStatus(
- authConfig?.MFA_PHONE_VERIFY_ENABLED || false,
- authConfig?.MFA_PHONE_ENROLL_ENABLED || false
- ) || 'Disabled',
- }
-
// 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 onSubmit = (values: any, { resetForm }: any) => {
- let payload = { ...values }
+ 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 }}',
+ },
+ })
+
+ 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 }}',
+ })
+ }
+ }
+ }, [authConfig, isUpdatingTotpForm, isUpdatingPhoneForm])
+
+ const onSubmitTotpForm = (values: any) => {
const { verifyEnabled: MFA_TOTP_VERIFY_ENABLED, enrollEnabled: MFA_TOTP_ENROLL_ENABLED } =
MfaStatusToState(values.MFA_TOTP)
- // MFA (Phone) is only available on Pro Plans and up. We translate the UI state, MFA_PHONE and MFA_TOTP into the underlying
- // Auth config state - MFA_PHONE_*_ENABLED and MFA_TOTP_*_ENABLED.
+
+ 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 onSubmitPhoneForm = (values: any) => {
+ let payload = { ...values }
+
if (isProPlanAndUp) {
const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } =
MfaStatusToState(values.MFA_PHONE)
@@ -131,23 +199,20 @@ const MfaAuthSettingsForm = () => {
MFA_PHONE_VERIFY_ENABLED,
}
}
- payload = {
- ...payload,
- MFA_TOTP_ENROLL_ENABLED,
- MFA_TOTP_VERIFY_ENABLED,
- }
- delete payload.MFA_TOTP
delete payload.MFA_PHONE
+ setIsUpdatingPhoneForm(true)
+
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
- toast.error(`Failed to update settings: ${error?.message}`)
+ toast.error(`Failed to update phone MFA settings: ${error?.message}`)
+ setIsUpdatingPhoneForm(false)
},
onSuccess: () => {
- toast.success('Successfully updated settings')
- resetForm({ values: values, initialValues: values })
+ toast.success('Successfully updated phone MFA settings')
+ setIsUpdatingPhoneForm(false)
},
}
)
@@ -167,134 +232,240 @@ const MfaAuthSettingsForm = () => {
return
}
+ const phoneMFAIsEnabled =
+ phoneForm.watch('MFA_PHONE') === 'Enabled' || phoneForm.watch('MFA_PHONE') === 'Verify Enabled'
+ const hasUpgradedPhoneMFA = authConfig?.MFA_PHONE_VERIFY_ENABLED === false && phoneMFAIsEnabled
+
return (
-
+ />
+
+ {!hasValidMFAProvider && phoneMFAIsEnabled && (
+
+
+
+ To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
+
+
+ )}
+
+
+
+ (
+
+
+
+
+
+ )}
+ />
+
+
+
+ (
+
+
+
+
+
+ )}
+ />
+
+
+ {hasUpgradedPhoneMFA && (
+
+
+
+
+ Enabling advanced MFA with phone 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.
+
+
+
+ )}
+
+
+ {phoneForm.formState.isDirty && (
+
+ )}
+
+
+
+
+
+
+ >
)
}
diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx
index 8202b5e94cb..32094c4986b 100644
--- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx
+++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx
@@ -2,8 +2,12 @@ import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
+import {
+ ScaffoldDescription,
+ ScaffoldSection,
+ ScaffoldSectionTitle,
+} from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
-import { FormHeader } from 'components/ui/Forms/FormHeader'
import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers/Shimmers'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
@@ -69,15 +73,18 @@ export const RedirectUrls = () => {
}
return (
-
+
-
+
+ Redirect URLs
+
+ URLs that auth providers are permitted to redirect to post authentication. Wildcards are
+ allowed, for example, https://*.domain.com
+
+