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:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+118
View File
@@ -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
View File
@@ -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>
)
}
+336
View File
@@ -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>
)
}
+42
View File
@@ -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>
)
}
+190
View File
@@ -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
View File
@@ -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>
)
}
+100
View File
@@ -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
+45
View File
@@ -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
View File
@@ -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,
}
}
+29
View File
@@ -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'
+106
View File
@@ -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'