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, 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 { useStatus } from '@/hooks/use-status' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from '@/components/ui/sheet' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { DateTimePicker } from '@/components/datetime-picker' import { MultiSelect } from '@/components/multi-select' import { createApiKey, updateApiKey, getApiKey } from '../api' import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' import { apiKeyFormSchema, type ApiKeyFormValues, getApiKeyFormDefaultValues, transformFormDataToPayload, transformApiKeyToFormDefaults, } from '../lib' import { type ApiKey } from '../types' import { ApiKeyGroupCombobox, type ApiKeyGroupOption, } from './api-key-group-combobox' import { useApiKeys } from './api-keys-provider' type ApiKeyMutateDrawerProps = { open: boolean onOpenChange: (open: boolean) => void currentRow?: ApiKey side?: 'left' | 'right' } type ApiKeyFormSectionProps = { title: string description: string icon: LucideIcon children: ReactNode } function ApiKeyFormSection(props: ApiKeyFormSectionProps) { const Icon = props.icon return ( {props.title} {props.description} {props.children} ) } export function ApiKeysMutateDrawer({ open, onOpenChange, currentRow, side = 'right', }: ApiKeyMutateDrawerProps) { const { t } = useTranslation() const isUpdate = !!currentRow const { triggerRefresh } = useApiKeys() const { status } = useStatus() const [isSubmitting, setIsSubmitting] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) const defaultUseAutoGroup = status?.default_use_auto_group === true // Fetch models const { data: modelsData } = useQuery({ queryKey: ['user-models'], queryFn: getUserModels, staleTime: 5 * 60 * 1000, // Cache for 5 minutes }) // Fetch groups const { data: groupsData } = useQuery({ queryKey: ['user-groups'], queryFn: getUserGroups, staleTime: 5 * 60 * 1000, }) const models = modelsData?.data || [] const groupsRaw = groupsData?.data || {} const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map( ([key, info]) => ({ value: key, label: key, desc: info.desc || key, ratio: info.ratio, }) ) // Add auto group if configured if (!groups.some((g) => g.value === 'auto')) { groups.unshift({ value: 'auto', label: 'auto', desc: t('Auto (Circuit Breaker)'), }) } const form = useForm({ resolver: zodResolver(apiKeyFormSchema), defaultValues: getApiKeyFormDefaultValues(defaultUseAutoGroup), }) // Load existing data when updating useEffect(() => { if (open && isUpdate && currentRow) { // For update, fetch fresh data getApiKey(currentRow.id).then((result) => { if (result.success && result.data) { form.reset(transformApiKeyToFormDefaults(result.data)) } }) } else if (open && !isUpdate) { // For create, reset to defaults form.reset(getApiKeyFormDefaultValues(defaultUseAutoGroup)) } }, [open, isUpdate, currentRow, form, defaultUseAutoGroup]) const onSubmit = async (data: ApiKeyFormValues) => { setIsSubmitting(true) try { const basePayload = transformFormDataToPayload(data) if (isUpdate && currentRow) { const result = await updateApiKey({ ...basePayload, id: currentRow.id, }) if (result.success) { toast.success(t(SUCCESS_MESSAGES.API_KEY_UPDATED)) onOpenChange(false) triggerRefresh() } else { toast.error(result.message || t(ERROR_MESSAGES.UPDATE_FAILED)) } } else { // Create mode - handle batch creation const count = data.tokenCount || 1 let successCount = 0 for (let i = 0; i < count; i++) { const result = await createApiKey({ ...basePayload, name: i === 0 && data.name ? data.name : `${data.name || 'default'}-${Math.random().toString(36).slice(2, 8)}`, }) if (result.success) { successCount++ } else { toast.error(result.message || t(ERROR_MESSAGES.CREATE_FAILED)) break } } if (successCount > 0) { toast.success( t('Successfully created {{count}} API Key(s)', { count: successCount, }) ) onOpenChange(false) triggerRefresh() } } } catch (_error) { toast.error(t(ERROR_MESSAGES.UNEXPECTED)) } finally { setIsSubmitting(false) } } const handleSetExpiry = (months: number, days: number, hours: number) => { if (months === 0 && days === 0 && hours === 0) { form.setValue('expired_time', undefined) return } const now = new Date() now.setMonth(now.getMonth() + months) now.setDate(now.getDate() + days) now.setHours(now.getHours() + hours) form.setValue('expired_time', now) } const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() const tokensOnly = currencyMeta.kind === 'tokens' const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) 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 ( { onOpenChange(v) if (!v) { form.reset() } }} > {isUpdate ? t('Update API Key') : t('Create API Key')} {isUpdate ? t('Update the API key by providing necessary info.') : t('Add a new API key by providing necessary info.')}{' '} {t("Click save when you're done.")} ( {t('Name')} )} /> ( {t('Group')} )} /> {selectedGroup === 'auto' && ( ( {t('Cross-group retry')} {t( 'When enabled, if channels in the current group fail, it will try channels in the next group in order.' )} )} /> )} ( {t('Expiration Time')} handleSetExpiry(0, 0, 0)} > {t('Never')} handleSetExpiry(1, 0, 0)} > {t('1 Month')} handleSetExpiry(0, 1, 0)} > {t('1 Day')} handleSetExpiry(0, 0, 1)} > {t('1 Hour')} )} /> {!isUpdate && ( ( {t('Quantity')} field.onChange(parseInt(e.target.value, 10) || 1) } /> {t( 'Create multiple API keys at once (random suffix will be added to names)' )} )} /> )} {!unlimitedQuota && ( ( {quotaLabel} field.onChange(parseFloat(e.target.value) || 0) } /> {tokensOnly ? t('Enter the quota amount in tokens') : t('Enter the quota amount in {{currency}}', { currency: currencyLabel, })} )} /> )} ( {t('Unlimited Quota')} {t('Enable unlimited quota for this API key')} )} /> } > {t('Advanced Settings')} {t('Set API key access restrictions')} ( {t('Model Limits')} ({ label: m, value: m, }))} selected={field.value} onChange={field.onChange} placeholder={t( 'Select models (empty for allow all)' )} /> {t('Limit which models can be used with this key')} )} /> ( {t('IP Whitelist (supports CIDR)')} {t( 'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.' )} )} /> } > {t('Close')} {isSubmitting ? t('Saving...') : t('Save changes')} ) }
{props.description}
{t('Set API key access restrictions')}