mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
settings but broken saving
This commit is contained in:
1 parent
5d3a1901ab
commit
d349812c69
10 files changed
+802
-643
No files matched your search
@@ -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<string | null>(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 (
|
||||
<div>
|
||||
<SectionHeader
|
||||
@@ -34,7 +126,7 @@ const AuthProvidersForm = () => {
|
||||
subtitle="Authenticate your users through a suite of providers and login methods"
|
||||
/>
|
||||
|
||||
<div className="-space-y-px">
|
||||
<div className="space-y-4">
|
||||
{authConfig?.EXTERNAL_EMAIL_ENABLED && authConfig?.MAILER_OTP_EXP > 3600 && (
|
||||
<Alert_Shadcn_
|
||||
className="flex w-full items-center justify-between my-3"
|
||||
@@ -57,17 +149,18 @@ const AuthProvidersForm = () => {
|
||||
</div>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{isLoading &&
|
||||
PROVIDERS_SCHEMAS.map((provider) => {
|
||||
return (
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{PROVIDERS_SCHEMAS.map((provider) => (
|
||||
<div
|
||||
key={`provider_${provider.title}`}
|
||||
className={[...ProviderCollapsibleClasses, 'px-6 py-3'].join(' ')}
|
||||
className="border rounded-md border-strong px-6 py-4"
|
||||
>
|
||||
<HorizontalShimmerWithIcon />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<WarningIcon />
|
||||
@@ -75,21 +168,110 @@ const AuthProvidersForm = () => {
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{isSuccess &&
|
||||
PROVIDERS_SCHEMAS.map((provider) => {
|
||||
const providerSchema =
|
||||
provider.title === 'Phone'
|
||||
? { ...provider, validationSchema: getPhoneProviderValidationSchema(authConfig) }
|
||||
: provider
|
||||
return (
|
||||
<ProviderForm
|
||||
key={`provider_${providerSchema.title}`}
|
||||
config={authConfig}
|
||||
provider={providerSchema as any}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{isSuccess && (
|
||||
<ResourceList>
|
||||
{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 (
|
||||
<ResourceItem
|
||||
key={`provider_${provider.title}`}
|
||||
media={
|
||||
<img
|
||||
src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`}
|
||||
width={18}
|
||||
alt={`${provider.title} auth icon`}
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<Badge variant={isActive ? 'success' : 'default'}>
|
||||
{isActive ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
}
|
||||
onClick={() => setSelectedProvider(provider.title)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-foreground">{provider.title}</div>
|
||||
{'helper' in provider.misc && (
|
||||
<p className="text-foreground-light text-xs">{provider.misc.helper}</p>
|
||||
)}
|
||||
</div>
|
||||
</ResourceItem>
|
||||
)
|
||||
})}
|
||||
</ResourceList>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
open={selectedProvider !== null}
|
||||
onOpenChange={(open) => !open && setSelectedProvider(null)}
|
||||
>
|
||||
<SheetContent size="lg" className="flex flex-col">
|
||||
{activeProviderSchema && (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="h-full flex flex-col">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle className="flex items-center gap-3">
|
||||
<img
|
||||
src={`${BASE_PATH}/img/icons/${activeProviderSchema.misc.iconKey}.svg`}
|
||||
width={21}
|
||||
alt={`${activeProviderSchema.title} auth icon`}
|
||||
/>
|
||||
{activeProviderSchema.title} Configuration
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="px-6 py-4 flex-grow space-y-4 flex-1 overflow-auto">
|
||||
<ProviderForm
|
||||
config={authConfig}
|
||||
provider={activeProviderSchema as any}
|
||||
onClose={() => setSelectedProvider(null)}
|
||||
form={form}
|
||||
/>
|
||||
</div>
|
||||
<SheetFooter className="border-t px-6 py-4 shrink-0">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<DocsButton href={activeProviderSchema.link} />
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
form.reset()
|
||||
setSelectedProvider(null)
|
||||
}}
|
||||
disabled={isUpdatingConfig}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isUpdatingConfig}
|
||||
disabled={isUpdatingConfig}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ReactMarkdown
|
||||
className="text-foreground-light text-sm"
|
||||
unwrapDisallowed
|
||||
disallowedElements={['p']}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
switch (properties.type) {
|
||||
case 'datetime':
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
layout="vertical"
|
||||
id={name}
|
||||
name={name}
|
||||
type="text"
|
||||
value={dateAsText}
|
||||
readOnly
|
||||
label={properties.title}
|
||||
labelOptional={
|
||||
properties.descriptionOptional ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.descriptionOptional}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.description}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
<DatePicker
|
||||
selectsRange={false}
|
||||
minDate={new Date()}
|
||||
from={formValues[name]}
|
||||
to={formValues[name]}
|
||||
onChange={(date) => {
|
||||
if (date && date.to) {
|
||||
setFieldValue(name, date.to)
|
||||
setDateAsText(formatDate(new Date(date.to)))
|
||||
} else {
|
||||
setDateAsText('')
|
||||
setFieldValue(name, '')
|
||||
<Popover_Shadcn_>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<Button
|
||||
type="outline"
|
||||
className={cn(
|
||||
'w-[240px] pl-3 text-left font-normal',
|
||||
!field.value && 'text-foreground-light'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{field.value ? format(new Date(field.value), 'PPP') : <span>Pick a date</span>}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl_Shadcn_>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value ? new Date(field.value) : undefined}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
if (date) {
|
||||
// Set time to 24 hours from now
|
||||
date.setHours(date.getHours() + 24)
|
||||
field.onChange(date.toISOString())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>Pick</span>
|
||||
</DatePicker>
|
||||
}
|
||||
/>
|
||||
disabled={(date: Date) => date < new Date()}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
layout="vertical"
|
||||
id={name}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
type={hidden ? 'password' : 'text'}
|
||||
label={properties.title}
|
||||
labelOptional={
|
||||
properties.descriptionOptional ? (
|
||||
<Markdown
|
||||
content={properties.descriptionOptional}
|
||||
className="text-foreground-lighter"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<Markdown content={properties.description} className="text-foreground-lighter" />
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
!!properties.isSecret ? (
|
||||
<Button
|
||||
icon={hidden ? <Eye /> : <EyeOff />}
|
||||
type="default"
|
||||
onClick={() => setHidden(!hidden)}
|
||||
/>
|
||||
) : (
|
||||
<span className="mr-3 text-foreground-lighter">
|
||||
{properties.units ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.units}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2 flex-grow">
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type={hidden ? 'password' : 'text'}
|
||||
disabled={disabled}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
{properties.isSecret && (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setHidden(!hidden)}
|
||||
icon={hidden ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{properties.units && (
|
||||
<span className="flex items-center text-foreground-light text-sm">
|
||||
{renderMarkdown(properties.units)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'multiline-string':
|
||||
return (
|
||||
<Input.TextArea
|
||||
size="small"
|
||||
layout="vertical"
|
||||
id={name}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
type={hidden ? 'password' : 'text'}
|
||||
label={properties.title}
|
||||
labelOptional={
|
||||
properties.descriptionOptional ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.descriptionOptional}
|
||||
</ReactMarkdown>
|
||||
) : undefined
|
||||
}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<Markdown content={properties.description} className="text-foreground-lighter" />
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
!!properties.isSecret ? (
|
||||
<Button
|
||||
icon={hidden ? <Eye /> : <EyeOff />}
|
||||
type="default"
|
||||
onClick={() => setHidden(!hidden)}
|
||||
/>
|
||||
) : (
|
||||
<span className="mr-3 text-scale-900">
|
||||
{properties.units ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.units}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex-grow">
|
||||
<TextArea_Shadcn_
|
||||
{...field}
|
||||
className="resize-y"
|
||||
rows={4}
|
||||
disabled={disabled}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
{properties.units && (
|
||||
<span className="flex items-center text-foreground-light text-sm mt-2">
|
||||
{renderMarkdown(properties.units)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<InputNumber
|
||||
size="small"
|
||||
layout="vertical"
|
||||
id={name}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
label={properties.title}
|
||||
labelOptional={
|
||||
properties.descriptionOptional ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.descriptionOptional}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<Markdown content={properties.description} className="text-foreground-lighter" />
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
<span className="mr-3 text-foreground-lighter">
|
||||
{properties.units ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.units}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
<div className="flex gap-2 flex-grow">
|
||||
<Input_Shadcn_ {...field} type="number" disabled={disabled} value={field.value ?? ''} />
|
||||
{properties.units && (
|
||||
<span className="flex items-center text-foreground-light text-sm">
|
||||
{renderMarkdown(properties.units)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<Toggle
|
||||
size="small"
|
||||
id={name}
|
||||
name={name}
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
label={properties.title}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<ReactMarkdown
|
||||
unwrapDisallowed
|
||||
disallowedElements={['p']}
|
||||
className="form-field-markdown"
|
||||
>
|
||||
{properties.description}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Listbox
|
||||
size="small"
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
label={properties.title}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<ReactMarkdown
|
||||
unwrapDisallowed
|
||||
disallowedElements={['p']}
|
||||
className="form-field-markdown"
|
||||
>
|
||||
{properties.description}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
}
|
||||
defaultValue={properties.enum[0]}
|
||||
>
|
||||
{properties.enum.map((option: Enum) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
id={option.value}
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
addOnBefore={() => {
|
||||
return option.icon ? (
|
||||
<img className="h-6 w-6" src={`${BASE_PATH}/img/icons/${option.icon}`} />
|
||||
) : null
|
||||
}}
|
||||
>
|
||||
<Select_Shadcn_ value={field.value} onValueChange={field.onChange} disabled={disabled}>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Select an option" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{properties.enum?.map((option: Enum) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
)
|
||||
|
||||
default:
|
||||
break
|
||||
return <Input_Shadcn_ {...field} value={field.value ?? ''} disabled={disabled} />
|
||||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default FormField
|
||||
@@ -1,13 +1,16 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Check, ChevronUp, ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { toast } from 'sonner'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { FormFieldWrapper } from 'components/ui/Forms'
|
||||
import type { components } from 'data/api'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
@@ -19,34 +22,80 @@ import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Button,
|
||||
Collapsible,
|
||||
Form,
|
||||
Input,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Switch,
|
||||
WarningIcon,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from 'ui'
|
||||
import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants'
|
||||
import type { Provider } from './AuthProvidersForm.types'
|
||||
import FormField from './FormField'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { EyeIcon } from 'lucide-react'
|
||||
import FormField from './FormField'
|
||||
|
||||
export interface ProviderFormProps {
|
||||
config: components['schemas']['GoTrueConfigResponse']
|
||||
provider: Provider
|
||||
onClose: () => void
|
||||
form: any
|
||||
}
|
||||
|
||||
const ProviderForm = ({ config, provider }: ProviderFormProps) => {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const { ref: projectRef, provider: urlProvider } = useParams()
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const generateInitialValues = (provider: Provider, config: ProviderFormProps['config']) => {
|
||||
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 ProviderForm = ({ config, provider, onClose, form }: ProviderFormProps) => {
|
||||
const { ref: projectRef, provider: urlProvider } = useParams()
|
||||
const canUpdateConfig: boolean = useCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
const watchedValues = useWatch({
|
||||
control: form.control,
|
||||
name: Object.keys(provider.properties).map((key) => key),
|
||||
})
|
||||
|
||||
const isSAMLEnabled: boolean =
|
||||
provider.title === 'SAML 2.0' && config && (config as any)['SAML_ENABLED']
|
||||
const isLinkedInOIDCEnabled: boolean =
|
||||
provider.title === 'LinkedIn (OIDC)' &&
|
||||
config &&
|
||||
(config as any)['EXTERNAL_LINKEDIN_OIDC_ENABLED']
|
||||
const isSlackOIDCEnabled =
|
||||
provider.title === 'Slack (OIDC)' && config['EXTERNAL_SLACK_OIDC_ENABLED']
|
||||
const isExternalProviderAndEnabled: boolean =
|
||||
config && (config as any)[`EXTERNAL_${provider?.title?.toUpperCase()}_ENABLED`]
|
||||
|
||||
const isActive: boolean =
|
||||
isSAMLEnabled || isExternalProviderAndEnabled || isLinkedInOIDCEnabled || isSlackOIDCEnabled
|
||||
|
||||
const shouldDisableField = (field: string): boolean => {
|
||||
const shouldDisableSmsFields =
|
||||
config.HOOK_SEND_SMS_ENABLED &&
|
||||
@@ -66,6 +115,13 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const apiUrl = `${protocol}://${endpoint}`
|
||||
|
||||
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
|
||||
|
||||
const showAlert = (title: string) => {
|
||||
switch (title) {
|
||||
// TODO (KM): Remove after 10th October 2024 when we disable the provider
|
||||
@@ -111,229 +167,127 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const apiUrl = `${protocol}://${endpoint}`
|
||||
|
||||
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
|
||||
|
||||
const generateInitialValues = () => {
|
||||
const initialValues: { [x: string]: string | boolean } = {}
|
||||
|
||||
// the config is already loaded through the parent component
|
||||
Object.keys(provider.properties).forEach((key) => {
|
||||
// When the key is a 'double negative' key, we must reverse the boolean before adding it to the form
|
||||
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 isSAMLEnabled: boolean =
|
||||
provider.title === 'SAML 2.0' && config && (config as any)['SAML_ENABLED']
|
||||
// [Joel] Introduced as the new LinkedIn provider has a corresponding config var of LINKEDIN_OIDC
|
||||
const isLinkedInOIDCEnabled: boolean =
|
||||
provider.title === 'LinkedIn (OIDC)' &&
|
||||
config &&
|
||||
(config as any)['EXTERNAL_LINKEDIN_OIDC_ENABLED']
|
||||
const isSlackOIDCEnabled =
|
||||
provider.title === 'Slack (OIDC)' && config['EXTERNAL_SLACK_OIDC_ENABLED']
|
||||
const isExternalProviderAndEnabled: boolean =
|
||||
config && (config as any)[`EXTERNAL_${provider?.title?.toUpperCase()}_ENABLED`]
|
||||
|
||||
// [Joshen] Doing this check as SAML doesn't follow the same naming structure as the other provider options
|
||||
const isActive: boolean =
|
||||
isSAMLEnabled || isExternalProviderAndEnabled || isLinkedInOIDCEnabled || isSlackOIDCEnabled
|
||||
const INITIAL_VALUES = generateInitialValues()
|
||||
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = { ...values }
|
||||
|
||||
// Format payload for the following checks:
|
||||
// 1. Convert all empty string values to null
|
||||
// 2. When the key is a 'double negative' key, we must reverse the boolean before the payload can be sent
|
||||
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: () => {
|
||||
resetForm({ values: { ...values }, initialValues: { ...values } })
|
||||
setOpen(false)
|
||||
toast.success('Successfully updated settings')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (urlProvider?.toLowerCase() === provider.title.toLowerCase()) {
|
||||
setOpen(true)
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
onClose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlProvider])
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className={ProviderCollapsibleClasses.join(' ')}
|
||||
>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className="group flex w-full items-center justify-between rounded py-3 px-4 text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChevronUp
|
||||
className="text-border-stronger transition data-open-parent:rotate-0 data-closed-parent:rotate-180"
|
||||
strokeWidth={2}
|
||||
width={14}
|
||||
/>
|
||||
<img
|
||||
src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`}
|
||||
width={18}
|
||||
alt={`${provider.title} auth icon`}
|
||||
/>
|
||||
<span className="text-sm">{provider.title}</span>
|
||||
<div className="py-4">
|
||||
{showAlert(provider.title)}
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Provider Status</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Enable or disable {provider.title} authentication for your users
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-foreground-light">
|
||||
{isActive ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isActive ? (
|
||||
<div className="flex items-center gap-1 rounded-full border border-brand-400 bg-brand-200 py-1 px-1 text-xs text-brand">
|
||||
<span className="rounded-full bg-brand p-0.5 text-xs text-brand-200">
|
||||
<Check strokeWidth={2} size={12} />
|
||||
</span>
|
||||
<span className="px-1">Enabled</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-strong bg-surface-100 py-1 px-3 text-xs text-foreground-lighter">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
<Form
|
||||
name={`provider-${provider.title}-form`}
|
||||
initialValues={INITIAL_VALUES}
|
||||
validationSchema={provider.validationSchema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ handleReset, initialValues, values, setFieldValue }: any) => {
|
||||
const noChanges = JSON.stringify(initialValues) === JSON.stringify(values)
|
||||
</CardContent>
|
||||
|
||||
{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 (
|
||||
<Collapsible.Content>
|
||||
<div className="group border-t border-strong bg-surface-100 py-6 px-4 md:px-6 text-foreground">
|
||||
<div className="mx-auto my-2 md:my-6 max-w-lg space-y-6">
|
||||
{showAlert(provider.title)}
|
||||
{Object.keys(provider.properties).map((x: string) => (
|
||||
<FormField
|
||||
key={x}
|
||||
name={x}
|
||||
setFieldValue={setFieldValue}
|
||||
properties={provider.properties[x]}
|
||||
formValues={values}
|
||||
disabled={
|
||||
// TODO (KM): Remove after 10th October 2024 when we disable the provider
|
||||
shouldDisableField(x) || !canUpdateConfig
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{provider?.misc?.alert && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={provider.misc.alert.title}
|
||||
description={
|
||||
<>
|
||||
<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{provider.misc.requiresRedirect && (
|
||||
<>
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
disabled
|
||||
label="Callback URL (for OAuth)"
|
||||
value={
|
||||
customDomainData?.customDomain?.status === 'active'
|
||||
? `https://${customDomainData.customDomain?.hostname}/auth/v1/callback`
|
||||
: `${apiUrl}/auth/v1/callback`
|
||||
}
|
||||
descriptionText={
|
||||
<Markdown
|
||||
content={provider.misc.helper}
|
||||
className="text-foreground-lighter"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<DocsButton href={provider.link} />
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Button
|
||||
type="default"
|
||||
htmlType="reset"
|
||||
onClick={() => {
|
||||
handleReset()
|
||||
setOpen(false)
|
||||
}}
|
||||
disabled={isUpdatingConfig}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonTooltip
|
||||
htmlType="submit"
|
||||
loading={isUpdatingConfig}
|
||||
disabled={isUpdatingConfig || !canUpdateConfig || noChanges}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateConfig
|
||||
? 'You need additional permissions to update provider settings'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
<CardContent key={key}>
|
||||
<FormFieldWrapper
|
||||
control={form.control}
|
||||
name={key}
|
||||
label={property.title || key}
|
||||
description={property.description || ''}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{(field) => (
|
||||
<FormField
|
||||
field={field}
|
||||
name={key}
|
||||
properties={property}
|
||||
disabled={shouldDisableField(key) || !canUpdateConfig}
|
||||
/>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
</CardContent>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</Collapsible>
|
||||
})}
|
||||
|
||||
{provider?.misc?.alert && (
|
||||
<CardContent>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={provider.misc.alert.title}
|
||||
description={
|
||||
<>
|
||||
<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{provider.misc.requiresRedirect && (
|
||||
<CardContent>
|
||||
<FormFieldWrapper
|
||||
control={form.control}
|
||||
name="callback_url"
|
||||
label="Callback URL (for OAuth)"
|
||||
description={
|
||||
<Markdown content={provider.misc.helper} className="text-foreground-lighter" />
|
||||
}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{() => (
|
||||
<div className="flex gap-4">
|
||||
<Input_Shadcn_
|
||||
readOnly
|
||||
disabled
|
||||
value={
|
||||
customDomainData?.customDomain?.status === 'active'
|
||||
? `https://${customDomainData.customDomain?.hostname}/auth/v1/callback`
|
||||
: `${apiUrl}/auth/v1/callback`
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
const url =
|
||||
customDomainData?.customDomain?.status === 'active'
|
||||
? `https://${customDomainData.customDomain?.hostname}/auth/v1/callback`
|
||||
: `${apiUrl}/auth/v1/callback`
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success('Copied to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+170
-162
@@ -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 (
|
||||
<div>
|
||||
<SectionHeader title="General" />
|
||||
<SectionHeader title="User Signups" />
|
||||
|
||||
<Form
|
||||
id={formId}
|
||||
initialValues={INITIAL_VALUES}
|
||||
onSubmit={onSubmit}
|
||||
validationSchema={schema}
|
||||
>
|
||||
{({ 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 (
|
||||
<>
|
||||
<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>
|
||||
}
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormFieldWrapper
|
||||
control={form.control}
|
||||
name="DISABLE_SIGNUP"
|
||||
label="Allow new users to sign up"
|
||||
description="If this is disabled, new users will not be able to sign up to your application."
|
||||
orientation="horizontal"
|
||||
>
|
||||
<FormSection header={<FormSectionLabel>User Signups</FormSectionLabel>}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Toggle
|
||||
id="DISABLE_SIGNUP"
|
||||
size="small"
|
||||
label="Allow new users to sign up"
|
||||
layout="flex"
|
||||
descriptionText="If this is disabled, new users will not be able to sign up to your application."
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
<Toggle
|
||||
id="SECURITY_MANUAL_LINKING_ENABLED"
|
||||
size="small"
|
||||
label="Allow manual linking"
|
||||
layout="flex"
|
||||
descriptionText={
|
||||
<Markdown
|
||||
extLinks
|
||||
className="[&>p>a]:text-foreground-light [&>p>a]:transition-all [&>p>a]:hover:text-foreground [&>p>a]:hover:decoration-brand"
|
||||
content="Enable [manual linking APIs](https://supabase.com/docs/guides/auth/auth-identity-linking#manual-linking-beta) for your project."
|
||||
/>
|
||||
}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
<Toggle
|
||||
id="EXTERNAL_ANONYMOUS_USERS_ENABLED"
|
||||
size="small"
|
||||
label="Allow anonymous sign-ins"
|
||||
layout="flex"
|
||||
descriptionText={
|
||||
<Markdown
|
||||
extLinks
|
||||
className="[&>p>a]:text-foreground-light [&>p>a]:transition-all [&>p>a]:hover:text-foreground [&>p>a]:hover:decoration-brand"
|
||||
content="Enable [anonymous sign-ins](https://supabase.com/docs/guides/auth/auth-anonymous) for your project."
|
||||
/>
|
||||
}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
{values.EXTERNAL_ANONYMOUS_USERS_ENABLED && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Alert_Shadcn_
|
||||
className="flex w-full items-center justify-between"
|
||||
variant="warning"
|
||||
{(field) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormFieldWrapper
|
||||
control={form.control}
|
||||
name="SECURITY_MANUAL_LINKING_ENABLED"
|
||||
label="Allow manual linking"
|
||||
description={
|
||||
<Markdown
|
||||
extLinks
|
||||
className="[&>p>a]:text-foreground-light [&>p>a]:transition-all [&>p>a]:hover:text-foreground [&>p>a]:hover:decoration-brand"
|
||||
content="Enable [manual linking APIs](https://supabase.com/docs/guides/auth/auth-identity-linking#manual-linking-beta) for your project."
|
||||
/>
|
||||
}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{(field) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormFieldWrapper
|
||||
control={form.control}
|
||||
name="EXTERNAL_ANONYMOUS_USERS_ENABLED"
|
||||
label="Allow anonymous sign-ins"
|
||||
description={
|
||||
<Markdown
|
||||
extLinks
|
||||
className="[&>p>a]:text-foreground-light [&>p>a]:transition-all [&>p>a]:hover:text-foreground [&>p>a]:hover:decoration-brand"
|
||||
content="Enable [anonymous sign-ins](https://supabase.com/docs/guides/auth/auth-anonymous) for your project."
|
||||
/>
|
||||
}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{(field) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
|
||||
{form.watch('EXTERNAL_ANONYMOUS_USERS_ENABLED') && (
|
||||
<Alert_Shadcn_
|
||||
className="flex w-full items-center justify-between mt-4"
|
||||
variant="warning"
|
||||
>
|
||||
<WarningIcon />
|
||||
<div>
|
||||
<AlertTitle_Shadcn_>
|
||||
Anonymous users will use the <code className="text-xs">authenticated</code>{' '}
|
||||
role when signing in
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-col gap-y-3">
|
||||
<p>
|
||||
As a result, anonymous users will be subjected to RLS policies that apply to
|
||||
the <code className="text-xs">public</code> and{' '}
|
||||
<code className="text-xs">authenticated</code> roles. We strongly advise{' '}
|
||||
<Link
|
||||
href={`/project/${projectRef}/auth/policies`}
|
||||
className="text-foreground underline"
|
||||
>
|
||||
<WarningIcon />
|
||||
<div>
|
||||
<AlertTitle_Shadcn_>
|
||||
Anonymous users will use the{' '}
|
||||
<code className="text-xs">authenticated</code> role when signing in
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-col gap-y-3">
|
||||
<p>
|
||||
As a result, anonymous users will be subjected to RLS policies that
|
||||
apply to the <code className="text-xs">public</code> and{' '}
|
||||
<code className="text-xs">authenticated</code> roles. We strongly
|
||||
advise{' '}
|
||||
<Link
|
||||
href={`/project/${projectRef}/auth/policies`}
|
||||
className="text-foreground underline"
|
||||
>
|
||||
reviewing your RLS policies
|
||||
</Link>{' '}
|
||||
to ensure that access to your data is restricted where required.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
className="w-min"
|
||||
icon={<ExternalLink />}
|
||||
>
|
||||
<Link href="https://supabase.com/docs/guides/auth/auth-anonymous#access-control">
|
||||
View access control docs
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription_Shadcn_>
|
||||
</div>
|
||||
</Alert_Shadcn_>
|
||||
</div>
|
||||
)}
|
||||
{!authConfig?.SECURITY_CAPTCHA_ENABLED &&
|
||||
values.EXTERNAL_ANONYMOUS_USERS_ENABLED && (
|
||||
<Alert_Shadcn_>
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
We highly recommend{' '}
|
||||
<span
|
||||
tabIndex={1}
|
||||
className="cursor-pointer underline"
|
||||
onClick={() => {
|
||||
const el = document.getElementById('enable-captcha')
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}}
|
||||
>
|
||||
enabling captcha
|
||||
</span>{' '}
|
||||
for anonymous sign-ins
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
This will prevent potential abuse on sign-ins which may bloat your
|
||||
database and incur costs for monthly active users (MAU)
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
reviewing your RLS policies
|
||||
</Link>{' '}
|
||||
to ensure that access to your data is restricted where required.
|
||||
</p>
|
||||
<Button asChild type="default" className="w-min" icon={<ExternalLink />}>
|
||||
<Link href="https://supabase.com/docs/guides/auth/auth-anonymous#access-control">
|
||||
View access control docs
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription_Shadcn_>
|
||||
</div>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
|
||||
{!authConfig?.SECURITY_CAPTCHA_ENABLED &&
|
||||
form.watch('EXTERNAL_ANONYMOUS_USERS_ENABLED') && (
|
||||
<Alert_Shadcn_ className="mt-4">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
We highly recommend{' '}
|
||||
<span
|
||||
tabIndex={1}
|
||||
className="cursor-pointer underline"
|
||||
onClick={() => {
|
||||
const el = document.getElementById('enable-captcha')
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}}
|
||||
>
|
||||
enabling captcha
|
||||
</span>{' '}
|
||||
for anonymous sign-ins
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
This will prevent potential abuse on sign-ins which may bloat your database
|
||||
and incur costs for monthly active users (MAU)
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canUpdateConfig || isUpdatingConfig}
|
||||
loading={isUpdatingConfig}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<any>
|
||||
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 (
|
||||
<FormField_Shadcn_
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_
|
||||
className={
|
||||
orientation === 'horizontal' ? 'grid grid-cols-12 gap-8 items-center' : 'space-y-2'
|
||||
}
|
||||
>
|
||||
<div className={orientation === 'horizontal' ? 'col-span-6' : ''}>
|
||||
<FormLabel_Shadcn_ className="text-foreground">{label}</FormLabel_Shadcn_>
|
||||
{description && (
|
||||
<FormDescription_Shadcn_ className="text-xs">{description}</FormDescription_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
<div className={orientation === 'horizontal' ? 'col-span-6 flex justify-end' : ''}>
|
||||
<FormControl_Shadcn_>{children(field)}</FormControl_Shadcn_>
|
||||
<FormMessage_Shadcn_ />
|
||||
</div>
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FormFieldWrapper } from './FormFieldWrapper'
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { CardContent, cn } from 'ui'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export interface ResourceItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
media?: React.ReactNode
|
||||
meta?: React.ReactNode
|
||||
onClick?: () => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const ResourceItem = forwardRef<HTMLDivElement, ResourceItemProps>(
|
||||
({ media, meta, onClick, children, className, ...props }, ref) => {
|
||||
return (
|
||||
<CardContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center justify-between text-sm gap-4',
|
||||
onClick && 'cursor-pointer hover:bg-surface-100',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
{media && (
|
||||
<div className="w-12 h-12 rounded bg-muted text-foreground-light flex items-center justify-center">
|
||||
{media}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">{children}</div>
|
||||
{meta && <div>{meta}</div>}
|
||||
{onClick && <ChevronRight strokeWidth={1.5} size={16} />}
|
||||
</CardContent>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ResourceItem.displayName = 'ResourceItem'
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Card } from 'ui'
|
||||
import { cn } from 'ui'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export interface ResourceListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const ResourceList = forwardRef<HTMLDivElement, ResourceListProps>(
|
||||
({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<Card ref={ref} {...props}>
|
||||
<div className={cn('divide-y divide-default', className)}>{children}</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ResourceList.displayName = 'ResourceList'
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ResourceList } from './ResourceList'
|
||||
export { ResourceItem } from './ResourceItem'
|
||||
export type { ResourceListProps } from './ResourceList'
|
||||
export type { ResourceItemProps } from './ResourceItem'
|
||||
@@ -6,7 +6,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
className={cn('rounded-lg border bg-surface-100 text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -15,14 +15,18 @@ Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 py-4 px-6 border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('text-base leading-none tracking-tight', className)} {...props} />
|
||||
<h3 ref={ref} className={cn('text-xs font-mono uppercase', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
@@ -31,20 +35,20 @@ const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-foreground-light', className)} {...props} />
|
||||
<p ref={ref} className={cn('text-sm text-foreground-muted', className)} {...props} />
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
<div ref={ref} className={cn('py-4 px-6 border-b last:border-none', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(' flex items-center p-6 pt-0', className)} {...props} />
|
||||
<div ref={ref} className={cn(' flex items-center py-4 px-6', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
Reference in new issue
Block a user