diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.tsx index b4f42017d00..9d2c89bdea9 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.tsx @@ -1,24 +1,73 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { ExternalLink } from 'lucide-react' import Link from 'next/link' +import { useState, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import { toast } from 'sonner' import { useParams } from 'common' 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' +import { BASE_PATH } from 'lib/constants' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, WarningIcon, + Sheet, + SheetContent, + Badge, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + Form_Shadcn_, } from 'ui' +import { ResourceList } from 'components/ui/Resource/ResourceList' +import { ResourceItem } from 'components/ui/Resource/ResourceItem' import { getPhoneProviderValidationSchema, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation' import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants' import ProviderForm from './ProviderForm' import { SectionHeader } from 'components/layouts/PageLayout' +import { DocsButton } from 'components/ui/DocsButton' +import type { Provider } from './AuthProvidersForm.types' + +const generateInitialValues = (provider: Provider, config: any) => { + const doubleNegativeKeys = ['MAILER_AUTOCONFIRM', 'SMS_AUTOCONFIRM'] + const initialValues: { [x: string]: string | boolean } = {} + + Object.keys(provider.properties).forEach((key) => { + const isDoubleNegative = doubleNegativeKeys.includes(key) + + if (provider.title === 'SAML 2.0') { + const configValue = (config as any)[key] + initialValues[key] = configValue || (provider.properties[key].type === 'boolean' ? false : '') + } else { + if (isDoubleNegative) { + initialValues[key] = !(config as any)[key] + } else { + const configValue = (config as any)[key] + initialValues[key] = configValue + ? configValue + : provider.properties[key].type === 'boolean' + ? false + : '' + } + } + }) + + return initialValues +} const AuthProvidersForm = () => { const { ref: projectRef } = useParams() + const [selectedProvider, setSelectedProvider] = useState(null) + const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() + const { isLoading, error: authConfigError, @@ -27,6 +76,49 @@ const AuthProvidersForm = () => { isSuccess, } = useAuthConfigQuery({ projectRef }) + const activeProvider = selectedProvider + ? PROVIDERS_SCHEMAS.find((provider) => provider.title === selectedProvider) + : null + + const activeProviderSchema = + activeProvider?.title === 'Phone' && authConfig + ? { ...activeProvider, validationSchema: getPhoneProviderValidationSchema(authConfig) } + : activeProvider + + const form = useForm({ + defaultValues: activeProviderSchema + ? generateInitialValues(activeProviderSchema, authConfig) + : {}, + }) + + useEffect(() => { + if (activeProviderSchema) { + form.reset(generateInitialValues(activeProviderSchema, authConfig)) + } + }, [activeProviderSchema]) + + const onSubmit = (values: any) => { + const doubleNegativeKeys = ['MAILER_AUTOCONFIRM', 'SMS_AUTOCONFIRM'] + const payload = { ...values } + + Object.keys(values).map((x: string) => { + if (doubleNegativeKeys.includes(x)) payload[x] = !values[x] + if (payload[x] === '') payload[x] = null + }) + + updateAuthConfig( + { projectRef: projectRef!, config: payload }, + { + onSuccess: () => { + setSelectedProvider(null) + toast.success('Successfully updated settings') + }, + } + ) + } + + if (!authConfig) return null + return (
{ subtitle="Authenticate your users through a suite of providers and login methods" /> -
+
{authConfig?.EXTERNAL_EMAIL_ENABLED && authConfig?.MAILER_OTP_EXP > 3600 && ( {
)} - {isLoading && - PROVIDERS_SCHEMAS.map((provider) => { - return ( + {isLoading && ( +
+ {PROVIDERS_SCHEMAS.map((provider) => (
- ) - })} + ))} +
+ )} {isError && ( @@ -75,21 +168,110 @@ const AuthProvidersForm = () => { {authConfigError.message} )} - {isSuccess && - PROVIDERS_SCHEMAS.map((provider) => { - const providerSchema = - provider.title === 'Phone' - ? { ...provider, validationSchema: getPhoneProviderValidationSchema(authConfig) } - : provider - return ( - - ) - })} + {isSuccess && ( + + {PROVIDERS_SCHEMAS.map((provider) => { + const isSAMLEnabled = provider.title === 'SAML 2.0' && authConfig?.SAML_ENABLED + const isLinkedInOIDCEnabled = + provider.title === 'LinkedIn (OIDC)' && authConfig?.EXTERNAL_LINKEDIN_OIDC_ENABLED + const isSlackOIDCEnabled = + provider.title === 'Slack (OIDC)' && authConfig?.EXTERNAL_SLACK_OIDC_ENABLED + const isExternalProviderAndEnabled = + authConfig && + (authConfig as any)[`EXTERNAL_${provider?.title?.toUpperCase()}_ENABLED`] + + const isActive = + isSAMLEnabled || + isExternalProviderAndEnabled || + isLinkedInOIDCEnabled || + isSlackOIDCEnabled + + return ( + + } + meta={ + + {isActive ? 'Enabled' : 'Disabled'} + + } + onClick={() => setSelectedProvider(provider.title)} + > +
+
{provider.title}
+ {'helper' in provider.misc && ( +

{provider.misc.helper}

+ )} +
+
+ ) + })} +
+ )}
+ + !open && setSelectedProvider(null)} + > + + {activeProviderSchema && ( + +
+ + + {`${activeProviderSchema.title} + {activeProviderSchema.title} Configuration + + +
+ setSelectedProvider(null)} + form={form} + /> +
+ +
+ +
+ + +
+
+
+
+
+ )} +
+
) } diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx index 11fdbf2264f..54a2b611384 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx @@ -1,19 +1,32 @@ import dayjs from 'dayjs' -import { Eye, EyeOff } from 'lucide-react' -import { useEffect, useState } from 'react' -import ReactMarkdown from 'react-markdown' - -import { Markdown } from 'components/interfaces/Markdown' -import { DatePicker } from 'components/ui/DatePicker' +import { Eye, EyeOff, CalendarIcon } from 'lucide-react' +import { useState } from 'react' import { BASE_PATH } from 'lib/constants' -import { Button, Input, InputNumber, Listbox, Toggle } from 'ui' +import { format } from 'date-fns' +import { cn } from 'ui/src/lib/utils/cn' +import ReactMarkdown from 'react-markdown' +import { + Button, + Input_Shadcn_, + Switch, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Popover_Shadcn_, + PopoverTrigger_Shadcn_, + PopoverContent_Shadcn_, + Calendar, + FormControl_Shadcn_, + TextArea_Shadcn_, +} from 'ui' import type { Enum } from './AuthProvidersForm.types' interface FormFieldProps { + field: any name: string properties: any - formValues: any - setFieldValue: (field: string, v: any) => any disabled?: boolean } @@ -21,268 +34,151 @@ function formatDate(date: Date): string { return dayjs(date).format('dddd, MMMM D, YYYY HH:mm:ss Z') } -const FormField = ({ - name, - properties, - formValues, - disabled = false, - setFieldValue, -}: FormFieldProps) => { +const FormField = ({ field, name, properties, disabled = false }: FormFieldProps) => { const [hidden, setHidden] = useState(!!properties.isSecret) - const [dateAsText, setDateAsText] = useState( - formValues[name] ? formatDate(new Date(formValues[name])) : '' - ) - - useEffect(() => { - if (properties.show && properties.show.key && !formValues[properties.show.key]) { - setFieldValue(name, '') - setDateAsText('') - } - }, [properties.show && properties.show.key && !formValues[properties.show.key]]) if (properties.show) { if (properties.show.matches) { - if (!properties.show.matches.includes(formValues[properties.show.key])) { + if (!properties.show.matches.includes(properties.show.key)) { return null } - } else if (!formValues[properties.show.key]) { + } else if (!properties.show.key) { return null } } + const renderMarkdown = (content?: string) => { + if (!content) return null + return ( + + {content} + + ) + } + switch (properties.type) { case 'datetime': return ( - - {properties.descriptionOptional} - - ) : null - } - descriptionText={ - properties.description ? ( - - {properties.description} - - ) : null - } - actions={ - { - if (date && date.to) { - setFieldValue(name, date.to) - setDateAsText(formatDate(new Date(date.to))) - } else { - setDateAsText('') - setFieldValue(name, '') + + + + + + + + { + if (date) { + // Set time to 24 hours from now + date.setHours(date.getHours() + 24) + field.onChange(date.toISOString()) } }} - > - Pick - - } - /> + disabled={(date: Date) => date < new Date()} + initialFocus + /> + + ) case 'string': return ( - - ) : null - } - descriptionText={ - properties.description ? ( - - ) : null - } - actions={ - !!properties.isSecret ? ( - - -
- {({ handleReset, initialValues, values, setFieldValue }: any) => { - const noChanges = JSON.stringify(initialValues) === JSON.stringify(values) + + + {Object.keys(provider.properties).map((key) => { + const property = provider.properties[key] + if (!property) return null + + // Check if field should be shown based on dependencies + if (property.show) { + const watchedValue = watchedValues[Object.keys(provider.properties).indexOf(key)] + if (property.show.matches) { + if (!property.show.matches.includes(watchedValue)) { + return null + } + } else if (!watchedValue) { + return null + } + } + return ( - -
-
- {showAlert(provider.title)} - {Object.keys(provider.properties).map((x: string) => ( - - ))} - - {provider?.misc?.alert && ( - - {provider.misc.alert.description} - - } - /> - )} - - {provider.misc.requiresRedirect && ( - <> - - } - /> - - )} -
- -
- - - Save - -
-
-
-
-
+ + + {(field) => ( + + )} + + ) - }} -
- + })} + + {provider?.misc?.alert && ( + + + {provider.misc.alert.description} + + } + /> + + )} + + {provider.misc.requiresRedirect && ( + + + } + orientation="horizontal" + > + {() => ( +
+ + +
+ )} +
+
+ )} + + ) } diff --git a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx index 8bc97d8b640..81a7a2c810d 100644 --- a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx @@ -4,12 +4,12 @@ import Link from 'next/link' import { useEffect } from 'react' import { toast } from 'sonner' import { boolean, object, string } from 'yup' +import { useForm } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' -import { FormActions } from 'components/ui/Forms/FormActions' -import { FormPanel } from 'components/ui/Forms/FormPanel' -import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' +import { FormFieldWrapper } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' @@ -19,11 +19,15 @@ import { AlertTitle_Shadcn_, Alert_Shadcn_, Button, - Form, - Toggle, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, + Form_Shadcn_, + Switch, WarningIcon, } from 'ui' -import { FormHeader } from 'components/ui/Forms/FormHeader' import { SectionHeader } from 'components/layouts/PageLayout' // Use a const string to represent no chars option. Represented as empty string on the backend side. @@ -36,8 +40,6 @@ const schema = object({ SITE_URL: string().required('Must have a Site URL'), }) -const formId = 'auth-config-basic-settings' - const BasicAuthSettingsForm = () => { const { ref: projectRef } = useParams() const { @@ -52,14 +54,28 @@ const BasicAuthSettingsForm = () => { const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') - const INITIAL_VALUES = { - DISABLE_SIGNUP: !authConfig?.DISABLE_SIGNUP, - EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig?.EXTERNAL_ANONYMOUS_USERS_ENABLED, - SECURITY_MANUAL_LINKING_ENABLED: authConfig?.SECURITY_MANUAL_LINKING_ENABLED || false, - SITE_URL: authConfig?.SITE_URL, - } + const form = useForm({ + resolver: yupResolver(schema), + defaultValues: { + DISABLE_SIGNUP: true, + EXTERNAL_ANONYMOUS_USERS_ENABLED: false, + SECURITY_MANUAL_LINKING_ENABLED: false, + SITE_URL: '', + }, + }) - const onSubmit = (values: any, { resetForm }: any) => { + useEffect(() => { + if (authConfig) { + form.reset({ + DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP, + EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED, + SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false, + SITE_URL: authConfig.SITE_URL, + }) + } + }, [authConfig]) + + const onSubmit = (values: any) => { const payload = { ...values } payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP // The backend uses empty string to represent no required characters in the password @@ -71,11 +87,10 @@ const BasicAuthSettingsForm = () => { { projectRef: projectRef!, config: payload }, { onError: (error) => { - toast.error(`Failed to update settings: ${error?.message}`) + toast.error(`Failed to update settings: ${error?.message}`) }, onSuccess: () => { - toast.success(`Successfully updated settings`) - resetForm({ values: values, initialValues: values }) + toast.success('Successfully updated settings') }, } ) @@ -97,153 +112,146 @@ const BasicAuthSettingsForm = () => { return (
- + -
- {({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => { - const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) - // 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]) - - return ( - <> - - -
- } + + + + + - User Signups}> - - - - } - disabled={!canUpdateConfig} - /> - - } - disabled={!canUpdateConfig} - /> - {values.EXTERNAL_ANONYMOUS_USERS_ENABLED && ( -
- ( + + )} + + + + + } + orientation="horizontal" + > + {(field) => ( + + )} + + + + + } + orientation="horizontal" + > + {(field) => ( + + )} + + + {form.watch('EXTERNAL_ANONYMOUS_USERS_ENABLED') && ( + + +
+ + Anonymous users will use the authenticated{' '} + role when signing in + + +

+ As a result, anonymous users will be subjected to RLS policies that apply to + the public and{' '} + authenticated roles. We strongly advise{' '} + - -

- - Anonymous users will use the{' '} - authenticated role when signing in - - -

- As a result, anonymous users will be subjected to RLS policies that - apply to the public and{' '} - authenticated roles. We strongly - advise{' '} - - reviewing your RLS policies - {' '} - to ensure that access to your data is restricted where required. -

- -
-
- -
- )} - {!authConfig?.SECURITY_CAPTCHA_ENABLED && - values.EXTERNAL_ANONYMOUS_USERS_ENABLED && ( - - - - We highly recommend{' '} - { - const el = document.getElementById('enable-captcha') - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) - }} - > - enabling captcha - {' '} - for anonymous sign-ins - - - This will prevent potential abuse on sign-ins which may bloat your - database and incur costs for monthly active users (MAU) - - - )} - - - - - ) - }} - + reviewing your RLS policies + {' '} + to ensure that access to your data is restricted where required. +

+ + +
+ + )} + + {!authConfig?.SECURITY_CAPTCHA_ENABLED && + form.watch('EXTERNAL_ANONYMOUS_USERS_ENABLED') && ( + + + + We highly recommend{' '} + { + const el = document.getElementById('enable-captcha') + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }} + > + enabling captcha + {' '} + for anonymous sign-ins + + + This will prevent potential abuse on sign-ins which may bloat your database + and incur costs for monthly active users (MAU) + + + )} +
+ + + +
+ +
) } diff --git a/apps/studio/components/ui/Forms/FormFieldWrapper.tsx b/apps/studio/components/ui/Forms/FormFieldWrapper.tsx new file mode 100644 index 00000000000..4e2bac51f0d --- /dev/null +++ b/apps/studio/components/ui/Forms/FormFieldWrapper.tsx @@ -0,0 +1,53 @@ +import { + FormField_Shadcn_, + FormItem_Shadcn_, + FormLabel_Shadcn_, + FormDescription_Shadcn_, + FormControl_Shadcn_, + FormMessage_Shadcn_, +} from 'ui' +import { Control } from 'react-hook-form' +import { ReactNode } from 'react' + +interface FormFieldWrapperProps { + control: Control + name: string + label: string + description?: string | ReactNode + orientation?: 'horizontal' | 'vertical' + children: (field: any) => ReactNode +} + +export function FormFieldWrapper({ + control, + name, + label, + description, + orientation = 'horizontal', + children, +}: FormFieldWrapperProps) { + return ( + ( + +
+ {label} + {description && ( + {description} + )} +
+
+ {children(field)} + +
+
+ )} + /> + ) +} diff --git a/apps/studio/components/ui/Forms/index.ts b/apps/studio/components/ui/Forms/index.ts new file mode 100644 index 00000000000..24a04069f06 --- /dev/null +++ b/apps/studio/components/ui/Forms/index.ts @@ -0,0 +1 @@ +export { FormFieldWrapper } from './FormFieldWrapper' diff --git a/apps/studio/components/ui/Resource/ResourceItem.tsx b/apps/studio/components/ui/Resource/ResourceItem.tsx new file mode 100644 index 00000000000..15e54cdf311 --- /dev/null +++ b/apps/studio/components/ui/Resource/ResourceItem.tsx @@ -0,0 +1,38 @@ +import { ChevronRight } from 'lucide-react' +import { CardContent, cn } from 'ui' +import { forwardRef } from 'react' + +export interface ResourceItemProps extends React.HTMLAttributes { + media?: React.ReactNode + meta?: React.ReactNode + onClick?: () => void + children?: React.ReactNode +} + +export const ResourceItem = forwardRef( + ({ media, meta, onClick, children, className, ...props }, ref) => { + return ( + + {media && ( +
+ {media} +
+ )} +
{children}
+ {meta &&
{meta}
} + {onClick && } +
+ ) + } +) + +ResourceItem.displayName = 'ResourceItem' diff --git a/apps/studio/components/ui/Resource/ResourceList.tsx b/apps/studio/components/ui/Resource/ResourceList.tsx new file mode 100644 index 00000000000..9ec12d8cacf --- /dev/null +++ b/apps/studio/components/ui/Resource/ResourceList.tsx @@ -0,0 +1,19 @@ +import { Card } from 'ui' +import { cn } from 'ui' +import { forwardRef } from 'react' + +export interface ResourceListProps extends React.HTMLAttributes { + children?: React.ReactNode +} + +export const ResourceList = forwardRef( + ({ children, className, ...props }, ref) => { + return ( + +
{children}
+
+ ) + } +) + +ResourceList.displayName = 'ResourceList' diff --git a/apps/studio/components/ui/Resource/index.ts b/apps/studio/components/ui/Resource/index.ts new file mode 100644 index 00000000000..7d4d24dac24 --- /dev/null +++ b/apps/studio/components/ui/Resource/index.ts @@ -0,0 +1,4 @@ +export { ResourceList } from './ResourceList' +export { ResourceItem } from './ResourceItem' +export type { ResourceListProps } from './ResourceList' +export type { ResourceItemProps } from './ResourceItem' diff --git a/packages/ui/src/components/shadcn/ui/card.tsx b/packages/ui/src/components/shadcn/ui/card.tsx index 081e0715cdb..59529534d41 100644 --- a/packages/ui/src/components/shadcn/ui/card.tsx +++ b/packages/ui/src/components/shadcn/ui/card.tsx @@ -6,7 +6,7 @@ const Card = React.forwardRef (
) @@ -15,14 +15,18 @@ Card.displayName = 'Card' const CardHeader = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
) ) CardHeader.displayName = 'CardHeader' const CardTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -

+

) ) CardTitle.displayName = 'CardTitle' @@ -31,20 +35,20 @@ const CardDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -

+

)) CardDescription.displayName = 'CardDescription' const CardContent = React.forwardRef>( ({ className, ...props }, ref) => ( -

+
) ) CardContent.displayName = 'CardContent' const CardFooter = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
) ) CardFooter.displayName = 'CardFooter'