refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
Vendored
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiResponse,
|
||||
GetApiKeysParams,
|
||||
GetApiKeysResponse,
|
||||
SearchApiKeysParams,
|
||||
ApiKeyFormData,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// API Key Management
|
||||
// ============================================================================
|
||||
|
||||
// Get paginated API keys list
|
||||
export async function getApiKeys(
|
||||
params: GetApiKeysParams = {}
|
||||
): Promise<GetApiKeysResponse> {
|
||||
const { p = 1, size = 10 } = params
|
||||
const res = await api.get(`/api/token/?p=${p}&size=${size}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Search API keys by keyword or token (with pagination)
|
||||
export async function searchApiKeys(
|
||||
params: SearchApiKeysParams
|
||||
): Promise<GetApiKeysResponse> {
|
||||
const { keyword = '', token = '', p, size } = params
|
||||
const queryParams = new URLSearchParams()
|
||||
if (keyword) queryParams.set('keyword', keyword)
|
||||
if (token) queryParams.set('token', token)
|
||||
if (p != null) queryParams.set('p', String(p))
|
||||
if (size != null) queryParams.set('size', String(size))
|
||||
const res = await api.get(`/api/token/search?${queryParams.toString()}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Get single API key by ID
|
||||
export async function getApiKey(id: number): Promise<ApiResponse<ApiKey>> {
|
||||
const res = await api.get(`/api/token/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Create a new API key
|
||||
export async function createApiKey(
|
||||
data: ApiKeyFormData
|
||||
): Promise<ApiResponse<ApiKey>> {
|
||||
const res = await api.post('/api/token/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Update an existing API key
|
||||
export async function updateApiKey(
|
||||
data: ApiKeyFormData & { id: number }
|
||||
): Promise<ApiResponse<ApiKey>> {
|
||||
const res = await api.put('/api/token/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Delete a single API key
|
||||
export async function deleteApiKey(id: number): Promise<ApiResponse> {
|
||||
const res = await api.delete(`/api/token/${id}/`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Batch delete multiple API keys
|
||||
export async function batchDeleteApiKeys(
|
||||
ids: number[]
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await api.post('/api/token/batch', { ids })
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Update API key status (enable/disable)
|
||||
export async function updateApiKeyStatus(
|
||||
id: number,
|
||||
status: number
|
||||
): Promise<ApiResponse<ApiKey>> {
|
||||
const res = await api.put('/api/token/?status_only=true', { id, status })
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Fetch the real (unmasked) key for a token by ID
|
||||
export async function fetchTokenKey(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
|
||||
const res = await api.post(`/api/token/${id}/key`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Batch fetch real (unmasked) keys for multiple tokens
|
||||
export async function fetchTokenKeysBatch(ids: number[]): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: { keys: Record<number, string> }
|
||||
}> {
|
||||
const res = await api.post('/api/token/batch/keys', { ids })
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ApiKeyGroupOption = {
|
||||
value: string
|
||||
label: string
|
||||
desc?: string
|
||||
ratio?: number | string
|
||||
}
|
||||
|
||||
type ApiKeyGroupComboboxProps = {
|
||||
options: ApiKeyGroupOption[]
|
||||
value?: string
|
||||
onValueChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function formatGroupRatio(
|
||||
ratio: ApiKeyGroupOption['ratio'],
|
||||
ratioLabel: string
|
||||
) {
|
||||
if (ratio === undefined || ratio === null || ratio === '') return null
|
||||
return `${ratio}x ${ratioLabel}`
|
||||
}
|
||||
|
||||
function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
|
||||
if (typeof ratio !== 'number') {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
}
|
||||
|
||||
if (ratio > 5) {
|
||||
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
|
||||
}
|
||||
if (ratio > 3) {
|
||||
return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
|
||||
}
|
||||
if (ratio > 1) {
|
||||
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
|
||||
}
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
}
|
||||
|
||||
function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
|
||||
const { t } = useTranslation()
|
||||
const label = formatGroupRatio(ratio, t('Ratio'))
|
||||
|
||||
if (!label) return null
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
|
||||
getRatioBadgeClassName(ratio)
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeyGroupCombobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: ApiKeyGroupComboboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const selectedOption = options.find((option) => option.value === value)
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const search = searchValue.trim().toLowerCase()
|
||||
if (!search) return options
|
||||
|
||||
return options.filter((option) => {
|
||||
const ratioText = String(option.ratio ?? '').toLowerCase()
|
||||
return (
|
||||
option.value.toLowerCase().includes(search) ||
|
||||
option.label.toLowerCase().includes(search) ||
|
||||
option.desc?.toLowerCase().includes(search) ||
|
||||
ratioText.includes(search)
|
||||
)
|
||||
})
|
||||
}, [options, searchValue])
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onValueChange(selectedValue)
|
||||
setOpen(false)
|
||||
setSearchValue('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
|
||||
<span className='min-w-0'>
|
||||
<span className='block truncate font-medium'>
|
||||
{selectedOption?.label || placeholder || t('Select a group')}
|
||||
</span>
|
||||
{selectedOption?.desc && (
|
||||
<span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
|
||||
{selectedOption.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className='hidden sm:block'>
|
||||
<GroupRatioBadge ratio={selectedOption?.ratio} />
|
||||
</span>
|
||||
</span>
|
||||
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='data-closed:zoom-out-100 data-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(--anchor-width)] overflow-hidden rounded-xl p-0 shadow-lg data-closed:duration-75 data-open:duration-100'
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onTouchMove={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('Search...')}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
<CommandList className='max-h-[360px]'>
|
||||
<CommandEmpty>{t('No group found.')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
className='data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors'
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4',
|
||||
value === option.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className='min-w-0 flex-1'>
|
||||
<span className='block truncate font-medium'>
|
||||
{option.label}
|
||||
</span>
|
||||
{option.desc && (
|
||||
<span className='text-muted-foreground block truncate text-xs'>
|
||||
{option.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<GroupRatioBadge ratio={option.ratio} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ApiKeyTimestampCellProps {
|
||||
timestamp: number
|
||||
now: number
|
||||
locale?: string
|
||||
justNowLabel: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
|
||||
if (!props.timestamp || props.timestamp === -1) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const timestampMs = props.timestamp * 1000
|
||||
const isJustNow = timestampMs <= props.now && props.now - timestampMs < 60_000
|
||||
const relativeTime = isJustNow
|
||||
? props.justNowLabel
|
||||
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
|
||||
const absoluteTime = formatTimestampToDate(props.timestamp)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<time
|
||||
dateTime={new Date(timestampMs).toISOString()}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'block truncate font-mono text-xs tabular-nums',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{relativeTime}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className='font-mono tabular-nums'>{absoluteTime}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Check, Copy, Loader2 } from 'lucide-react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BadgeCell } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
|
||||
import type { ApiKey } from '../types'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
resolveRealKey,
|
||||
resolvedKeys,
|
||||
loadingKeys,
|
||||
copiedKeyId,
|
||||
markKeyCopied,
|
||||
} = useApiKeys()
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
|
||||
const isLoading = !!loadingKeys[apiKey.id]
|
||||
const resolvedFullKey = resolvedKeys[apiKey.id]
|
||||
const isCopied = copiedKeyId === apiKey.id
|
||||
const maskedKey = `sk-${apiKey.key}`
|
||||
|
||||
const handlePopoverOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
setPopoverOpen(open)
|
||||
if (open && !resolvedFullKey) {
|
||||
resolveRealKey(apiKey.id)
|
||||
}
|
||||
},
|
||||
[resolvedFullKey, resolveRealKey, apiKey.id]
|
||||
)
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const realKey = resolvedFullKey || (await resolveRealKey(apiKey.id))
|
||||
if (!realKey) return
|
||||
|
||||
const ok = await copyToClipboard(realKey)
|
||||
if (ok) markKeyCopied(apiKey.id)
|
||||
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied])
|
||||
|
||||
let copyIcon = <Copy className='size-3.5' />
|
||||
let copyTooltip = t('Copy API key')
|
||||
if (isLoading) {
|
||||
copyIcon = <Loader2 className='size-3.5 animate-spin' />
|
||||
copyTooltip = t('Loading...')
|
||||
} else if (isCopied) {
|
||||
copyIcon = <Check className='size-3.5 text-green-600' />
|
||||
copyTooltip = t('Copied!')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex max-w-full min-w-0 items-center'>
|
||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='truncate'>{maskedKey}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto max-w-[min(90vw,28rem)]'
|
||||
align='start'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>{t('Full API Key')}</p>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center gap-2 py-2'>
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Loading...')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
readOnly
|
||||
value={resolvedFullKey || maskedKey}
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
className='bg-muted/50 w-full min-w-[280px] rounded-md border px-3 py-2 font-mono text-xs outline-none'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-7 shrink-0'
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{copyIcon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{copyTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Unlimited')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const models = apiKey.model_limits.split(',').filter(Boolean)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<BadgeCell />}>
|
||||
<StatusBadge
|
||||
label={t('{{count}} model(s)', { count: models.length })}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
|
||||
{models.map((m) => (
|
||||
<div key={m} className='font-mono'>
|
||||
{m}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
const { t } = useTranslation()
|
||||
const allowIps = apiKey.allow_ips?.trim()
|
||||
|
||||
if (!allowIps) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('No restriction')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ips = allowIps
|
||||
.split('\n')
|
||||
.map((ip) => ip.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<BadgeCell />}>
|
||||
<StatusBadge
|
||||
label={t('{{count}} IP(s)', { count: ips.length })}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
|
||||
{ips.map((ip) => (
|
||||
<div key={ip} className='font-mono'>
|
||||
{ip}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BadgeCell, TruncatedCell } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { getUserGroups } from '@/lib/api'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { API_KEY_STATUSES } from '../constants'
|
||||
import type { ApiKey } from '../types'
|
||||
import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
|
||||
import {
|
||||
ApiKeyCell,
|
||||
ModelLimitsCell,
|
||||
IpRestrictionsCell,
|
||||
} from './api-keys-cells'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
|
||||
function getQuotaProgressColor(percentage: number): string {
|
||||
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
|
||||
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
|
||||
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
|
||||
}
|
||||
|
||||
function useGroupRatios(): Record<string, number> {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['user-groups'],
|
||||
queryFn: getUserGroups,
|
||||
staleTime: 0,
|
||||
select: (res) => {
|
||||
if (!res.success || !res.data) return {}
|
||||
const ratios: Record<string, number> = {}
|
||||
for (const [group, info] of Object.entries(res.data)) {
|
||||
if (typeof info.ratio === 'number') {
|
||||
ratios[group] = info.ratio
|
||||
}
|
||||
}
|
||||
return ratios
|
||||
},
|
||||
})
|
||||
|
||||
return data ?? {}
|
||||
}
|
||||
|
||||
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
|
||||
const { t, i18n } = useTranslation()
|
||||
const groupRatios = useGroupRatios()
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const justNowLabel = t('Just now')
|
||||
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='Select all'
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label='Select row'
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('Name'),
|
||||
cell: ({ row }) => (
|
||||
<span className='font-medium'>{row.getValue('name')}</span>
|
||||
),
|
||||
size: 180,
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const statusConfig = API_KEY_STATUSES[row.getValue('status') as number]
|
||||
if (!statusConfig) return null
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t(statusConfig.label)}
|
||||
variant={statusConfig.variant}
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
|
||||
size: 120,
|
||||
meta: { mobileBadge: true },
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
accessorKey: 'key',
|
||||
header: t('API Key'),
|
||||
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 260,
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
accessorKey: 'remain_quota',
|
||||
header: t('Quota'),
|
||||
cell: ({ row }) => {
|
||||
const apiKey = row.original
|
||||
if (apiKey.unlimited_quota) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Unlimited')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const used = apiKey.used_quota
|
||||
const remaining = apiKey.remain_quota
|
||||
const total = used + remaining
|
||||
const percentage = total > 0 ? (remaining / total) * 100 : 0
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className='w-[150px] space-y-1' />}>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span className='font-medium tabular-nums'>
|
||||
{formatQuota(remaining)}
|
||||
</span>
|
||||
<span className='text-muted-foreground tabular-nums'>
|
||||
{formatQuota(total)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn('h-1.5', getQuotaProgressColor(percentage))}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='space-y-1 text-xs'>
|
||||
<div>
|
||||
{t('Used:')} {formatQuota(used)}
|
||||
</div>
|
||||
<div>
|
||||
{t('Remaining:')} {formatQuota(remaining)} (
|
||||
{percentage.toFixed(1)}%)
|
||||
</div>
|
||||
<div>
|
||||
{t('Total:')} {formatQuota(total)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
size: 170,
|
||||
},
|
||||
{
|
||||
accessorKey: 'group',
|
||||
header: t('Group'),
|
||||
cell: ({ row }) => {
|
||||
const apiKey = row.original
|
||||
const group = row.getValue('group') as string
|
||||
const ratio = group && group !== 'auto' ? groupRatios[group] : undefined
|
||||
|
||||
if (group === 'auto') {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<BadgeCell className='gap-1.5 text-xs' />}
|
||||
>
|
||||
<GroupBadge group='auto' />
|
||||
{apiKey.cross_group_retry && (
|
||||
<StatusBadge
|
||||
label={t('Cross-group')}
|
||||
variant='info'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className='text-xs'>
|
||||
{t(
|
||||
'Automatically selects the best available group with circuit breaker mechanism'
|
||||
)}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TruncatedCell
|
||||
className='-ml-1.5'
|
||||
tooltipContent={group || '-'}
|
||||
tooltipClassName='break-all'
|
||||
>
|
||||
<GroupBadge group={group} ratio={ratio} />
|
||||
</TruncatedCell>
|
||||
)
|
||||
},
|
||||
size: 160,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'model_limits',
|
||||
accessorKey: 'model_limits',
|
||||
header: t('Models'),
|
||||
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'allow_ips',
|
||||
accessorKey: 'allow_ips',
|
||||
header: t('IP Restriction'),
|
||||
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_time',
|
||||
header: t('Created'),
|
||||
cell: ({ row }) => (
|
||||
<ApiKeyTimestampCell
|
||||
timestamp={row.getValue('created_time')}
|
||||
now={now}
|
||||
locale={locale}
|
||||
justNowLabel={justNowLabel}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
),
|
||||
size: 180,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'accessed_time',
|
||||
header: t('Last Used'),
|
||||
cell: ({ row }) => {
|
||||
const accessedTime = row.getValue('accessed_time') as number
|
||||
const isStale =
|
||||
accessedTime > 0 && accessedTime * 1000 < staleAccessThreshold
|
||||
|
||||
return (
|
||||
<ApiKeyTimestampCell
|
||||
timestamp={accessedTime}
|
||||
now={now}
|
||||
locale={locale}
|
||||
justNowLabel={justNowLabel}
|
||||
className={isStale ? 'text-warning' : 'text-muted-foreground'}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'expired_time',
|
||||
header: t('Expires'),
|
||||
cell: ({ row }) => {
|
||||
const expiredTime = row.getValue('expired_time') as number
|
||||
if (expiredTime === -1) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Never')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
const isExpired = expiredTime * 1000 < now
|
||||
return (
|
||||
<ApiKeyTimestampCell
|
||||
timestamp={expiredTime}
|
||||
now={now}
|
||||
locale={locale}
|
||||
justNowLabel={justNowLabel}
|
||||
className={cn(
|
||||
isExpired ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
import { deleteApiKey } from '../api'
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
export function ApiKeysDeleteDialog() {
|
||||
const { t } = useTranslation()
|
||||
const { open, setOpen, currentRow, triggerRefresh } = useApiKeys()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!currentRow) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const result = await deleteApiKey(currentRow.id)
|
||||
if (result.success) {
|
||||
toast.success(t(SUCCESS_MESSAGES.API_KEY_DELETED))
|
||||
setOpen(null)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open === 'delete'}
|
||||
onOpenChange={(open) => !open && setOpen(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('This will permanently delete API key')}{' '}
|
||||
<span className='font-semibold'>{currentRow?.name}</span>
|
||||
{t('. This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
variant='destructive'
|
||||
>
|
||||
{isDeleting ? t('Deleting...') : t('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
|
||||
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
|
||||
|
||||
export function ApiKeysDialogs() {
|
||||
const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
|
||||
|
||||
return (
|
||||
<>
|
||||
<ApiKeysMutateDrawer
|
||||
open={open === 'create' || open === 'update'}
|
||||
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
|
||||
currentRow={open === 'update' ? currentRow || undefined : undefined}
|
||||
/>
|
||||
<ApiKeysDeleteDialog />
|
||||
<CCSwitchDialog
|
||||
open={open === 'cc-switch'}
|
||||
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
|
||||
tokenKey={resolvedKey}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
import { batchDeleteApiKeys } from '../api'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import { type ApiKey } from '../types'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
type ApiKeysMultiDeleteDialogProps<TData> = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function ApiKeysMultiDeleteDialog<TData>({
|
||||
open,
|
||||
onOpenChange,
|
||||
table,
|
||||
}: ApiKeysMultiDeleteDialogProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const { triggerRefresh } = useApiKeys()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const ids = selectedRows.map((row) => (row.original as ApiKey).id)
|
||||
const result = await batchDeleteApiKeys(ids)
|
||||
|
||||
if (result.success) {
|
||||
const count = result.data || ids.length
|
||||
toast.success(t('Successfully deleted {{count}} API key(s)', { count }))
|
||||
table.resetRowSelection()
|
||||
triggerRefresh()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.BATCH_DELETE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
destructive
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
handleConfirm={handleConfirm}
|
||||
isLoading={isDeleting}
|
||||
className='max-w-md'
|
||||
title={t('Delete {{count}} API key(s)?', { count: selectedRows.length })}
|
||||
desc={
|
||||
<>
|
||||
{t('You are about to delete {{count}} API key(s).', {
|
||||
count: selectedRows.length,
|
||||
})}{' '}
|
||||
<br />
|
||||
{t('This action cannot be undone.')}
|
||||
</>
|
||||
}
|
||||
confirmText={t('Delete')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronDown, KeyRound, Settings2, WalletCards } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, type SubmitErrorHandler } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import {
|
||||
SideDrawerSection,
|
||||
SideDrawerSectionHeader,
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFooterClassName,
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
sideDrawerSwitchItemClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
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 { useStatus } from '@/hooks/use-status'
|
||||
import { getUserModels, getUserGroups } from '@/lib/api'
|
||||
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { createApiKey, updateApiKey, getApiKey } from '../api'
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import {
|
||||
getApiKeyFormSchema,
|
||||
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
|
||||
}
|
||||
|
||||
export function ApiKeysMutateDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentRow,
|
||||
}: 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,
|
||||
enabled: open,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Fetch groups
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['user-groups'],
|
||||
queryFn: getUserGroups,
|
||||
enabled: open,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
)
|
||||
const backendHasAuto = groups.some((g) => g.value === 'auto')
|
||||
const schema = getApiKeyFormSchema(t)
|
||||
|
||||
const form = useForm<ApiKeyFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: getApiKeyFormDefaultValues(defaultUseAutoGroup),
|
||||
})
|
||||
|
||||
// Load existing data when updating
|
||||
useEffect(() => {
|
||||
if (open && isUpdate && currentRow) {
|
||||
void getApiKey(currentRow.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
form.reset(transformApiKeyToFormDefaults(result.data))
|
||||
}
|
||||
})
|
||||
} else if (open && !isUpdate) {
|
||||
form.reset(
|
||||
getApiKeyFormDefaultValues(defaultUseAutoGroup && backendHasAuto)
|
||||
)
|
||||
}
|
||||
}, [open, isUpdate, currentRow, form, defaultUseAutoGroup, backendHasAuto])
|
||||
|
||||
// Correct group after groups load: if the form value is not in available groups, fall back
|
||||
useEffect(() => {
|
||||
if (groups.length === 0) return
|
||||
const currentGroup = form.getValues('group')
|
||||
if (currentGroup && !groups.some((g) => g.value === currentGroup)) {
|
||||
const fallback =
|
||||
groups.find((g) => g.value === 'default')?.value ??
|
||||
groups[0]?.value ??
|
||||
''
|
||||
form.setValue('group', fallback)
|
||||
if (currentGroup === 'auto') {
|
||||
form.setValue('cross_group_retry', false)
|
||||
}
|
||||
}
|
||||
}, [groups, form])
|
||||
|
||||
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 {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onInvalid: SubmitErrorHandler<ApiKeyFormValues> = () => {
|
||||
toast.error(t('Please fix the highlighted fields before saving'))
|
||||
}
|
||||
|
||||
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 (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
if (!v) {
|
||||
form.reset()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
className={sideDrawerContentClassName('max-w-none sm:!max-w-[620px]')}
|
||||
>
|
||||
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||
<SheetTitle>
|
||||
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isUpdate
|
||||
? t('Update the API key by providing necessary info.')
|
||||
: t('Add a new API key by providing necessary info.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='api-key-form'
|
||||
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
|
||||
className={sideDrawerFormClassName('gap-5')}
|
||||
>
|
||||
<SideDrawerSection>
|
||||
<SideDrawerSectionHeader
|
||||
title={t('Basic Information')}
|
||||
description={t('Set API key basic information')}
|
||||
icon={<KeyRound className='size-4' />}
|
||||
iconTone='info'
|
||||
/>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedGroup === 'auto' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cross_group_retry'
|
||||
render={({ field }) => (
|
||||
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<FormLabel className='text-sm'>
|
||||
{t('Cross-group retry')}
|
||||
</FormLabel>
|
||||
<FormDescription className='line-clamp-2 text-xs sm:line-clamp-none'>
|
||||
{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 [&_input[type=time]]:w-24 sm:[&_input[type=time]]:w-32'
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='grid grid-cols-4 gap-2 sm:flex'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='px-2 text-xs sm:px-3 sm:text-sm'
|
||||
onClick={() => handleSetExpiry(0, 0, 0)}
|
||||
>
|
||||
{t('Never')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='px-2 text-xs sm:px-3 sm:text-sm'
|
||||
onClick={() => handleSetExpiry(1, 0, 0)}
|
||||
>
|
||||
{t('1 Month')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='px-2 text-xs sm:px-3 sm:text-sm'
|
||||
onClick={() => handleSetExpiry(0, 1, 0)}
|
||||
>
|
||||
{t('1 Day')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='px-2 text-xs sm:px-3 sm:text-sm'
|
||||
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(
|
||||
Number.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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SideDrawerSection>
|
||||
|
||||
<SideDrawerSection>
|
||||
<SideDrawerSectionHeader
|
||||
title={t('Quota Settings')}
|
||||
description={t('Set quota amount and limits')}
|
||||
icon={<WalletCards className='size-4' />}
|
||||
iconTone='success'
|
||||
/>
|
||||
{!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(
|
||||
Number.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={sideDrawerSwitchItemClassName()}>
|
||||
<div className='flex flex-col gap-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>
|
||||
)}
|
||||
/>
|
||||
</SideDrawerSection>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<SideDrawerSection>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-muted/40 flex w-full items-center gap-3 rounded-md py-1.5 text-left transition-colors'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SideDrawerSectionHeader
|
||||
className='flex-1'
|
||||
title={t('Advanced Settings')}
|
||||
description={t('Set API key access restrictions')}
|
||||
icon={<Settings2 className='size-4' />}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform',
|
||||
advancedOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className='flex flex-col gap-4 pt-2'>
|
||||
<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>
|
||||
</SideDrawerSection>
|
||||
</Collapsible>
|
||||
</form>
|
||||
</Form>
|
||||
<SheetFooter className={sideDrawerFooterClassName()}>
|
||||
<SheetClose
|
||||
render={<Button variant='outline' className='w-full sm:w-auto' />}
|
||||
>
|
||||
{t('Close')}
|
||||
</SheetClose>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={form.handleSubmit(onSubmit, onInvalid)}
|
||||
disabled={isSubmitting}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
{isSubmitting ? t('Saving...') : t('Save changes')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
export function ApiKeysPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen } = useApiKeys()
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' onClick={() => setOpen('create')}>
|
||||
<Plus className='h-4 w-4' />
|
||||
{t('Create API Key')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import useDialogState from '@/hooks/use-dialog'
|
||||
|
||||
import { fetchTokenKey, fetchTokenKeysBatch } from '../api'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import { type ApiKey, type ApiKeysDialogType } from '../types'
|
||||
|
||||
type ApiKeysContextType = {
|
||||
open: ApiKeysDialogType | null
|
||||
setOpen: (str: ApiKeysDialogType | null) => void
|
||||
currentRow: ApiKey | null
|
||||
setCurrentRow: React.Dispatch<React.SetStateAction<ApiKey | null>>
|
||||
refreshTrigger: number
|
||||
triggerRefresh: () => void
|
||||
resolvedKey: string
|
||||
setResolvedKey: React.Dispatch<React.SetStateAction<string>>
|
||||
resolveRealKey: (id: number) => Promise<string | null>
|
||||
resolveRealKeysBatch: (ids: number[]) => Promise<Record<number, string>>
|
||||
resolvedKeys: Record<number, string>
|
||||
loadingKeys: Record<number, boolean>
|
||||
copiedKeyId: number | null
|
||||
markKeyCopied: (id: number) => void
|
||||
}
|
||||
|
||||
const ApiKeysContext = React.createContext<ApiKeysContextType | null>(null)
|
||||
|
||||
export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useDialogState<ApiKeysDialogType>(null)
|
||||
const [currentRow, setCurrentRow] = useState<ApiKey | null>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [resolvedKey, setResolvedKey] = useState('')
|
||||
|
||||
const [resolvedKeys, setResolvedKeys] = useState<Record<number, string>>({})
|
||||
const [loadingKeys, setLoadingKeys] = useState<Record<number, boolean>>({})
|
||||
const pendingRequests = useRef<Record<number, Promise<string | null>>>({})
|
||||
|
||||
const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null)
|
||||
const copiedTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(copiedTimerRef.current)
|
||||
}, [])
|
||||
|
||||
const markKeyCopied = useCallback((id: number) => {
|
||||
setCopiedKeyId(id)
|
||||
clearTimeout(copiedTimerRef.current)
|
||||
copiedTimerRef.current = setTimeout(() => setCopiedKeyId(null), 2000)
|
||||
}, [])
|
||||
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshTrigger((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
const resolveRealKey = useCallback(
|
||||
async (id: number): Promise<string | null> => {
|
||||
if (resolvedKeys[id]) return resolvedKeys[id]
|
||||
if (id in pendingRequests.current) return pendingRequests.current[id]
|
||||
|
||||
const request = (async () => {
|
||||
setLoadingKeys((prev) => ({ ...prev, [id]: true }))
|
||||
try {
|
||||
const res = await fetchTokenKey(id)
|
||||
if (res.success && res.data?.key) {
|
||||
const fullKey = `sk-${res.data.key}`
|
||||
setResolvedKeys((prev) => ({ ...prev, [id]: fullKey }))
|
||||
return fullKey
|
||||
}
|
||||
toast.error(res.message || t(ERROR_MESSAGES.UNEXPECTED))
|
||||
return null
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
return null
|
||||
} finally {
|
||||
delete pendingRequests.current[id]
|
||||
setLoadingKeys((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.current[id] = request
|
||||
return request
|
||||
},
|
||||
[resolvedKeys, t]
|
||||
)
|
||||
|
||||
const resolveRealKeysBatch = useCallback(
|
||||
async (ids: number[]): Promise<Record<number, string>> => {
|
||||
const uncachedIds = ids.filter((id) => !resolvedKeys[id])
|
||||
if (uncachedIds.length === 0) {
|
||||
const result: Record<number, string> = {}
|
||||
for (const id of ids) result[id] = resolvedKeys[id]
|
||||
return result
|
||||
}
|
||||
|
||||
for (const id of uncachedIds) {
|
||||
setLoadingKeys((prev) => ({ ...prev, [id]: true }))
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchTokenKeysBatch(uncachedIds)
|
||||
if (res.success && res.data?.keys) {
|
||||
const newKeys: Record<number, string> = {}
|
||||
for (const [idStr, key] of Object.entries(res.data.keys)) {
|
||||
newKeys[Number(idStr)] = `sk-${key}`
|
||||
}
|
||||
setResolvedKeys((prev) => ({ ...prev, ...newKeys }))
|
||||
|
||||
const result: Record<number, string> = { ...newKeys }
|
||||
for (const id of ids) {
|
||||
if (resolvedKeys[id]) result[id] = resolvedKeys[id]
|
||||
}
|
||||
return result
|
||||
}
|
||||
toast.error(res.message || t(ERROR_MESSAGES.UNEXPECTED))
|
||||
return {}
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
return {}
|
||||
} finally {
|
||||
for (const id of uncachedIds) {
|
||||
setLoadingKeys((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[resolvedKeys, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<ApiKeysContext
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentRow,
|
||||
setCurrentRow,
|
||||
refreshTrigger,
|
||||
triggerRefresh,
|
||||
resolvedKey,
|
||||
setResolvedKey,
|
||||
resolveRealKey,
|
||||
resolveRealKeysBatch,
|
||||
resolvedKeys,
|
||||
loadingKeys,
|
||||
copiedKeyId,
|
||||
markKeyCopied,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ApiKeysContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useApiKeys = () => {
|
||||
const apiKeysContext = React.useContext(ApiKeysContext)
|
||||
|
||||
if (!apiKeysContext) {
|
||||
throw new Error('useApiKeys has to be used within <ApiKeysContext>')
|
||||
}
|
||||
|
||||
return apiKeysContext
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import type { Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getApiKeys, searchApiKeys } from '../api'
|
||||
import {
|
||||
API_KEY_STATUS,
|
||||
API_KEY_STATUS_OPTIONS,
|
||||
API_KEY_STATUSES,
|
||||
ERROR_MESSAGES,
|
||||
} from '../constants'
|
||||
import type { ApiKey } from '../types'
|
||||
import { ApiKeyCell } from './api-keys-cells'
|
||||
import { useApiKeysColumns } from './api-keys-columns'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
|
||||
const route = getRouteApi('/_authenticated/keys/')
|
||||
const API_KEYS_COLUMN_VISIBILITY_STORAGE_KEY = 'api-keys:column-visibility'
|
||||
const API_KEYS_MOBILE_SKELETON_IDS = Array.from(
|
||||
{ length: 5 },
|
||||
(_, index) => `api-key-mobile-skeleton-${index + 1}`
|
||||
)
|
||||
|
||||
function isDisabledApiKeyRow(apiKey: ApiKey) {
|
||||
return apiKey.status !== API_KEY_STATUS.ENABLED
|
||||
}
|
||||
|
||||
function ApiKeysMobileSkeleton() {
|
||||
return (
|
||||
<div className='divide-border overflow-hidden rounded-lg border'>
|
||||
{API_KEYS_MOBILE_SKELETON_IDS.map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-5 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Skeleton className='h-7 w-44' />
|
||||
<Skeleton className='h-8 w-16' />
|
||||
</div>
|
||||
<Skeleton className='h-3 w-28' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeysMobileList({
|
||||
table,
|
||||
isLoading,
|
||||
}: {
|
||||
table: TanstackTable<ApiKey>
|
||||
isLoading: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
if (isLoading) return <ApiKeysMobileSkeleton />
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className='rounded-lg border p-8'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t('No API Keys Found')}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{t(
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='divide-border overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const apiKey = row.original
|
||||
const statusConfig = API_KEY_STATUSES[apiKey.status]
|
||||
const total = apiKey.used_quota + apiKey.remain_quota
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
|
||||
isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE
|
||||
)}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate text-sm font-semibold'>
|
||||
{apiKey.name}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
{t('API Key')}
|
||||
</div>
|
||||
</div>
|
||||
{statusConfig && (
|
||||
<StatusBadge
|
||||
label={t(statusConfig.label)}
|
||||
variant={statusConfig.variant}
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 items-center justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1 [&_button:first-child]:max-w-full [&_button:first-child]:truncate [&_button:first-child]:px-0'>
|
||||
<ApiKeyCell apiKey={apiKey} />
|
||||
</div>
|
||||
<DataTableRowActions row={row} />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between gap-2 text-xs'>
|
||||
<span className='text-muted-foreground'>{t('Quota')}</span>
|
||||
{apiKey.unlimited_quota ? (
|
||||
<span className='font-medium'>{t('Unlimited')}</span>
|
||||
) : (
|
||||
<span className='font-medium tabular-nums'>
|
||||
{formatQuota(apiKey.remain_quota)}
|
||||
<span className='text-muted-foreground font-normal'>
|
||||
{' / '}
|
||||
{formatQuota(total)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeysTable() {
|
||||
const { t } = useTranslation()
|
||||
const { refreshTrigger } = useApiKeys()
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const columns = useApiKeysColumns(now)
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 30_000)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
ensurePageInRange,
|
||||
} = useTableUrlState({
|
||||
search: route.useSearch(),
|
||||
navigate: route.useNavigate(),
|
||||
pagination: { defaultPage: 1, defaultPageSize: 20 },
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [
|
||||
{ columnId: 'status', searchKey: 'status', type: 'array' },
|
||||
{ columnId: '_tokenSearch', searchKey: 'token', type: 'string' },
|
||||
],
|
||||
})
|
||||
|
||||
const {
|
||||
value: tokenFilter,
|
||||
inputValue: tokenFilterInput,
|
||||
setInputValue: setTokenFilterInput,
|
||||
} = useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId: '_tokenSearch',
|
||||
onColumnFiltersChange,
|
||||
})
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
|
||||
|
||||
// Fetch data with React Query
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: [
|
||||
'keys',
|
||||
pagination.pageIndex + 1,
|
||||
pagination.pageSize,
|
||||
globalFilter,
|
||||
tokenFilter,
|
||||
refreshTrigger,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const result = shouldSearch
|
||||
? await searchApiKeys({
|
||||
keyword: globalFilter,
|
||||
token: tokenFilter,
|
||||
p: pagination.pageIndex + 1,
|
||||
size: pagination.pageSize,
|
||||
})
|
||||
: await getApiKeys({
|
||||
p: pagination.pageIndex + 1,
|
||||
size: pagination.pageSize,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(
|
||||
result.message ||
|
||||
t(
|
||||
shouldSearch
|
||||
? ERROR_MESSAGES.SEARCH_FAILED
|
||||
: ERROR_MESSAGES.LOAD_FAILED
|
||||
)
|
||||
)
|
||||
return { items: [], total: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
items: result.data?.items || [],
|
||||
total: result.data?.total || 0,
|
||||
}
|
||||
},
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
|
||||
const apiKeys = data?.items || []
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: apiKeys,
|
||||
columns,
|
||||
enableRowSelection: true,
|
||||
columnFilters,
|
||||
columnVisibilityStorageKey: API_KEYS_COLUMN_VISIBILITY_STORAGE_KEY,
|
||||
globalFilter,
|
||||
pagination,
|
||||
globalFilterFn: () => true,
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
manualPagination: true,
|
||||
totalCount: data?.total || 0,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No API Keys Found')}
|
||||
emptyDescription={t(
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='api-keys-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name...'),
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by API key...')}
|
||||
aria-label={t('Filter by API key...')}
|
||||
value={tokenFilterInput}
|
||||
onChange={(e) => setTokenFilterInput(e.target.value)}
|
||||
className='w-full sm:w-50 lg:w-60'
|
||||
/>
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
|
||||
getRowClassName={(row) =>
|
||||
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
|
||||
}
|
||||
bulkActions={<DataTableBulkActions table={table} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Copy, Trash2, Loader2 } from 'lucide-react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
|
||||
import { type ApiKey } from '../types'
|
||||
import { ApiKeysMultiDeleteDialog } from './api-keys-multi-delete-dialog'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
type DataTableBulkActionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
}: DataTableBulkActionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const { resolveRealKeysBatch } = useApiKeys()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
|
||||
const handleBatchCopy = useCallback(async () => {
|
||||
if (selectedRows.length === 0) return
|
||||
|
||||
setIsCopying(true)
|
||||
try {
|
||||
const ids = selectedRows.map((row) => (row.original as ApiKey).id)
|
||||
const keysMap = await resolveRealKeysBatch(ids)
|
||||
|
||||
const lines: string[] = []
|
||||
for (const row of selectedRows) {
|
||||
const apiKey = row.original as ApiKey
|
||||
const realKey = keysMap[apiKey.id]
|
||||
if (realKey) {
|
||||
lines.push(`${apiKey.name}\t${realKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length > 0) {
|
||||
const ok = await copyToClipboard(lines.join('\n'))
|
||||
if (ok) {
|
||||
toast.success(t('Copied {{count}} key(s)', { count: lines.length }))
|
||||
} else {
|
||||
toast.error(t('Failed to copy keys'))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('Failed to copy keys'))
|
||||
} finally {
|
||||
setIsCopying(false)
|
||||
}
|
||||
}, [selectedRows, resolveRealKeysBatch, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName='API key'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
onClick={handleBatchCopy}
|
||||
disabled={isCopying}
|
||||
aria-label={t('Copy selected keys')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isCopying ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Copy selected keys')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected API keys')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected API keys')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Delete selected API keys')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</BulkActionsToolbar>
|
||||
|
||||
<ApiKeysMultiDeleteDialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
table={table}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Row } from '@tanstack/react-table'
|
||||
import {
|
||||
Trash2,
|
||||
Edit,
|
||||
Power,
|
||||
PowerOff,
|
||||
ExternalLink,
|
||||
ArrowRightLeft,
|
||||
Copy,
|
||||
Link,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuShortcut,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
|
||||
import { resolveChatUrl, type ChatPreset } from '@/features/chat/lib/chat-links'
|
||||
import { sendToFluent } from '@/features/chat/lib/send-to-fluent'
|
||||
import { encodeChannelConnectionInfo } from '@/lib/channel-connection-info'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
|
||||
import { updateApiKeyStatus } from '../api'
|
||||
import { API_KEY_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import { apiKeySchema } from '../types'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
function getServerAddress(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem('status')
|
||||
if (raw) {
|
||||
const status = JSON.parse(raw)
|
||||
if (status.server_address) return status.server_address as string
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
type DataTableRowActionsProps<TData> = {
|
||||
row: Row<TData>
|
||||
}
|
||||
|
||||
export function DataTableRowActions<TData>({
|
||||
row,
|
||||
}: DataTableRowActionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const apiKey = apiKeySchema.parse(row.original)
|
||||
const {
|
||||
setOpen,
|
||||
setCurrentRow,
|
||||
triggerRefresh,
|
||||
setResolvedKey,
|
||||
resolveRealKey,
|
||||
resolvedKeys,
|
||||
loadingKeys,
|
||||
} = useApiKeys()
|
||||
const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED
|
||||
const { chatPresets, serverAddress } = useChatPresets()
|
||||
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
|
||||
const resolvedRealKey = resolvedKeys[apiKey.id]
|
||||
const isRealKeyLoading = Boolean(loadingKeys[apiKey.id])
|
||||
|
||||
const hasChatPresets = chatPresets.length > 0
|
||||
const toggleLabel = isEnabled ? t('Disable') : t('Enable')
|
||||
|
||||
const handleMenuOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open && !resolvedRealKey && !isRealKeyLoading) {
|
||||
void resolveRealKey(apiKey.id)
|
||||
}
|
||||
},
|
||||
[apiKey.id, isRealKeyLoading, resolvedRealKey, resolveRealKey]
|
||||
)
|
||||
|
||||
const getCachedRealKey = useCallback(() => {
|
||||
if (resolvedRealKey) return resolvedRealKey
|
||||
void resolveRealKey(apiKey.id)
|
||||
toast.info(t('API key is loading, please try again in a moment'))
|
||||
return null
|
||||
}, [apiKey.id, resolvedRealKey, resolveRealKey, t])
|
||||
|
||||
const handleOpenChatPreset = useCallback(
|
||||
async (preset: ChatPreset) => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
if (!realKey) return
|
||||
|
||||
if (preset.type === 'fluent') {
|
||||
const success = sendToFluent(realKey, serverAddress)
|
||||
if (success) {
|
||||
toast.success(t('Sent the API key to FluentRead.'))
|
||||
} else {
|
||||
toast.info(
|
||||
t(
|
||||
'FluentRead extension not detected. Please ensure it is installed and active.'
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedUrl = resolveChatUrl({
|
||||
template: preset.url,
|
||||
apiKey: realKey,
|
||||
serverAddress,
|
||||
})
|
||||
|
||||
if (!resolvedUrl) {
|
||||
toast.error(t('Invalid chat link. Please contact your administrator.'))
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
window.open(resolvedUrl, '_blank', 'noopener')
|
||||
} catch {
|
||||
window.location.href = resolvedUrl
|
||||
}
|
||||
},
|
||||
[resolveRealKey, apiKey.id, serverAddress, t]
|
||||
)
|
||||
|
||||
const handleToggleStatus = async (
|
||||
e?: React.MouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
e?.stopPropagation()
|
||||
const newStatus = isEnabled
|
||||
? API_KEY_STATUS.DISABLED
|
||||
: API_KEY_STATUS.ENABLED
|
||||
|
||||
setIsTogglingStatus(true)
|
||||
try {
|
||||
const result = await updateApiKeyStatus(apiKey.id, newStatus)
|
||||
if (result.success) {
|
||||
const message = isEnabled
|
||||
? t(SUCCESS_MESSAGES.API_KEY_DISABLED)
|
||||
: t(SUCCESS_MESSAGES.API_KEY_ENABLED)
|
||||
toast.success(message)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.STATUS_UPDATE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
let statusIcon = <Power className='size-4' />
|
||||
if (isTogglingStatus) {
|
||||
statusIcon = <Loader2 className='size-4 animate-spin' />
|
||||
} else if (isEnabled) {
|
||||
statusIcon = <PowerOff className='size-4' />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-ml-1.5 flex items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={toggleLabel}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{statusIcon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{toggleLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={() => {
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('update')
|
||||
}}
|
||||
aria-label={t('Edit')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Edit />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Edit')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DataTableRowActionMenu
|
||||
ariaLabel={t('Open menu')}
|
||||
contentClassName='w-[200px]'
|
||||
modal={false}
|
||||
onOpenChange={handleMenuOpenChange}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = getCachedRealKey()
|
||||
if (!realKey) return
|
||||
const ok = await copyToClipboard(realKey)
|
||||
if (ok) toast.success(t('Copied'))
|
||||
}}
|
||||
>
|
||||
{t('Copy Key')}
|
||||
<DropdownMenuShortcut>
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = getCachedRealKey()
|
||||
if (!realKey) return
|
||||
const connStr = encodeChannelConnectionInfo(
|
||||
realKey,
|
||||
getServerAddress()
|
||||
)
|
||||
const ok = await copyToClipboard(connStr)
|
||||
if (ok) toast.success(t('Copied'))
|
||||
}}
|
||||
>
|
||||
{t('Copy Connection Info')}
|
||||
<DropdownMenuShortcut>
|
||||
<Link size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
if (!realKey) return
|
||||
setResolvedKey(realKey)
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('cc-switch')
|
||||
}}
|
||||
>
|
||||
{t('CC Switch')}
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowRightLeft size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{hasChatPresets && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>{t('Chat')}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{chatPresets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => handleOpenChatPreset(preset)}
|
||||
>
|
||||
{preset.name}
|
||||
{preset.type !== 'web' && (
|
||||
<DropdownMenuShortcut>
|
||||
<ExternalLink size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('delete')
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DataTableRowActionMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ComboboxInput } from '@/components/ui/combobox-input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { getUserModels } from '@/lib/api'
|
||||
|
||||
const APP_CONFIGS = {
|
||||
claude: {
|
||||
label: 'Claude',
|
||||
defaultName: 'My Claude',
|
||||
modelFields: [
|
||||
{ key: 'model', labelKey: 'Primary Model', required: true },
|
||||
{ key: 'haikuModel', labelKey: 'Haiku Model', required: false },
|
||||
{ key: 'sonnetModel', labelKey: 'Sonnet Model', required: false },
|
||||
{ key: 'opusModel', labelKey: 'Opus Model', required: false },
|
||||
],
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
defaultName: 'My Codex',
|
||||
modelFields: [{ key: 'model', labelKey: 'Primary Model', required: true }],
|
||||
},
|
||||
gemini: {
|
||||
label: 'Gemini',
|
||||
defaultName: 'My Gemini',
|
||||
modelFields: [{ key: 'model', labelKey: 'Primary Model', required: true }],
|
||||
},
|
||||
} as const
|
||||
|
||||
type AppType = keyof typeof APP_CONFIGS
|
||||
|
||||
function getServerAddress(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem('status')
|
||||
if (raw) {
|
||||
const status = JSON.parse(raw)
|
||||
if (status.server_address) return status.server_address
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
function buildCCSwitchURL(
|
||||
app: string,
|
||||
name: string,
|
||||
models: Record<string, string>,
|
||||
apiKey: string
|
||||
): string {
|
||||
const serverAddress = getServerAddress()
|
||||
const endpoint = app === 'codex' ? serverAddress + '/v1' : serverAddress
|
||||
const params = new URLSearchParams()
|
||||
params.set('resource', 'provider')
|
||||
params.set('app', app)
|
||||
params.set('name', name)
|
||||
params.set('endpoint', endpoint)
|
||||
params.set('apiKey', apiKey)
|
||||
for (const [k, v] of Object.entries(models)) {
|
||||
if (v) params.set(k, v)
|
||||
}
|
||||
params.set('homepage', serverAddress)
|
||||
params.set('enabled', 'true')
|
||||
return `ccswitch://v1/import?${params.toString()}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
tokenKey: string
|
||||
}
|
||||
|
||||
export function CCSwitchDialog(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [app, setApp] = useState<AppType>('claude')
|
||||
const [name, setName] = useState<string>(APP_CONFIGS.claude.defaultName)
|
||||
const [models, setModels] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: modelsData } = useQuery({
|
||||
queryKey: ['user-models-ccswitch'],
|
||||
queryFn: getUserModels,
|
||||
enabled: props.open,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const items = modelsData?.data ?? []
|
||||
return items.map((m) => ({ value: m, label: m }))
|
||||
}, [modelsData?.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setModels({})
|
||||
|
||||
setApp('claude')
|
||||
|
||||
setName(APP_CONFIGS.claude.defaultName)
|
||||
}
|
||||
}, [props.open])
|
||||
|
||||
const currentConfig = APP_CONFIGS[app]
|
||||
|
||||
const handleAppChange = (val: string) => {
|
||||
const appVal = val as AppType
|
||||
setApp(appVal)
|
||||
setName(APP_CONFIGS[appVal].defaultName)
|
||||
setModels({})
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!models.model) {
|
||||
toast.warning(t('Please select a primary model'))
|
||||
return
|
||||
}
|
||||
const key = props.tokenKey.startsWith('sk-')
|
||||
? props.tokenKey
|
||||
: `sk-${props.tokenKey}`
|
||||
const url = buildCCSwitchURL(app, name, models, key)
|
||||
window.open(url, '_blank')
|
||||
props.onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={t('Import to CC Switch')}
|
||||
contentClassName='sm:max-w-md'
|
||||
contentHeight='auto'
|
||||
bodyClassName={
|
||||
currentConfig.modelFields.length === 1 ? 'space-y-4 pb-52' : 'space-y-4'
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>{t('Open CC Switch')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Application')}</Label>
|
||||
<RadioGroup
|
||||
value={app}
|
||||
onValueChange={handleAppChange}
|
||||
className='flex gap-4'
|
||||
>
|
||||
{(
|
||||
Object.entries(APP_CONFIGS) as [
|
||||
AppType,
|
||||
(typeof APP_CONFIGS)[AppType],
|
||||
][]
|
||||
).map(([key, cfg]) => (
|
||||
<div key={key} className='flex items-center gap-2'>
|
||||
<RadioGroupItem value={key} id={`app-${key}`} />
|
||||
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
|
||||
{cfg.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Name')}</Label>
|
||||
<ComboboxInput
|
||||
options={[]}
|
||||
value={name}
|
||||
onValueChange={setName}
|
||||
placeholder={currentConfig.defaultName}
|
||||
emptyText=''
|
||||
allowCustomValue={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentConfig.modelFields.map((field) => (
|
||||
<div key={field.key} className='space-y-2'>
|
||||
<Label>
|
||||
{t(field.labelKey)}
|
||||
{field.required && (
|
||||
<span className='text-destructive ml-0.5'>*</span>
|
||||
)}
|
||||
</Label>
|
||||
<ComboboxInput
|
||||
options={modelOptions}
|
||||
value={models[field.key] || ''}
|
||||
onValueChange={(v) =>
|
||||
setModels((prev) => ({ ...prev, [field.key]: v }))
|
||||
}
|
||||
placeholder={t('Select or enter model name')}
|
||||
emptyText={t('No models found')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Vendored
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type StatusBadgeProps } from '@/components/status-badge'
|
||||
|
||||
// ============================================================================
|
||||
// API Key Status Configuration
|
||||
// label values are i18n keys; use t(config.label) in components (e.g. StatusBadge)
|
||||
// ============================================================================
|
||||
|
||||
export const API_KEY_STATUS = {
|
||||
ENABLED: 1,
|
||||
DISABLED: 2,
|
||||
EXPIRED: 3,
|
||||
EXHAUSTED: 4,
|
||||
} as const
|
||||
|
||||
export const API_KEY_STATUSES: Record<
|
||||
number,
|
||||
Pick<StatusBadgeProps, 'variant'> & {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
> = {
|
||||
[API_KEY_STATUS.ENABLED]: {
|
||||
label: 'Enabled',
|
||||
variant: 'success',
|
||||
value: API_KEY_STATUS.ENABLED,
|
||||
},
|
||||
[API_KEY_STATUS.DISABLED]: {
|
||||
label: 'Disabled',
|
||||
variant: 'neutral',
|
||||
value: API_KEY_STATUS.DISABLED,
|
||||
},
|
||||
[API_KEY_STATUS.EXPIRED]: {
|
||||
label: 'Expired',
|
||||
variant: 'warning',
|
||||
value: API_KEY_STATUS.EXPIRED,
|
||||
},
|
||||
[API_KEY_STATUS.EXHAUSTED]: {
|
||||
label: 'Exhausted',
|
||||
variant: 'danger',
|
||||
value: API_KEY_STATUS.EXHAUSTED,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const API_KEY_STATUS_OPTIONS = Object.values(API_KEY_STATUSES).map(
|
||||
(config) => ({
|
||||
label: config.label,
|
||||
value: String(config.value),
|
||||
})
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_GROUP = '' as const
|
||||
|
||||
// ============================================================================
|
||||
// Error Messages (i18n keys: use t(ERROR_MESSAGES.xxx) when displaying)
|
||||
// ============================================================================
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
UNEXPECTED: 'An unexpected error occurred',
|
||||
LOAD_FAILED: 'Failed to load API keys',
|
||||
SEARCH_FAILED: 'Failed to search API keys',
|
||||
CREATE_FAILED: 'Failed to create API key',
|
||||
UPDATE_FAILED: 'Failed to update API key',
|
||||
DELETE_FAILED: 'Failed to delete API key',
|
||||
BATCH_DELETE_FAILED: 'Failed to delete API keys',
|
||||
STATUS_UPDATE_FAILED: 'Failed to update API key status',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Success Messages (i18n keys: use t(SUCCESS_MESSAGES.xxx) when displaying)
|
||||
// ============================================================================
|
||||
|
||||
export const SUCCESS_MESSAGES = {
|
||||
API_KEY_CREATED: 'API Key created successfully',
|
||||
API_KEY_UPDATED: 'API Key updated successfully',
|
||||
API_KEY_DELETED: 'API Key deleted successfully',
|
||||
API_KEY_ENABLED: 'API Key enabled successfully',
|
||||
API_KEY_DISABLED: 'API Key disabled successfully',
|
||||
} as const
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
|
||||
import { ApiKeysDialogs } from './components/api-keys-dialogs'
|
||||
import { ApiKeysPrimaryButtons } from './components/api-keys-primary-buttons'
|
||||
import { ApiKeysProvider } from './components/api-keys-provider'
|
||||
import { ApiKeysTable } from './components/api-keys-table'
|
||||
|
||||
export function ApiKeys() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<ApiKeysProvider>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<ApiKeysPrimaryButtons />
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<ApiKeysTable />
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
<ApiKeysDialogs />
|
||||
</ApiKeysProvider>
|
||||
)
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { TFunction } from 'i18next'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
|
||||
|
||||
import { DEFAULT_GROUP } from '../constants'
|
||||
import { type ApiKeyFormData, type ApiKey } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Form Schema
|
||||
// ============================================================================
|
||||
|
||||
export function getApiKeyFormSchema(t: TFunction) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string().min(1, t('Please enter a name')),
|
||||
remain_quota_dollars: z.number().optional(),
|
||||
expired_time: z.date().optional(),
|
||||
unlimited_quota: z.boolean(),
|
||||
model_limits: z.array(z.string()),
|
||||
allow_ips: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
cross_group_retry: z.boolean().optional(),
|
||||
tokenCount: z.number().min(1).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.unlimited_quota) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
data.remain_quota_dollars === undefined ||
|
||||
data.remain_quota_dollars < 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['remain_quota_dollars'],
|
||||
message: t('Quota must be zero or greater'),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type ApiKeyFormValues = z.infer<ReturnType<typeof getApiKeyFormSchema>>
|
||||
|
||||
// ============================================================================
|
||||
// Form Defaults
|
||||
// ============================================================================
|
||||
|
||||
export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = {
|
||||
name: '',
|
||||
remain_quota_dollars: 10,
|
||||
expired_time: undefined,
|
||||
unlimited_quota: true,
|
||||
model_limits: [],
|
||||
allow_ips: '',
|
||||
group: DEFAULT_GROUP,
|
||||
cross_group_retry: true,
|
||||
tokenCount: 1,
|
||||
}
|
||||
|
||||
export function getApiKeyFormDefaultValues(
|
||||
defaultUseAutoGroup: boolean
|
||||
): ApiKeyFormValues {
|
||||
return {
|
||||
...API_KEY_FORM_DEFAULT_VALUES,
|
||||
group: defaultUseAutoGroup ? 'auto' : DEFAULT_GROUP,
|
||||
cross_group_retry: defaultUseAutoGroup,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Data Transformation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transform form data to API payload
|
||||
*/
|
||||
export function transformFormDataToPayload(
|
||||
data: ApiKeyFormValues
|
||||
): ApiKeyFormData {
|
||||
return {
|
||||
name: data.name,
|
||||
remain_quota: data.unlimited_quota
|
||||
? 0
|
||||
: parseQuotaFromDollars(data.remain_quota_dollars || 0),
|
||||
expired_time: data.expired_time
|
||||
? Math.floor(data.expired_time.getTime() / 1000)
|
||||
: -1,
|
||||
unlimited_quota: data.unlimited_quota,
|
||||
model_limits_enabled: data.model_limits.length > 0,
|
||||
model_limits: data.model_limits.join(','),
|
||||
allow_ips: data.allow_ips || '',
|
||||
group: data.group || '',
|
||||
cross_group_retry: data.group === 'auto' ? !!data.cross_group_retry : false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API key data to form defaults
|
||||
*/
|
||||
export function transformApiKeyToFormDefaults(
|
||||
apiKey: ApiKey
|
||||
): ApiKeyFormValues {
|
||||
return {
|
||||
name: apiKey.name,
|
||||
remain_quota_dollars: apiKey.unlimited_quota
|
||||
? 0
|
||||
: quotaUnitsToDollars(apiKey.remain_quota),
|
||||
expired_time:
|
||||
apiKey.expired_time > 0
|
||||
? new Date(apiKey.expired_time * 1000)
|
||||
: undefined,
|
||||
unlimited_quota: apiKey.unlimited_quota,
|
||||
model_limits: apiKey.model_limits
|
||||
? apiKey.model_limits.split(',').filter(Boolean)
|
||||
: [],
|
||||
allow_ips: apiKey.allow_ips || '',
|
||||
group: apiKey.group || DEFAULT_GROUP,
|
||||
cross_group_retry: !!apiKey.cross_group_retry,
|
||||
tokenCount: 1,
|
||||
}
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// ============================================================================
|
||||
// Form Utilities
|
||||
// ============================================================================
|
||||
export {
|
||||
getApiKeyFormSchema,
|
||||
type ApiKeyFormValues,
|
||||
API_KEY_FORM_DEFAULT_VALUES,
|
||||
getApiKeyFormDefaultValues,
|
||||
transformFormDataToPayload,
|
||||
transformApiKeyToFormDefaults,
|
||||
} from './api-key-form'
|
||||
Vendored
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
// ============================================================================
|
||||
// API Key Schema & Types
|
||||
// ============================================================================
|
||||
|
||||
export const apiKeySchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
key: z.string(),
|
||||
status: z.number(), // 1: enabled, 2: disabled, 3: expired, 4: exhausted
|
||||
remain_quota: z.number(),
|
||||
used_quota: z.number(),
|
||||
unlimited_quota: z.boolean(),
|
||||
expired_time: z.number(), // -1 for never expires
|
||||
created_time: z.number(),
|
||||
accessed_time: z.number(),
|
||||
group: z.string().nullish().default(''),
|
||||
cross_group_retry: z
|
||||
.preprocess((v) => {
|
||||
if (v === 1) return true
|
||||
if (v === 0) return false
|
||||
return v
|
||||
}, z.boolean())
|
||||
.optional()
|
||||
.default(false),
|
||||
model_limits_enabled: z.boolean(),
|
||||
model_limits: z.string().nullish().default(''),
|
||||
allow_ips: z.string().nullish().default(''),
|
||||
})
|
||||
|
||||
export type ApiKey = z.infer<typeof apiKeySchema>
|
||||
|
||||
// ============================================================================
|
||||
// API Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface GetApiKeysParams {
|
||||
p?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface GetApiKeysResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
items: ApiKey[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchApiKeysParams {
|
||||
keyword?: string
|
||||
token?: string
|
||||
p?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface ApiKeyFormData {
|
||||
name: string
|
||||
remain_quota: number
|
||||
expired_time: number
|
||||
unlimited_quota: boolean
|
||||
model_limits_enabled: boolean
|
||||
model_limits: string
|
||||
allow_ips: string
|
||||
group: string
|
||||
cross_group_retry: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dialog Types
|
||||
// ============================================================================
|
||||
|
||||
export type ApiKeysDialogType =
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'batch-delete'
|
||||
| 'cc-switch'
|
||||
Reference in New Issue
Block a user