mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 06:39:09 +03:00
rvert
This commit is contained in:
1 parent
d349812c69
commit
e7ad392b1d
5 files changed
+483
-510
No files matched your search
@@ -1,73 +1,24 @@
|
||||
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,
|
||||
@@ -76,49 +27,6 @@ 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
|
||||
@@ -126,7 +34,7 @@ const AuthProvidersForm = () => {
|
||||
subtitle="Authenticate your users through a suite of providers and login methods"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="-space-y-px">
|
||||
{authConfig?.EXTERNAL_EMAIL_ENABLED && authConfig?.MAILER_OTP_EXP > 3600 && (
|
||||
<Alert_Shadcn_
|
||||
className="flex w-full items-center justify-between my-3"
|
||||
@@ -149,18 +57,17 @@ const AuthProvidersForm = () => {
|
||||
</div>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{PROVIDERS_SCHEMAS.map((provider) => (
|
||||
{isLoading &&
|
||||
PROVIDERS_SCHEMAS.map((provider) => {
|
||||
return (
|
||||
<div
|
||||
key={`provider_${provider.title}`}
|
||||
className="border rounded-md border-strong px-6 py-4"
|
||||
className={[...ProviderCollapsibleClasses, 'px-6 py-3'].join(' ')}
|
||||
>
|
||||
<HorizontalShimmerWithIcon />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
{isError && (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<WarningIcon />
|
||||
@@ -168,110 +75,21 @@ const AuthProvidersForm = () => {
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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,32 +1,19 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Eye, EyeOff, CalendarIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { format } from 'date-fns'
|
||||
import { cn } from 'ui/src/lib/utils/cn'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { Markdown } from 'components/interfaces/Markdown'
|
||||
import { DatePicker } from 'components/ui/DatePicker'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Button, Input, InputNumber, Listbox, Toggle } 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
|
||||
}
|
||||
|
||||
@@ -34,151 +21,268 @@ function formatDate(date: Date): string {
|
||||
return dayjs(date).format('dddd, MMMM D, YYYY HH:mm:ss Z')
|
||||
}
|
||||
|
||||
const FormField = ({ field, name, properties, disabled = false }: FormFieldProps) => {
|
||||
const FormField = ({
|
||||
name,
|
||||
properties,
|
||||
formValues,
|
||||
disabled = false,
|
||||
setFieldValue,
|
||||
}: 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(properties.show.key)) {
|
||||
if (!properties.show.matches.includes(formValues[properties.show.key])) {
|
||||
return null
|
||||
}
|
||||
} else if (!properties.show.key) {
|
||||
} else if (!formValues[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 (
|
||||
<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())
|
||||
<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, '')
|
||||
}
|
||||
}}
|
||||
disabled={(date: Date) => date < new Date()}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
>
|
||||
<span>Pick</span>
|
||||
</DatePicker>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'multiline-string':
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<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)}
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={field.onChange}
|
||||
<Toggle
|
||||
size="small"
|
||||
id={name}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
label={properties.title}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<ReactMarkdown
|
||||
unwrapDisallowed
|
||||
disallowedElements={['p']}
|
||||
className="form-field-markdown"
|
||||
>
|
||||
{properties.description}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<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}>
|
||||
<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
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
)
|
||||
|
||||
default:
|
||||
return <Input_Shadcn_ {...field} value={field.value ?? ''} disabled={disabled} />
|
||||
break
|
||||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default FormField
|
||||
@@ -1,16 +1,13 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Check, ChevronUp, ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } 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'
|
||||
@@ -22,80 +19,34 @@ import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Switch,
|
||||
Collapsible,
|
||||
Form,
|
||||
Input,
|
||||
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 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 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 doubleNegativeKeys = ['MAILER_AUTOCONFIRM', 'SMS_AUTOCONFIRM']
|
||||
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 &&
|
||||
@@ -115,13 +66,6 @@ const ProviderForm = ({ config, provider, onClose, form }: 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
|
||||
@@ -167,127 +111,229 @@ const ProviderForm = ({ config, provider, onClose, form }: 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()) {
|
||||
onClose()
|
||||
setOpen(true)
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlProvider])
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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 (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
|
||||
{provider?.misc?.alert && (
|
||||
<CardContent>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={provider.misc.alert.title}
|
||||
description={
|
||||
<>
|
||||
<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>
|
||||
</>
|
||||
}
|
||||
<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}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
<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>
|
||||
<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)
|
||||
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.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>
|
||||
{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>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -239,11 +239,16 @@ const BasicAuthSettingsForm = () => {
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{form.formState.isDirty && (
|
||||
<Button type="default" onClick={() => form.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canUpdateConfig || isUpdatingConfig}
|
||||
disabled={!canUpdateConfig || isUpdatingConfig || !form.formState.isDirty}
|
||||
loading={isUpdatingConfig}
|
||||
>
|
||||
Save changes
|
||||
|
||||
@@ -42,7 +42,7 @@ export function FormFieldWrapper({
|
||||
<FormDescription_Shadcn_ className="text-xs">{description}</FormDescription_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
<div className={orientation === 'horizontal' ? 'col-span-6 flex justify-end' : ''}>
|
||||
<div className={orientation === 'horizontal' ? 'col-span-6 flex flex-col items-end' : ''}>
|
||||
<FormControl_Shadcn_>{children(field)}</FormControl_Shadcn_>
|
||||
<FormMessage_Shadcn_ />
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user