mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Updating authentication mfa and url pages (#33956)
* init PrePostTab component * init * support auto fixing rounded of inputs/select/textarea * remove form item layout prepost * Update FormLayout.tsx * Update FormLayout.tsx * Update FormLayout.tsx * form layout and advanced * Update advanced.tsx * protection and hooks * mfa and url * remove onchange --------- Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
This commit is contained in:
1 parent
470db8a0f4
commit
5bd430ca38
5 files changed
+526
-312
No files matched your search
+341
-170
@@ -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 <NoPermission resourceText="view auth configuration settings" />
|
||||
}
|
||||
|
||||
const phoneMFAIsEnabled =
|
||||
phoneForm.watch('MFA_PHONE') === 'Enabled' || phoneForm.watch('MFA_PHONE') === 'Verify Enabled'
|
||||
const hasUpgradedPhoneMFA = authConfig?.MFA_PHONE_VERIFY_ENABLED === false && phoneMFAIsEnabled
|
||||
|
||||
return (
|
||||
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
|
||||
{({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">
|
||||
Multi-Factor Authentication (MFA)
|
||||
</ScaffoldSectionTitle>
|
||||
|
||||
// Form is reset once remote data is loaded in store
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
|
||||
}, [isSuccess])
|
||||
<Form_Shadcn_ {...totpForm}>
|
||||
<form onSubmit={totpForm.handleSubmit(onSubmitTotpForm)} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<FormField_Shadcn_
|
||||
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_Shadcn_>
|
||||
<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_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
const hasUpgradedPhoneMFA =
|
||||
INITIAL_VALUES.MFA_PHONE === 'Disabled' && values.MFA_PHONE !== INITIAL_VALUES.MFA_PHONE
|
||||
const phoneMFAIsEnabled =
|
||||
values.MFA_PHONE === 'Enabled' || values.MFA_PHONE === 'Verify Enabled'
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
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_Shadcn_>
|
||||
<PrePostTab postTab="factors">
|
||||
<Input_Shadcn_
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</PrePostTab>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isUpdatingConfig}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
disabled={!canUpdateConfig}
|
||||
helper={
|
||||
!canUpdateConfig
|
||||
? 'You need additional permissions to update authentication settings'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection
|
||||
header={<FormSectionLabel>Multi-Factor Authentication (MFA)</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<FormField
|
||||
name="MFA_TOTP"
|
||||
properties={{
|
||||
type: 'select',
|
||||
title: 'TOTP (App Authenticator)',
|
||||
description: 'Control use of TOTP (App Authenticator) factors',
|
||||
enum: MFAFactorSelectionOptions,
|
||||
}}
|
||||
formValues={values}
|
||||
disabled={!canUpdateConfig}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
<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_Shadcn_>
|
||||
</ScaffoldSection>
|
||||
|
||||
<InputNumber
|
||||
id="MFA_MAX_ENROLLED_FACTORS"
|
||||
size="small"
|
||||
label="Maximum number of per-user MFA factors"
|
||||
descriptionText="How many MFA factors can be enrolled at once per user."
|
||||
actions={<span className="mr-3 text-foreground-lighter">factors</span>}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<FormSection header={<FormSectionLabel>SMS MFA</FormSectionLabel>}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
{promptProPlanUpgrade && (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">SMS MFA</ScaffoldSectionTitle>
|
||||
|
||||
<Form_Shadcn_ {...phoneForm}>
|
||||
<form onSubmit={phoneForm.handleSubmit(onSubmitPhoneForm)} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{promptProPlanUpgrade && (
|
||||
<div className="mb-4">
|
||||
<UpgradeToPro
|
||||
primaryText="Upgrade to Pro"
|
||||
secondaryText="Advanced MFA requires the Pro Plan"
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
name="MFA_PHONE"
|
||||
properties={{
|
||||
type: 'select',
|
||||
title: 'Phone',
|
||||
description: 'Control use of phone factors',
|
||||
enum: MFAFactorSelectionOptions,
|
||||
}}
|
||||
formValues={values}
|
||||
disabled={!canUpdateConfig || !isProPlanAndUp}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
{!hasValidMFAProvider && phoneMFAIsEnabled && (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
|
||||
</AlertTitle_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputNumber
|
||||
id="MFA_PHONE_OTP_LENGTH"
|
||||
size="small"
|
||||
label="Phone OTP Length"
|
||||
descriptionText="Number of digits in OTP"
|
||||
disabled={!canUpdateConfig || !isProPlanAndUp}
|
||||
/>
|
||||
<FormField
|
||||
name="MFA_PHONE_TEMPLATE"
|
||||
properties={{
|
||||
title: 'Phone verification message',
|
||||
type: 'multiline-string',
|
||||
description: 'To format the OTP code use `{{ .Code }}`',
|
||||
}}
|
||||
formValues={values}
|
||||
disabled={!canUpdateConfig || !isProPlanAndUp}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
{hasUpgradedPhoneMFA && (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
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.
|
||||
</AlertTitle_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
<FormField_Shadcn_
|
||||
control={phoneForm.control}
|
||||
name="MFA_PHONE"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Phone"
|
||||
description="Control use of phone factors"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={!canUpdateConfig || !isProPlanAndUp}
|
||||
>
|
||||
<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_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
/>
|
||||
|
||||
{!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_Shadcn_
|
||||
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_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
type="number"
|
||||
min={6}
|
||||
max={30}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !isProPlanAndUp}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
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_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
type="text"
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !isProPlanAndUp}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
{hasUpgradedPhoneMFA && (
|
||||
<CardContent>
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
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.
|
||||
</AlertTitle_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{phoneForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => phoneForm.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={
|
||||
!canUpdateConfig ||
|
||||
isUpdatingPhoneForm ||
|
||||
!phoneForm.formState.isDirty ||
|
||||
!isProPlanAndUp
|
||||
}
|
||||
loading={isUpdatingPhoneForm}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</ScaffoldSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<FormHeader
|
||||
className="mb-0"
|
||||
title="Redirect URLs"
|
||||
description="URLs that auth providers are permitted to redirect to post authentication. Wildcards are allowed, for example, https://*.domain.com"
|
||||
/>
|
||||
<div>
|
||||
<ScaffoldSectionTitle>Redirect URLs</ScaffoldSectionTitle>
|
||||
<ScaffoldDescription>
|
||||
URLs that auth providers are permitted to redirect to post authentication. Wildcards are
|
||||
allowed, for example, https://*.domain.com
|
||||
</ScaffoldDescription>
|
||||
</div>
|
||||
<DocsButton href="https://supabase.com/docs/guides/auth/concepts/redirect-urls" />
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
<ValueContainer>
|
||||
@@ -88,6 +95,7 @@ export const RedirectUrls = () => {
|
||||
</ValueContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<WarningIcon />
|
||||
@@ -95,6 +103,7 @@ export const RedirectUrls = () => {
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<RedirectUrlList
|
||||
allowList={URI_ALLOW_LIST_ARRAY}
|
||||
@@ -166,6 +175,6 @@ export const RedirectUrls = () => {
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
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 { object, string } from 'yup'
|
||||
import { yupResolver } from '@hookform/resolvers/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 } from 'components/ui/Forms/FormSection'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Form, Input } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
} from 'ui'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
const schema = object({
|
||||
@@ -20,31 +32,40 @@ const schema = object({
|
||||
|
||||
const SiteUrl = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
data: authConfig,
|
||||
error: authConfigError,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
const { data: authConfig, error: authConfigError, isError } = useAuthConfigQuery({ projectRef })
|
||||
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
|
||||
const [isUpdatingSiteUrl, setIsUpdatingSiteUrl] = useState(false)
|
||||
|
||||
const formId = 'auth-config-general-form'
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
const INITIAL_VALUES = { SITE_URL: authConfig?.SITE_URL }
|
||||
const siteUrlForm = useForm({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
SITE_URL: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (authConfig && !isUpdatingSiteUrl) {
|
||||
siteUrlForm.reset({
|
||||
SITE_URL: authConfig.SITE_URL || '',
|
||||
})
|
||||
}
|
||||
}, [authConfig, isUpdatingSiteUrl])
|
||||
|
||||
const onSubmitSiteUrl = (values: any) => {
|
||||
setIsUpdatingSiteUrl(true)
|
||||
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = { ...values }
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: payload },
|
||||
{ projectRef: projectRef!, config: values },
|
||||
{
|
||||
onError: () => {
|
||||
toast.error('Failed to update settings')
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update site URL: ${error?.message}`)
|
||||
setIsUpdatingSiteUrl(false)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully updated settings')
|
||||
resetForm({ values: values, initialValues: values })
|
||||
toast.success('Successfully updated site URL')
|
||||
setIsUpdatingSiteUrl(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -61,53 +82,49 @@ const SiteUrl = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
|
||||
{({ handleReset, resetForm, values, initialValues }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Site URL</ScaffoldSectionTitle>
|
||||
|
||||
// Form is reset once remote data is loaded in store
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
|
||||
}
|
||||
}, [isSuccess])
|
||||
<Form_Shadcn_ {...siteUrlForm}>
|
||||
<form onSubmit={siteUrlForm.handleSubmit(onSubmitSiteUrl)} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<FormField_Shadcn_
|
||||
control={siteUrlForm.control}
|
||||
name="SITE_URL"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Site URL"
|
||||
description="Configure the default redirect URL used when a redirect URL is not specified or doesn't match one from the allow list. This value is also exposed as a template variable in the email templates section. Wildcards cannot be used here."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} disabled={!canUpdateConfig} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormHeader
|
||||
title="Site URL"
|
||||
description="Configure the default redirect URL used when a redirect URL is not specified or doesn't match one from the allow list. This value is also exposed as a template variable in the email templates section. Wildcards cannot be used here."
|
||||
/>
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isUpdatingConfig}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
disabled={!canUpdateConfig}
|
||||
helper={
|
||||
!canUpdateConfig
|
||||
? 'You need additional permissions to update authentication settings'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Input id="SITE_URL" size="small" label="Site URL" disabled={!canUpdateConfig} />
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{siteUrlForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => siteUrlForm.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canUpdateConfig || isUpdatingSiteUrl || !siteUrlForm.formState.isDirty}
|
||||
loading={isUpdatingSiteUrl}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +1,50 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { MfaAuthSettingsForm } from 'components/interfaces/Auth'
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import {
|
||||
ScaffoldContainer,
|
||||
ScaffoldDescription,
|
||||
ScaffoldHeader,
|
||||
ScaffoldTitle,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
// TODO: check if these permissions cover third party auth as well
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldHeader>
|
||||
<ScaffoldTitle>Multi-Factor Authentication (MFA)</ScaffoldTitle>
|
||||
<ScaffoldDescription>
|
||||
Requires users to provide additional verification factors to authenticate
|
||||
</ScaffoldDescription>
|
||||
</ScaffoldHeader>
|
||||
</ScaffoldContainer>
|
||||
<ScaffoldContainer className="flex flex-col gap-10" bottomPadding>
|
||||
{!isPermissionsLoaded ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : !canReadAuthSettings ? (
|
||||
<NoPermission isFullPage resourceText="access your project's authentication settings" />
|
||||
) : (
|
||||
<>
|
||||
<MfaAuthSettingsForm />
|
||||
</>
|
||||
)}
|
||||
</ScaffoldContainer>
|
||||
</>
|
||||
)
|
||||
interface MfaLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => {
|
||||
export const MfaLayout = ({ children }: MfaLayoutProps) => {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout>
|
||||
<PageLayout
|
||||
title="Multi-Factor Authentication (MFA)"
|
||||
subtitle="Requires users to provide additional verification factors to authenticate"
|
||||
>
|
||||
{children}
|
||||
</PageLayout>
|
||||
</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
export default PageLayout
|
||||
|
||||
const MfaPage: NextPageWithLayout = () => {
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
|
||||
if (isPermissionsLoaded && !canReadAuthSettings) {
|
||||
return <NoPermission isFullPage resourceText="access your project's authentication settings" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
{!isPermissionsLoaded ? <GenericSkeletonLoader /> : <MfaAuthSettingsForm />}
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
MfaPage.getLayout = (page) => <MfaLayout>{page}</MfaLayout>
|
||||
|
||||
export default MfaPage
|
||||
@@ -1,42 +1,63 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { RedirectUrls } from 'components/interfaces/Auth/RedirectUrls/RedirectUrls'
|
||||
import SiteUrl from 'components/interfaces/Auth/SiteUrl/SiteUrl'
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold'
|
||||
import { FormsContainer } from 'components/ui/Forms/FormsContainer'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
const URLConfiguration: NextPageWithLayout = () => {
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
|
||||
if (isPermissionsLoaded && !canReadAuthSettings) {
|
||||
return <NoPermission isFullPage resourceText="access your project's email settings" />
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<ScaffoldHeader className="pb-0">
|
||||
<ScaffoldContainer id="auth-page-top">
|
||||
<ScaffoldTitle>URL Configuration</ScaffoldTitle>
|
||||
</ScaffoldContainer>
|
||||
</ScaffoldHeader>
|
||||
|
||||
<ScaffoldContainer className="my-8 space-y-8">
|
||||
<SiteUrl />
|
||||
<RedirectUrls />
|
||||
</ScaffoldContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
interface URLConfigurationLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
URLConfiguration.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
export const URLConfigurationLayout = ({ children }: URLConfigurationLayoutProps) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>
|
||||
<PageLayout
|
||||
title="URL Configuration"
|
||||
subtitle="Configure site URL and redirect URLs for authentication"
|
||||
>
|
||||
{children}
|
||||
</PageLayout>
|
||||
</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const URLConfiguration: NextPageWithLayout = () => {
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
|
||||
if (isPermissionsLoaded && !canReadAuthSettings) {
|
||||
return <NoPermission isFullPage resourceText="access your project's authentication settings" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
{!isPermissionsLoaded ? (
|
||||
<div className="mt-12">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SiteUrl />
|
||||
<RedirectUrls />
|
||||
</>
|
||||
)}
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
URLConfiguration.getLayout = (page) => <URLConfigurationLayout>{page}</URLConfigurationLayout>
|
||||
|
||||
export default URLConfiguration
|
||||
Reference in new issue
Block a user