feat(logs): add username to TaskLog interface and implement log avatar styling
This commit is contained in:
@@ -110,7 +110,7 @@ export function ApiKeyGroupCombobox({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className='h-auto min-h-10 w-full justify-between gap-3 px-3 py-2 text-start'
|
||||
className='border-input bg-muted/40 h-auto min-h-20 w-full justify-between gap-3 rounded-lg px-4 py-3 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted/55 hover:text-foreground active:bg-background data-[state=open]:border-ring data-[state=open]:bg-background data-[state=open]:ring-ring/20 data-[state=open]:ring-[3px]'
|
||||
>
|
||||
<span className='flex min-w-0 flex-1 items-center justify-between gap-3'>
|
||||
<span className='min-w-0'>
|
||||
@@ -128,7 +128,7 @@ export function ApiKeyGroupCombobox({
|
||||
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[var(--radix-popover-trigger-width)] p-0'>
|
||||
<PopoverContent className='data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-xl p-0 shadow-lg data-[state=closed]:duration-75 data-[state=open]:duration-100'>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('Search...')}
|
||||
@@ -143,7 +143,7 @@ export function ApiKeyGroupCombobox({
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={handleSelect}
|
||||
className='items-start gap-3 px-3 py-3'
|
||||
className='items-start gap-3 rounded-lg px-3 py-3 transition-colors data-[selected=true]:bg-muted'
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
|
||||
@@ -31,6 +31,16 @@ function getQuotaProgressColor(percentage: number): string {
|
||||
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
|
||||
}
|
||||
|
||||
function getGroupRatioClassName(ratio: number): string {
|
||||
if (ratio > 1) {
|
||||
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300'
|
||||
}
|
||||
if (ratio < 1) {
|
||||
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300'
|
||||
}
|
||||
return 'border-border bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
function useGroupRatios(): Record<string, number> {
|
||||
const isAdmin = useAuthStore((s) =>
|
||||
Boolean(s.auth.user?.role && s.auth.user.role >= 10)
|
||||
@@ -242,15 +252,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className='inline-flex items-center gap-1.5 text-xs'>
|
||||
<span className='inline-flex items-center gap-2 text-xs'>
|
||||
<span className='font-medium'>{group || t('Default')}</span>
|
||||
{ratio != null && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground/60 font-mono tabular-nums'>
|
||||
{ratio}x
|
||||
</span>
|
||||
</>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
|
||||
getGroupRatioClassName(ratio)
|
||||
)}
|
||||
>
|
||||
<span className='size-1 rounded-full bg-current opacity-60' />
|
||||
<span>{ratio}x</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
+327
-254
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='expired_time'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isUpdate && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='tokenCount'
|
||||
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(
|
||||
'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
|
||||
'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='remain_quota_dollars'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{quotaLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type='number'
|
||||
step={tokensOnly ? 1 : 0.01}
|
||||
placeholder={quotaPlaceholder}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</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='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}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</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'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{quotaLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type='number'
|
||||
step={tokensOnly ? 1 : 0.01}
|
||||
placeholder={quotaPlaceholder}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isUpdate && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='tokenCount'
|
||||
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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ApiKeyFormSection>
|
||||
|
||||
<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'>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<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}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user