mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 06:39:09 +03:00
Update authentication advanced page (#33955)
* init PrePostTab component * init * support auto fixing rounded of inputs/select/textarea * remove form item layout prepost * Update FormLayout.tsx * Update FormLayout.tsx * Update FormLayout.tsx * form layout and advanced * Update advanced.tsx * protection and hooks * fix * parse --------- Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
This commit is contained in:
1 parent
9939cfc670
commit
3718608cf0
10 files changed
+513
-312
No files matched your search
@@ -1,15 +1,12 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { StringNumberOrNull } from 'components/ui/Forms/Form.constants'
|
||||
import { FormActions } from 'components/ui/Forms/FormActions'
|
||||
import { FormPanel } from 'components/ui/Forms/FormPanel'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
@@ -22,15 +19,19 @@ import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
PrePostTab,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
const formId = 'auth-config-advanced-form'
|
||||
import { StringNumberOrNull } from 'components/ui/Forms/Form.constants'
|
||||
|
||||
const FormSchema = z.object({
|
||||
API_MAX_REQUEST_DURATION: z.coerce
|
||||
@@ -46,69 +47,110 @@ export const AdvancedAuthSettingsForm = () => {
|
||||
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
const [isUpdatingRequestDurationForm, setIsUpdatingRequestDurationForm] = useState(false)
|
||||
const [isUpdatingDatabaseForm, setIsUpdatingDatabaseForm] = useState(false)
|
||||
|
||||
const {
|
||||
data: authConfig,
|
||||
error: authConfigError,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
|
||||
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
|
||||
orgSlug: organization?.slug,
|
||||
})
|
||||
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
API_MAX_REQUEST_DURATION: authConfig?.API_MAX_REQUEST_DURATION ?? undefined,
|
||||
DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE ?? '',
|
||||
}),
|
||||
[authConfig]
|
||||
)
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: defaultValues as any,
|
||||
})
|
||||
const { isDirty } = form.formState
|
||||
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const isTeamsEnterprisePlan =
|
||||
isSuccessSubscription && subscription.plan.id !== 'free' && subscription.plan.id !== 'pro'
|
||||
isSuccessSubscription && subscription?.plan.id !== 'free' && subscription?.plan.id !== 'pro'
|
||||
const promptTeamsEnterpriseUpgrade =
|
||||
IS_PLATFORM && isSuccessSubscription && !isTeamsEnterprisePlan
|
||||
|
||||
const resetForm = () => {
|
||||
form.reset(defaultValues as any)
|
||||
}
|
||||
const requestDurationForm = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
API_MAX_REQUEST_DURATION: FormSchema.shape.API_MAX_REQUEST_DURATION,
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
API_MAX_REQUEST_DURATION: 10,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = (data) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
const databaseForm = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
DB_MAX_POOL_SIZE: FormSchema.shape.DB_MAX_POOL_SIZE,
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
DB_MAX_POOL_SIZE: '',
|
||||
},
|
||||
})
|
||||
|
||||
const { API_MAX_REQUEST_DURATION, DB_MAX_POOL_SIZE, ...config } = data
|
||||
const payload = {
|
||||
...config,
|
||||
...(isTeamsEnterprisePlan ? { API_MAX_REQUEST_DURATION } : {}),
|
||||
DB_MAX_POOL_SIZE,
|
||||
useEffect(() => {
|
||||
if (authConfig) {
|
||||
if (!isUpdatingRequestDurationForm) {
|
||||
requestDurationForm.reset({
|
||||
API_MAX_REQUEST_DURATION: authConfig?.API_MAX_REQUEST_DURATION ?? 10,
|
||||
})
|
||||
}
|
||||
|
||||
if (!isUpdatingDatabaseForm) {
|
||||
databaseForm.reset({
|
||||
DB_MAX_POOL_SIZE:
|
||||
authConfig?.DB_MAX_POOL_SIZE !== null ? String(authConfig?.DB_MAX_POOL_SIZE) : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [authConfig, isUpdatingRequestDurationForm, isUpdatingDatabaseForm])
|
||||
|
||||
const onSubmitRequestDurationForm = (values: any) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!isTeamsEnterprisePlan) return
|
||||
|
||||
setIsUpdatingRequestDurationForm(true)
|
||||
|
||||
updateAuthConfig(
|
||||
// @ts-expect-error
|
||||
{ projectRef: projectRef, config: payload },
|
||||
{ projectRef: projectRef, config: values },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update request duration settings: ${error?.message}`)
|
||||
setIsUpdatingRequestDurationForm(false)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully updated settings')
|
||||
form.reset({ ...defaultValues, ...data })
|
||||
toast.success('Successfully updated request duration settings')
|
||||
setIsUpdatingRequestDurationForm(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// reset form with the current values
|
||||
if (isSuccess) resetForm()
|
||||
}, [defaultValues, isSuccess])
|
||||
const onSubmitDatabaseForm = (values: any) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
|
||||
setIsUpdatingDatabaseForm(true)
|
||||
|
||||
const config = {
|
||||
DB_MAX_POOL_SIZE: values.DB_MAX_POOL_SIZE,
|
||||
}
|
||||
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef, config },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update database connection settings: ${error?.message}`)
|
||||
setIsUpdatingDatabaseForm(false)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully updated database connection settings')
|
||||
setIsUpdatingDatabaseForm(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
@@ -127,88 +169,126 @@ export const AdvancedAuthSettingsForm = () => {
|
||||
return (
|
||||
<>
|
||||
{promptTeamsEnterpriseUpgrade && (
|
||||
<UpgradeToPro
|
||||
primaryText="Upgrade to Team or Enterprise"
|
||||
secondaryText="Advanced Auth server settings are only available on the Team Plan and up."
|
||||
buttonText="Upgrade to Team"
|
||||
/>
|
||||
<div className="my-4">
|
||||
<UpgradeToPro
|
||||
primaryText="Upgrade to Team or Enterprise"
|
||||
secondaryText="Advanced Auth server settings are only available on the Team Plan and up."
|
||||
buttonText="Upgrade to Team"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormPanel
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isUpdatingConfig}
|
||||
hasChanges={isDirty}
|
||||
handleReset={() => resetForm()}
|
||||
disabled={!canUpdateConfig}
|
||||
helper={
|
||||
!canUpdateConfig
|
||||
? 'You need additional permissions to update authentication settings'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Request Duration</ScaffoldSectionTitle>
|
||||
|
||||
<Form_Shadcn_ {...requestDurationForm}>
|
||||
<form
|
||||
onSubmit={requestDurationForm.handleSubmit(onSubmitRequestDurationForm)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormSection header={<FormSectionLabel>Max Request Duration</FormSectionLabel>}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
control={requestDurationForm.control}
|
||||
name="API_MAX_REQUEST_DURATION"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Maximum time allowed for an Auth request to last"
|
||||
description="Number of seconds to wait for an Auth request to complete before canceling it. In certain high-load situations setting a larger or smaller value can be used to control load-shedding. Recommended: 10 seconds."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<div className="relative">
|
||||
<Input_Shadcn_
|
||||
type="number"
|
||||
disabled={!canUpdateConfig || promptTeamsEnterpriseUpgrade}
|
||||
placeholder="10"
|
||||
{...field}
|
||||
/>
|
||||
<span className="text-sm text-foreground-lighter absolute top-1.5 right-3">
|
||||
seconds
|
||||
</span>
|
||||
<PrePostTab postTab="seconds">
|
||||
<Input_Shadcn_
|
||||
type="number"
|
||||
min={5}
|
||||
max={30}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || promptTeamsEnterpriseUpgrade}
|
||||
/>
|
||||
</PrePostTab>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</CardContent>
|
||||
|
||||
<FormSection header={<FormSectionLabel>Auth Database Connections</FormSectionLabel>}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{requestDurationForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => requestDurationForm.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={
|
||||
!canUpdateConfig ||
|
||||
isUpdatingRequestDurationForm ||
|
||||
!requestDurationForm.formState.isDirty ||
|
||||
promptTeamsEnterpriseUpgrade
|
||||
}
|
||||
loading={isUpdatingRequestDurationForm}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</ScaffoldSection>
|
||||
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Auth Database Connections</ScaffoldSectionTitle>
|
||||
|
||||
<Form_Shadcn_ {...databaseForm}>
|
||||
<form onSubmit={databaseForm.handleSubmit(onSubmitDatabaseForm)} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
control={databaseForm.control}
|
||||
name="DB_MAX_POOL_SIZE"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Max Direct Auth Connections"
|
||||
description="Auth will take up no more than this number of connections from the total number of available connections to serve requests. These connections are not reserved, so when unused they are released. Defaults to 10 connections."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
type="number"
|
||||
disabled={!canUpdateConfig || promptTeamsEnterpriseUpgrade}
|
||||
placeholder="10"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
disabled={!canUpdateConfig || promptTeamsEnterpriseUpgrade}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{databaseForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => databaseForm.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={
|
||||
!canUpdateConfig || isUpdatingDatabaseForm || !databaseForm.formState.isDirty
|
||||
}
|
||||
loading={isUpdatingDatabaseForm}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</ScaffoldSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { CreateHookSheet } from './CreateHookSheet'
|
||||
import { HookCard } from './HookCard'
|
||||
import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants'
|
||||
import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
|
||||
export const HooksListing = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -68,8 +69,11 @@ export const HooksListing = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<FormHeader title="All hooks" actions={<AddHookDropdown onSelectHook={setSelectedHook} />} />
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<ScaffoldSectionTitle>All hooks</ScaffoldSectionTitle>
|
||||
<AddHookDropdown onSelectHook={setSelectedHook} />
|
||||
</div>
|
||||
|
||||
{hooks.filter((h) => isValidHook(h)).length === 0 && (
|
||||
<div
|
||||
@@ -155,6 +159,6 @@ export const HooksListing = () => {
|
||||
)}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
+179
-129
@@ -1,30 +1,40 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { boolean, number, object, string } from 'yup'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { FormActions } from 'components/ui/Forms/FormActions'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { FormPanel } from 'components/ui/Forms/FormPanel'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Toggle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
Switch,
|
||||
WarningIcon,
|
||||
PrePostTab,
|
||||
} from 'ui'
|
||||
import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
|
||||
import FormField from '../AuthProvidersForm/FormField'
|
||||
|
||||
const schema = object({
|
||||
DISABLE_SIGNUP: boolean().required(),
|
||||
@@ -51,8 +61,6 @@ const schema = object({
|
||||
PASSWORD_HIBP_ENABLED: boolean(),
|
||||
})
|
||||
|
||||
const formId = 'auth-config-protection-settings'
|
||||
|
||||
const ProtectionAuthSettingsForm = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
@@ -60,32 +68,57 @@ const ProtectionAuthSettingsForm = () => {
|
||||
error: authConfigError,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const [isUpdatingProtection, setIsUpdatingProtection] = useState(false)
|
||||
const [hidden, setHidden] = useState(true)
|
||||
|
||||
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,
|
||||
SECURITY_CAPTCHA_ENABLED: authConfig?.SECURITY_CAPTCHA_ENABLED || false,
|
||||
SECURITY_CAPTCHA_SECRET: authConfig?.SECURITY_CAPTCHA_SECRET || '',
|
||||
SECURITY_CAPTCHA_PROVIDER: authConfig?.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
|
||||
SESSIONS_TIMEBOX: authConfig?.SESSIONS_TIMEBOX || 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0,
|
||||
SESSIONS_SINGLE_PER_USER: authConfig?.SESSIONS_SINGLE_PER_USER || false,
|
||||
PASSWORD_MIN_LENGTH: authConfig?.PASSWORD_MIN_LENGTH || 6,
|
||||
PASSWORD_REQUIRED_CHARACTERS:
|
||||
authConfig?.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
|
||||
PASSWORD_HIBP_ENABLED: authConfig?.PASSWORD_HIBP_ENABLED || false,
|
||||
}
|
||||
const protectionForm = useForm({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
DISABLE_SIGNUP: true,
|
||||
EXTERNAL_ANONYMOUS_USERS_ENABLED: false,
|
||||
SECURITY_MANUAL_LINKING_ENABLED: false,
|
||||
SITE_URL: '',
|
||||
SECURITY_CAPTCHA_ENABLED: false,
|
||||
SECURITY_CAPTCHA_SECRET: '',
|
||||
SECURITY_CAPTCHA_PROVIDER: 'hcaptcha',
|
||||
SESSIONS_TIMEBOX: 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: 0,
|
||||
SESSIONS_SINGLE_PER_USER: false,
|
||||
PASSWORD_MIN_LENGTH: 6,
|
||||
PASSWORD_REQUIRED_CHARACTERS: NO_REQUIRED_CHARACTERS,
|
||||
PASSWORD_HIBP_ENABLED: false,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (authConfig && !isUpdatingProtection) {
|
||||
protectionForm.reset({
|
||||
DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP,
|
||||
EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED || false,
|
||||
SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false,
|
||||
SITE_URL: authConfig.SITE_URL || '',
|
||||
SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED || false,
|
||||
SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
|
||||
SECURITY_CAPTCHA_PROVIDER: authConfig.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
|
||||
SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
|
||||
SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
|
||||
PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
|
||||
PASSWORD_REQUIRED_CHARACTERS:
|
||||
authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
|
||||
PASSWORD_HIBP_ENABLED: authConfig.PASSWORD_HIBP_ENABLED || false,
|
||||
})
|
||||
}
|
||||
}, [authConfig, isUpdatingProtection])
|
||||
|
||||
const onSubmitProtection = (values: any) => {
|
||||
setIsUpdatingProtection(true)
|
||||
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = { ...values }
|
||||
payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP
|
||||
// The backend uses empty string to represent no required characters in the password
|
||||
@@ -97,11 +130,12 @@ const ProtectionAuthSettingsForm = () => {
|
||||
{ projectRef: projectRef!, config: payload },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update settings: ${error?.message}`)
|
||||
toast.error(`Failed to update settings: ${error?.message}`)
|
||||
setIsUpdatingProtection(false)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully updated settings`)
|
||||
resetForm({ values: values, initialValues: values })
|
||||
toast.success('Successfully updated settings')
|
||||
setIsUpdatingProtection(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -122,106 +156,122 @@ const ProtectionAuthSettingsForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormHeader
|
||||
title="Bot and Abuse Protection"
|
||||
description="Configure protection settings for your application."
|
||||
/>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Bot and Abuse Protection</ScaffoldSectionTitle>
|
||||
|
||||
<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])
|
||||
<Form_Shadcn_ {...protectionForm}>
|
||||
<form onSubmit={protectionForm.handleSubmit(onSubmitProtection)} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={protectionForm.control}
|
||||
name="SECURITY_CAPTCHA_ENABLED"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Enable Captcha protection"
|
||||
description="Protect authentication endpoints from bots and abuse."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isUpdatingConfig}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
disabled={!canUpdateConfig}
|
||||
helper={
|
||||
!canUpdateConfig
|
||||
? 'You need additional permissions to update authentication settings'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection header={<FormSectionLabel>Bot and Abuse Protection</FormSectionLabel>}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Toggle
|
||||
id="SECURITY_CAPTCHA_ENABLED"
|
||||
size="small"
|
||||
label="Enable Captcha protection"
|
||||
layout="flex"
|
||||
descriptionText="Protect authentication endpoints from bots and abuse."
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
{values.SECURITY_CAPTCHA_ENABLED && (
|
||||
<>
|
||||
<FormField
|
||||
name="SECURITY_CAPTCHA_PROVIDER"
|
||||
properties={{
|
||||
type: 'select',
|
||||
title: 'Choose Captcha Provider',
|
||||
description: '',
|
||||
enum: [
|
||||
{
|
||||
label: 'hCaptcha',
|
||||
value: 'hcaptcha',
|
||||
icon: 'hcaptcha-icon.png',
|
||||
},
|
||||
{
|
||||
label: 'Turnstile by Cloudflare',
|
||||
value: 'turnstile',
|
||||
icon: 'cloudflare-icon.png',
|
||||
},
|
||||
],
|
||||
}}
|
||||
formValues={values}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
<Input
|
||||
id="SECURITY_CAPTCHA_SECRET"
|
||||
type={hidden ? 'password' : 'text'}
|
||||
size="small"
|
||||
label="Captcha secret"
|
||||
descriptionText="Obtain this secret from the provider."
|
||||
disabled={!canUpdateConfig}
|
||||
actions={
|
||||
<Button
|
||||
icon={hidden ? <Eye /> : <EyeOff />}
|
||||
type="default"
|
||||
onClick={() => setHidden(!hidden)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
{protectionForm.watch('SECURITY_CAPTCHA_ENABLED') && (
|
||||
<>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={protectionForm.control}
|
||||
name="SECURITY_CAPTCHA_PROVIDER"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="flex-row-reverse" label="Choose Captcha Provider">
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={!canUpdateConfig}
|
||||
>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Select provider" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="hcaptcha">hCaptcha</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="turnstile">
|
||||
Turnstile by Cloudflare
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</div>
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={protectionForm.control}
|
||||
name="SECURITY_CAPTCHA_SECRET"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Captcha secret"
|
||||
description="Obtain this secret from the provider."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<div className="flex items-center gap-2">
|
||||
<PrePostTab
|
||||
postTab={
|
||||
<Button
|
||||
type="text"
|
||||
className="p-0"
|
||||
onClick={() => setHidden(!hidden)}
|
||||
icon={hidden ? <Eye /> : <EyeOff />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type={hidden ? 'password' : 'text'}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</PrePostTab>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{protectionForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => protectionForm.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={
|
||||
!canUpdateConfig || isUpdatingProtection || !protectionForm.formState.isDirty
|
||||
}
|
||||
loading={isUpdatingProtection}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +1,56 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { AdvancedAuthSettingsForm } from 'components/interfaces/Auth/AdvancedAuthSettingsForm'
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
// TODO: check if these permissions cover third party auth as well
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldHeader>
|
||||
<ScaffoldTitle>Advanced</ScaffoldTitle>
|
||||
</ScaffoldHeader>
|
||||
</ScaffoldContainer>
|
||||
<ScaffoldContainer className="flex flex-col gap-10" bottomPadding>
|
||||
{!isPermissionsLoaded ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : !canReadAuthSettings ? (
|
||||
<NoPermission isFullPage resourceText="access your project's authentication settings" />
|
||||
) : (
|
||||
<AdvancedAuthSettingsForm />
|
||||
)}
|
||||
</ScaffoldContainer>
|
||||
</>
|
||||
)
|
||||
interface AdvancedLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => {
|
||||
export const AdvancedLayout = ({ children }: AdvancedLayoutProps) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout>
|
||||
<PageLayout title="Advanced" subtitle="Configure advanced authentication server settings">
|
||||
{children}
|
||||
</PageLayout>
|
||||
</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
export default PageLayout
|
||||
|
||||
const AdvancedPage: NextPageWithLayout = () => {
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
|
||||
if (isPermissionsLoaded && !canReadAuthSettings) {
|
||||
return <NoPermission isFullPage resourceText="access your project's authentication settings" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
{!isPermissionsLoaded ? (
|
||||
<div className="mt-12">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : (
|
||||
<AdvancedAuthSettingsForm />
|
||||
)}
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
AdvancedPage.getLayout = (page) => <AdvancedLayout>{page}</AdvancedLayout>
|
||||
|
||||
export default AdvancedPage
|
||||
@@ -1,48 +1,49 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { HooksListing } from 'components/interfaces/Auth/Hooks/HooksListing'
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
import {
|
||||
ScaffoldHeader,
|
||||
ScaffoldContainer,
|
||||
ScaffoldTitle,
|
||||
ScaffoldDescription,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
|
||||
interface HooksLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const HooksLayout = ({ children }: HooksLayoutProps) => {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>
|
||||
<PageLayout
|
||||
title="Auth Hooks"
|
||||
subtitle="Use Postgres functions or HTTP endpoints to customize the behavior of Supabase Auth to meet your needs"
|
||||
>
|
||||
{children}
|
||||
</PageLayout>
|
||||
</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const Hooks: NextPageWithLayout = () => {
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
|
||||
if (isPermissionsLoaded && !canReadAuthSettings) {
|
||||
return <NoPermission isFullPage resourceText="access your project's auth hooks" />
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<ScaffoldHeader className="pb-0">
|
||||
<ScaffoldContainer id="auth-page-top">
|
||||
<ScaffoldTitle>Auth Hooks</ScaffoldTitle>
|
||||
<ScaffoldDescription>
|
||||
Use Postgres functions or HTTP endpoints to customize the behavior of Supabase Auth to
|
||||
meet your needs
|
||||
</ScaffoldDescription>
|
||||
</ScaffoldContainer>
|
||||
</ScaffoldHeader>
|
||||
|
||||
<ScaffoldContainer className="my-8 space-y-8">
|
||||
<HooksListing />
|
||||
</ScaffoldContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<HooksListing />
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
Hooks.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
Hooks.getLayout = (page) => <HooksLayout>{page}</HooksLayout>
|
||||
|
||||
export default Hooks
|
||||
@@ -1,45 +1,59 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
AuthProvidersForm,
|
||||
ProtectionAuthSettingsForm,
|
||||
ThirdPartyAuthForm,
|
||||
} from 'components/interfaces/Auth'
|
||||
import { useParams } from 'common'
|
||||
import { ProtectionAuthSettingsForm } from 'components/interfaces/Auth'
|
||||
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { ScaffoldHeader, ScaffoldContainer, ScaffoldTitle } from 'components/layouts/Scaffold'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
|
||||
if (isPermissionsLoaded && !canReadAuthSettings) {
|
||||
return <NoPermission isFullPage resourceText="access your project's auth provider settings" />
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<ScaffoldHeader className="pb-0">
|
||||
<ScaffoldContainer id="auth-page-top">
|
||||
<ScaffoldTitle>Attack Protection</ScaffoldTitle>
|
||||
</ScaffoldContainer>
|
||||
</ScaffoldHeader>
|
||||
|
||||
<ScaffoldContainer className="my-8 space-y-8">
|
||||
<ProtectionAuthSettingsForm />
|
||||
</ScaffoldContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
interface ProtectionLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => {
|
||||
export const ProtectionLayout = ({ children }: ProtectionLayoutProps) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
<AuthLayout>
|
||||
<PageLayout
|
||||
title="Attack Protection"
|
||||
subtitle="Configure security settings to protect your project from attacks"
|
||||
>
|
||||
{children}
|
||||
</PageLayout>
|
||||
</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
export default PageLayout
|
||||
|
||||
const ProtectionPage: NextPageWithLayout = () => {
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
|
||||
if (isPermissionsLoaded && !canReadAuthSettings) {
|
||||
return <NoPermission isFullPage resourceText="access your project's auth provider settings" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
{!isPermissionsLoaded ? (
|
||||
<div className="mt-12">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : (
|
||||
<ProtectionAuthSettingsForm />
|
||||
)}
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
ProtectionPage.getLayout = (page) => <ProtectionLayout>{page}</ProtectionLayout>
|
||||
|
||||
export default ProtectionPage
|
||||
@@ -45,7 +45,7 @@ const ContainerVariants = cva('relative grid gap-10', {
|
||||
horizontal: 'flex flex-col gap-2 md:grid md:grid-cols-12',
|
||||
vertical: 'flex flex-col gap-3',
|
||||
flex: 'flex flex-row gap-3',
|
||||
'flex-row-reverse': 'flex flex-row gap-3 flex-row-reverse justify-between',
|
||||
'flex-row-reverse': 'flex flex-row gap-6 flex-row-reverse justify-between',
|
||||
},
|
||||
flex: {
|
||||
true: '',
|
||||
@@ -233,7 +233,7 @@ const FlexContainer = cva('', {
|
||||
},
|
||||
{
|
||||
layout: 'flex-row-reverse',
|
||||
className: 'flex flex-col justify-center items-end',
|
||||
className: 'flex flex-col justify-center items-end shrink-0',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -58,6 +58,8 @@ export * from './src/components/Upload'
|
||||
export * from './src/components/Form'
|
||||
export * from './src/components/ExpandingTextArea'
|
||||
|
||||
export * from './src/components/PrePostTab'
|
||||
|
||||
// layout
|
||||
export * from './src/components/LoadingLine'
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { forwardRef, ReactNode, PropsWithChildren, HTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export interface PrePostTabProps extends HTMLAttributes<HTMLDivElement> {
|
||||
preTab?: ReactNode | string
|
||||
postTab?: ReactNode | string
|
||||
}
|
||||
|
||||
export const PrePostTab = forwardRef<HTMLDivElement, PropsWithChildren<PrePostTabProps>>(
|
||||
({ preTab, postTab, children, className, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} {...props} className={cn('flex -space-x-px', className)}>
|
||||
{preTab && (
|
||||
<div className="border border-strong bg-surface-300 rounded-l-md px-3 flex items-center justify-center">
|
||||
<span className="text-foreground-lighter text-xs font-mono">{preTab}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn('flex-1', {
|
||||
'[&_input]:rounded-l-none [&_select]:rounded-l-none [&_textarea]:rounded-l-none':
|
||||
Boolean(preTab),
|
||||
'[&_input]:rounded-r-none [&_select]:rounded-r-none [&_textarea]:rounded-r-none':
|
||||
Boolean(postTab),
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{postTab && (
|
||||
<div className="border border-strong bg-surface-300 rounded-r-md px-3 flex items-center justify-center">
|
||||
<span className="text-foreground-lighter text-xs font-mono">{postTab}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PrePostTab } from './PrePostTab'
|
||||
export type { PrePostTabProps } from './PrePostTab'
|
||||
Reference in new issue
Block a user