|
|
@@ -1,12 +1,19 @@
|
|
|
-import { useEffect, useState } from 'react'
|
|
|
+import { useEffect, useState, type ReactNode } from 'react'
|
|
|
import { useForm } from 'react-hook-form'
|
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
|
import { useQuery } from '@tanstack/react-query'
|
|
|
-import { ChevronDown } from 'lucide-react'
|
|
|
+import {
|
|
|
+ ChevronDown,
|
|
|
+ KeyRound,
|
|
|
+ Settings2,
|
|
|
+ WalletCards,
|
|
|
+ type LucideIcon,
|
|
|
+} from 'lucide-react'
|
|
|
import { useTranslation } from 'react-i18next'
|
|
|
import { toast } from 'sonner'
|
|
|
import { getUserModels, getUserGroups } from '@/lib/api'
|
|
|
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
|
|
|
+import { cn } from '@/lib/utils'
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
import {
|
|
|
Collapsible,
|
|
|
@@ -59,6 +66,34 @@ type ApiKeyMutateDrawerProps = {
|
|
|
side?: 'left' | 'right'
|
|
|
}
|
|
|
|
|
|
+type ApiKeyFormSectionProps = {
|
|
|
+ title: string
|
|
|
+ description: string
|
|
|
+ icon: LucideIcon
|
|
|
+ children: ReactNode
|
|
|
+}
|
|
|
+
|
|
|
+function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
|
|
|
+ const Icon = props.icon
|
|
|
+
|
|
|
+ return (
|
|
|
+ <section className='bg-card rounded-lg border'>
|
|
|
+ <div className='flex items-center gap-3 border-b px-4 py-3'>
|
|
|
+ <div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
|
|
|
+ <Icon className='size-5' />
|
|
|
+ </div>
|
|
|
+ <div className='min-w-0'>
|
|
|
+ <h3 className='text-sm font-medium leading-none'>{props.title}</h3>
|
|
|
+ <p className='text-muted-foreground mt-1 text-xs'>
|
|
|
+ {props.description}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className='space-y-4 p-4'>{props.children}</div>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
export function ApiKeysMutateDrawer({
|
|
|
open,
|
|
|
onOpenChange,
|
|
|
@@ -201,6 +236,8 @@ export function ApiKeysMutateDrawer({
|
|
|
const quotaPlaceholder = tokensOnly
|
|
|
? t('Enter quota in tokens')
|
|
|
: t('Enter quota in {{currency}}', { currency: currencyLabel })
|
|
|
+ const selectedGroup = form.watch('group')
|
|
|
+ const unlimitedQuota = form.watch('unlimited_quota')
|
|
|
|
|
|
return (
|
|
|
<Sheet
|
|
|
@@ -214,10 +251,10 @@ export function ApiKeysMutateDrawer({
|
|
|
>
|
|
|
<SheetContent
|
|
|
side={side}
|
|
|
- className='flex w-full flex-col sm:max-w-[600px]'
|
|
|
+ className='bg-background flex w-full gap-0 overflow-hidden p-0 sm:max-w-[620px]'
|
|
|
>
|
|
|
- <SheetHeader className='text-start'>
|
|
|
- <SheetTitle>
|
|
|
+ <SheetHeader className='bg-background border-b px-5 py-4 text-start'>
|
|
|
+ <SheetTitle className='text-lg'>
|
|
|
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
|
|
</SheetTitle>
|
|
|
<SheetDescription>
|
|
|
@@ -231,278 +268,314 @@ export function ApiKeysMutateDrawer({
|
|
|
<form
|
|
|
id='api-key-form'
|
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
|
- className='flex-1 space-y-6 overflow-y-auto px-4'
|
|
|
+ className='flex-1 space-y-4 overflow-y-auto px-4 py-4'
|
|
|
>
|
|
|
- <FormField
|
|
|
- control={form.control}
|
|
|
- name='name'
|
|
|
- render={({ field }) => (
|
|
|
- <FormItem>
|
|
|
- <FormLabel>{t('Name')}</FormLabel>
|
|
|
- <FormControl>
|
|
|
- <Input {...field} placeholder={t('Enter a name')} />
|
|
|
- </FormControl>
|
|
|
- <FormMessage />
|
|
|
- </FormItem>
|
|
|
- )}
|
|
|
- />
|
|
|
-
|
|
|
- <FormField
|
|
|
- control={form.control}
|
|
|
- name='group'
|
|
|
- render={({ field }) => (
|
|
|
- <FormItem>
|
|
|
- <FormLabel>{t('Group')}</FormLabel>
|
|
|
- <FormControl>
|
|
|
- <ApiKeyGroupCombobox
|
|
|
- options={groups}
|
|
|
- value={field.value}
|
|
|
- onValueChange={field.onChange}
|
|
|
- placeholder={t('Select a group')}
|
|
|
- />
|
|
|
- </FormControl>
|
|
|
- <FormDescription>
|
|
|
- {t('Auto group enables circuit breaker mechanism')}
|
|
|
- </FormDescription>
|
|
|
- <FormMessage />
|
|
|
- </FormItem>
|
|
|
- )}
|
|
|
- />
|
|
|
-
|
|
|
- {form.watch('group') === 'auto' && (
|
|
|
+ <ApiKeyFormSection
|
|
|
+ title={t('Basic Information')}
|
|
|
+ description={t('Set API key basic information')}
|
|
|
+ icon={KeyRound}
|
|
|
+ >
|
|
|
<FormField
|
|
|
control={form.control}
|
|
|
- name='cross_group_retry'
|
|
|
+ name='name'
|
|
|
render={({ field }) => (
|
|
|
- <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
|
|
- <div className='space-y-0.5'>
|
|
|
- <FormLabel className='text-base'>
|
|
|
- {t('Cross-group retry')}
|
|
|
- </FormLabel>
|
|
|
- <FormDescription>
|
|
|
- {t(
|
|
|
- 'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
|
|
|
- )}
|
|
|
- </FormDescription>
|
|
|
- </div>
|
|
|
+ <FormItem>
|
|
|
+ <FormLabel>{t('Name')}</FormLabel>
|
|
|
<FormControl>
|
|
|
- <Switch
|
|
|
- checked={!!field.value}
|
|
|
- onCheckedChange={field.onChange}
|
|
|
+ <Input
|
|
|
+ {...field}
|
|
|
+ placeholder={t('Enter a name')}
|
|
|
/>
|
|
|
</FormControl>
|
|
|
+ <FormMessage />
|
|
|
</FormItem>
|
|
|
)}
|
|
|
/>
|
|
|
- )}
|
|
|
-
|
|
|
- <FormField
|
|
|
- control={form.control}
|
|
|
- name='unlimited_quota'
|
|
|
- render={({ field }) => (
|
|
|
- <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
|
|
- <div className='space-y-0.5'>
|
|
|
- <FormLabel className='text-base'>
|
|
|
- {t('Unlimited Quota')}
|
|
|
- </FormLabel>
|
|
|
- <FormDescription>
|
|
|
- {t('Enable unlimited quota for this API key')}
|
|
|
- </FormDescription>
|
|
|
- </div>
|
|
|
- <FormControl>
|
|
|
- <Switch
|
|
|
- checked={field.value}
|
|
|
- onCheckedChange={field.onChange}
|
|
|
- />
|
|
|
- </FormControl>
|
|
|
- </FormItem>
|
|
|
- )}
|
|
|
- />
|
|
|
|
|
|
- {!form.watch('unlimited_quota') && (
|
|
|
<FormField
|
|
|
control={form.control}
|
|
|
- name='remain_quota_dollars'
|
|
|
+ name='group'
|
|
|
render={({ field }) => (
|
|
|
<FormItem>
|
|
|
- <FormLabel>{quotaLabel}</FormLabel>
|
|
|
+ <FormLabel>{t('Group')}</FormLabel>
|
|
|
<FormControl>
|
|
|
- <Input
|
|
|
- {...field}
|
|
|
- type='number'
|
|
|
- step={tokensOnly ? 1 : 0.01}
|
|
|
- placeholder={quotaPlaceholder}
|
|
|
- onChange={(e) =>
|
|
|
- field.onChange(parseFloat(e.target.value) || 0)
|
|
|
- }
|
|
|
+ <ApiKeyGroupCombobox
|
|
|
+ options={groups}
|
|
|
+ value={field.value}
|
|
|
+ onValueChange={field.onChange}
|
|
|
+ placeholder={t('Select a group')}
|
|
|
/>
|
|
|
</FormControl>
|
|
|
- <FormDescription>
|
|
|
- {tokensOnly
|
|
|
- ? t('Enter the quota amount in tokens')
|
|
|
- : t('Enter the quota amount in {{currency}}', {
|
|
|
- currency: currencyLabel,
|
|
|
- })}
|
|
|
- </FormDescription>
|
|
|
<FormMessage />
|
|
|
</FormItem>
|
|
|
)}
|
|
|
/>
|
|
|
- )}
|
|
|
|
|
|
- <FormField
|
|
|
- control={form.control}
|
|
|
- name='expired_time'
|
|
|
- render={({ field }) => (
|
|
|
- <FormItem>
|
|
|
- <FormLabel>{t('Expiration Time')}</FormLabel>
|
|
|
- <div className='space-y-2'>
|
|
|
- <FormControl>
|
|
|
- <DateTimePicker
|
|
|
- value={field.value}
|
|
|
- onChange={field.onChange}
|
|
|
- placeholder={t('Never expires')}
|
|
|
- />
|
|
|
- </FormControl>
|
|
|
- <div className='flex gap-2'>
|
|
|
- <Button
|
|
|
- type='button'
|
|
|
- variant='outline'
|
|
|
- size='sm'
|
|
|
- onClick={() => handleSetExpiry(0, 0, 0)}
|
|
|
- >
|
|
|
- {t('Never')}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- type='button'
|
|
|
- variant='outline'
|
|
|
- size='sm'
|
|
|
- onClick={() => handleSetExpiry(1, 0, 0)}
|
|
|
- >
|
|
|
- {t('1 Month')}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- type='button'
|
|
|
- variant='outline'
|
|
|
- size='sm'
|
|
|
- onClick={() => handleSetExpiry(0, 1, 0)}
|
|
|
- >
|
|
|
- {t('1 Day')}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- type='button'
|
|
|
- variant='outline'
|
|
|
- size='sm'
|
|
|
- onClick={() => handleSetExpiry(0, 0, 1)}
|
|
|
- >
|
|
|
- {t('1 Hour')}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <FormDescription>
|
|
|
- {t('Leave empty for never expires')}
|
|
|
- </FormDescription>
|
|
|
- <FormMessage />
|
|
|
- </FormItem>
|
|
|
+ {selectedGroup === 'auto' && (
|
|
|
+ <FormField
|
|
|
+ control={form.control}
|
|
|
+ name='cross_group_retry'
|
|
|
+ render={({ field }) => (
|
|
|
+ <FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
|
|
|
+ <div className='space-y-0.5'>
|
|
|
+ <FormLabel className='text-sm'>
|
|
|
+ {t('Cross-group retry')}
|
|
|
+ </FormLabel>
|
|
|
+ <FormDescription className='text-xs'>
|
|
|
+ {t(
|
|
|
+ 'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
|
|
|
+ )}
|
|
|
+ </FormDescription>
|
|
|
+ </div>
|
|
|
+ <FormControl>
|
|
|
+ <Switch
|
|
|
+ checked={!!field.value}
|
|
|
+ onCheckedChange={field.onChange}
|
|
|
+ />
|
|
|
+ </FormControl>
|
|
|
+ </FormItem>
|
|
|
+ )}
|
|
|
+ />
|
|
|
)}
|
|
|
- />
|
|
|
|
|
|
- {!isUpdate && (
|
|
|
<FormField
|
|
|
control={form.control}
|
|
|
- name='tokenCount'
|
|
|
+ name='expired_time'
|
|
|
render={({ field }) => (
|
|
|
<FormItem>
|
|
|
- <FormLabel>{t('Quantity')}</FormLabel>
|
|
|
- <FormControl>
|
|
|
- <Input
|
|
|
- {...field}
|
|
|
- type='number'
|
|
|
- min='1'
|
|
|
- placeholder={t('Number of keys to create')}
|
|
|
- onChange={(e) =>
|
|
|
- field.onChange(parseInt(e.target.value, 10) || 1)
|
|
|
- }
|
|
|
- />
|
|
|
- </FormControl>
|
|
|
- <FormDescription>
|
|
|
- {t(
|
|
|
- 'Create multiple API keys at once (random suffix will be added to names)'
|
|
|
- )}
|
|
|
- </FormDescription>
|
|
|
+ <FormLabel>{t('Expiration Time')}</FormLabel>
|
|
|
+ <div className='grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
|
|
|
+ <FormControl>
|
|
|
+ <DateTimePicker
|
|
|
+ value={field.value}
|
|
|
+ onChange={field.onChange}
|
|
|
+ placeholder={t('Never expires')}
|
|
|
+ className='min-w-0'
|
|
|
+ />
|
|
|
+ </FormControl>
|
|
|
+ <div className='grid grid-cols-4 gap-2 sm:flex'>
|
|
|
+ <Button
|
|
|
+ type='button'
|
|
|
+ variant='outline'
|
|
|
+ size='sm'
|
|
|
+ className='px-3'
|
|
|
+ onClick={() => handleSetExpiry(0, 0, 0)}
|
|
|
+ >
|
|
|
+ {t('Never')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type='button'
|
|
|
+ variant='outline'
|
|
|
+ size='sm'
|
|
|
+ className='px-3'
|
|
|
+ onClick={() => handleSetExpiry(1, 0, 0)}
|
|
|
+ >
|
|
|
+ {t('1 Month')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type='button'
|
|
|
+ variant='outline'
|
|
|
+ size='sm'
|
|
|
+ className='px-3'
|
|
|
+ onClick={() => handleSetExpiry(0, 1, 0)}
|
|
|
+ >
|
|
|
+ {t('1 Day')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type='button'
|
|
|
+ variant='outline'
|
|
|
+ size='sm'
|
|
|
+ className='px-3'
|
|
|
+ onClick={() => handleSetExpiry(0, 0, 1)}
|
|
|
+ >
|
|
|
+ {t('1 Hour')}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
<FormMessage />
|
|
|
</FormItem>
|
|
|
)}
|
|
|
/>
|
|
|
- )}
|
|
|
|
|
|
- <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
|
- <CollapsibleTrigger asChild>
|
|
|
- <Button
|
|
|
- type='button'
|
|
|
- variant='outline'
|
|
|
- className='flex w-full items-center justify-between'
|
|
|
- >
|
|
|
- <span className='font-medium'>{t('Advanced Options')}</span>
|
|
|
- <ChevronDown
|
|
|
- className={`h-4 w-4 transition-transform duration-200 ${
|
|
|
- advancedOpen ? 'rotate-180' : ''
|
|
|
- }`}
|
|
|
- />
|
|
|
- </Button>
|
|
|
- </CollapsibleTrigger>
|
|
|
- <CollapsibleContent className='space-y-6 pt-6'>
|
|
|
+ {!isUpdate && (
|
|
|
<FormField
|
|
|
control={form.control}
|
|
|
- name='model_limits'
|
|
|
+ name='tokenCount'
|
|
|
render={({ field }) => (
|
|
|
<FormItem>
|
|
|
- <FormLabel>{t('Model Limits')}</FormLabel>
|
|
|
+ <FormLabel>{t('Quantity')}</FormLabel>
|
|
|
<FormControl>
|
|
|
- <MultiSelect
|
|
|
- options={models.map((m) => ({ label: m, value: m }))}
|
|
|
- selected={field.value}
|
|
|
- onChange={field.onChange}
|
|
|
- placeholder={t('Select models (empty for allow all)')}
|
|
|
+ <Input
|
|
|
+ {...field}
|
|
|
+ type='number'
|
|
|
+ min='1'
|
|
|
+ placeholder={t('Number of keys to create')}
|
|
|
+ onChange={(e) =>
|
|
|
+ field.onChange(parseInt(e.target.value, 10) || 1)
|
|
|
+ }
|
|
|
/>
|
|
|
</FormControl>
|
|
|
<FormDescription>
|
|
|
- {t('Limit which models can be used with this key')}
|
|
|
+ {t(
|
|
|
+ 'Create multiple API keys at once (random suffix will be added to names)'
|
|
|
+ )}
|
|
|
</FormDescription>
|
|
|
<FormMessage />
|
|
|
</FormItem>
|
|
|
)}
|
|
|
/>
|
|
|
+ )}
|
|
|
+ </ApiKeyFormSection>
|
|
|
|
|
|
+ <ApiKeyFormSection
|
|
|
+ title={t('Quota Settings')}
|
|
|
+ description={t('Set quota amount and limits')}
|
|
|
+ icon={WalletCards}
|
|
|
+ >
|
|
|
+ {!unlimitedQuota && (
|
|
|
<FormField
|
|
|
control={form.control}
|
|
|
- name='allow_ips'
|
|
|
+ name='remain_quota_dollars'
|
|
|
render={({ field }) => (
|
|
|
<FormItem>
|
|
|
- <FormLabel>{t('IP Whitelist (supports CIDR)')}</FormLabel>
|
|
|
+ <FormLabel>{quotaLabel}</FormLabel>
|
|
|
<FormControl>
|
|
|
- <Textarea
|
|
|
+ <Input
|
|
|
{...field}
|
|
|
- placeholder={t(
|
|
|
- 'One IP per line (empty for no restriction)'
|
|
|
- )}
|
|
|
- rows={3}
|
|
|
+ type='number'
|
|
|
+ step={tokensOnly ? 1 : 0.01}
|
|
|
+ placeholder={quotaPlaceholder}
|
|
|
+ onChange={(e) =>
|
|
|
+ field.onChange(parseFloat(e.target.value) || 0)
|
|
|
+ }
|
|
|
/>
|
|
|
</FormControl>
|
|
|
<FormDescription>
|
|
|
- {t(
|
|
|
- 'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
|
|
|
- )}
|
|
|
+ {tokensOnly
|
|
|
+ ? t('Enter the quota amount in tokens')
|
|
|
+ : t('Enter the quota amount in {{currency}}', {
|
|
|
+ currency: currencyLabel,
|
|
|
+ })}
|
|
|
</FormDescription>
|
|
|
<FormMessage />
|
|
|
</FormItem>
|
|
|
)}
|
|
|
/>
|
|
|
- </CollapsibleContent>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <FormField
|
|
|
+ control={form.control}
|
|
|
+ name='unlimited_quota'
|
|
|
+ render={({ field }) => (
|
|
|
+ <FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
|
|
|
+ <div className='space-y-0.5'>
|
|
|
+ <FormLabel className='text-sm'>
|
|
|
+ {t('Unlimited Quota')}
|
|
|
+ </FormLabel>
|
|
|
+ <FormDescription className='text-xs'>
|
|
|
+ {t('Enable unlimited quota for this API key')}
|
|
|
+ </FormDescription>
|
|
|
+ </div>
|
|
|
+ <FormControl>
|
|
|
+ <Switch
|
|
|
+ checked={field.value}
|
|
|
+ onCheckedChange={field.onChange}
|
|
|
+ />
|
|
|
+ </FormControl>
|
|
|
+ </FormItem>
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ </ApiKeyFormSection>
|
|
|
+
|
|
|
+ <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
|
+ <section className='bg-card rounded-lg border'>
|
|
|
+ <CollapsibleTrigger asChild>
|
|
|
+ <button
|
|
|
+ type='button'
|
|
|
+ className='hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors'
|
|
|
+ >
|
|
|
+ <div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
|
|
|
+ <Settings2 className='size-5' />
|
|
|
+ </div>
|
|
|
+ <div className='min-w-0 flex-1'>
|
|
|
+ <h3 className='text-sm font-medium leading-none'>
|
|
|
+ {t('Advanced Settings')}
|
|
|
+ </h3>
|
|
|
+ <p className='text-muted-foreground mt-1 text-xs'>
|
|
|
+ {t('Set API key access restrictions')}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <ChevronDown
|
|
|
+ className={cn(
|
|
|
+ 'text-muted-foreground size-4 shrink-0 transition-transform',
|
|
|
+ advancedOpen && 'rotate-180'
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ </CollapsibleTrigger>
|
|
|
+ <CollapsibleContent>
|
|
|
+ <div className='space-y-4 border-t p-4'>
|
|
|
+ <FormField
|
|
|
+ control={form.control}
|
|
|
+ name='model_limits'
|
|
|
+ render={({ field }) => (
|
|
|
+ <FormItem>
|
|
|
+ <FormLabel>{t('Model Limits')}</FormLabel>
|
|
|
+ <FormControl>
|
|
|
+ <MultiSelect
|
|
|
+ options={models.map((m) => ({
|
|
|
+ label: m,
|
|
|
+ value: m,
|
|
|
+ }))}
|
|
|
+ selected={field.value}
|
|
|
+ onChange={field.onChange}
|
|
|
+ placeholder={t(
|
|
|
+ 'Select models (empty for allow all)'
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ </FormControl>
|
|
|
+ <FormDescription>
|
|
|
+ {t('Limit which models can be used with this key')}
|
|
|
+ </FormDescription>
|
|
|
+ <FormMessage />
|
|
|
+ </FormItem>
|
|
|
+ )}
|
|
|
+ />
|
|
|
+
|
|
|
+ <FormField
|
|
|
+ control={form.control}
|
|
|
+ name='allow_ips'
|
|
|
+ render={({ field }) => (
|
|
|
+ <FormItem>
|
|
|
+ <FormLabel>
|
|
|
+ {t('IP Whitelist (supports CIDR)')}
|
|
|
+ </FormLabel>
|
|
|
+ <FormControl>
|
|
|
+ <Textarea
|
|
|
+ {...field}
|
|
|
+ className='min-h-20 resize-none'
|
|
|
+ placeholder={t(
|
|
|
+ 'One IP per line (empty for no restriction)'
|
|
|
+ )}
|
|
|
+ rows={3}
|
|
|
+ />
|
|
|
+ </FormControl>
|
|
|
+ <FormDescription>
|
|
|
+ {t(
|
|
|
+ 'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
|
|
|
+ )}
|
|
|
+ </FormDescription>
|
|
|
+ <FormMessage />
|
|
|
+ </FormItem>
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </CollapsibleContent>
|
|
|
+ </section>
|
|
|
</Collapsible>
|
|
|
</form>
|
|
|
</Form>
|
|
|
- <SheetFooter className='gap-2'>
|
|
|
+ <SheetFooter className='bg-background gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end'>
|
|
|
<SheetClose asChild>
|
|
|
<Button variant='outline'>{t('Close')}</Button>
|
|
|
</SheetClose>
|