settings but broken saving

This commit is contained in:
Saxon Fletcher committed 2025-02-10 16:56:34 +10:00
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>
)
}
@@ -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_>
)}
/>
)
}
+1
View File
@@ -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'
+10 -6
View File
@@ -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'