Commit 2bec3706 by CaIon

feat(web): refine API key and user quota displays

Show remaining and used API key quota with a progress bar, and use consistent mobile cards, group multiplier badges, and activity timestamps. Present available user balance with used quota underneath and translate the new labels.

Resolve full API keys only for explicit copy or chat actions. Reviewed OWASP Authentication and Session Management guidance and ASVS 5.0.0 V14.2.6 and V8.3.1; backend authorization is unchanged, and regression tests cover refused and denied key resolution. This frontend change does not assert application-wide ASVS compliance.

Validation: 55 related component tests passed; the latest mobile group and quota changes passed 35 focused tests. TypeScript, scoped lint, formatting, production build, and git diff checks passed. Responsive previews verified narrow screens and finite, unlimited, exhausted, and inactive quota states.
parent a5e41a89
...@@ -148,7 +148,7 @@ function SplitHeaderTableView<TData>({ ...@@ -148,7 +148,7 @@ function SplitHeaderTableView<TData>({
<table <table
data-slot='table' data-slot='table'
className={cn( className={cn(
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm', 'w-full caption-bottom text-sm tabular-nums',
props.tableClassName props.tableClassName
)} )}
style={tableSizing.style} style={tableSizing.style}
......
...@@ -33,6 +33,7 @@ type TruncatedCellProps = { ...@@ -33,6 +33,7 @@ type TruncatedCellProps = {
side?: 'top' | 'bottom' | 'left' | 'right' side?: 'top' | 'bottom' | 'left' | 'right'
tooltipClassName?: string tooltipClassName?: string
tooltipContent?: React.ReactNode tooltipContent?: React.ReactNode
tabIndex?: number
} }
export function TruncatedCell({ export function TruncatedCell({
...@@ -43,12 +44,14 @@ export function TruncatedCell({ ...@@ -43,12 +44,14 @@ export function TruncatedCell({
side = 'top', side = 'top',
tooltipClassName, tooltipClassName,
tooltipContent, tooltipContent,
tabIndex,
}: TruncatedCellProps) { }: TruncatedCellProps) {
const content = tooltipContent ?? getTextContent(children) const content = tooltipContent ?? getTextContent(children)
if (!content) { if (!content) {
return ( return (
<div <div
tabIndex={tabIndex}
className={cn( className={cn(
'block max-w-full min-w-0 truncate', 'block max-w-full min-w-0 truncate',
cellClassName, cellClassName,
...@@ -65,6 +68,7 @@ export function TruncatedCell({ ...@@ -65,6 +68,7 @@ export function TruncatedCell({
<TooltipTrigger <TooltipTrigger
render={ render={
<div <div
tabIndex={tabIndex}
className={cn( className={cn(
'block max-w-full min-w-0 truncate', 'block max-w-full min-w-0 truncate',
cellClassName, cellClassName,
......
...@@ -16,11 +16,43 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,11 +16,43 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge' import { StatusBadge, type StatusBadgeProps } from './status-badge'
import { Badge } from './ui/badge'
export function GroupMultiplierBadge(props: {
children?: ReactNode
className?: string
label?: string
ratio?: number | null
}) {
let colorClassName =
'border-muted-foreground/30 bg-muted text-muted-foreground'
if (props.ratio != null && props.ratio > 1) {
colorClassName = 'border-warning/30 bg-warning/10 text-warning'
} else if (props.ratio != null && props.ratio < 1) {
colorClassName = 'border-info/30 bg-info/10 text-info'
}
return (
<Badge
variant='outline'
className={cn(
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-xs leading-none font-medium shadow-none',
!props.label && 'font-mono tabular-nums',
colorClassName,
props.className
)}
>
{props.children}
<span>{props.label ?? `${props.ratio}x`}</span>
</Badge>
)
}
type GroupBadgeProps = Omit< type GroupBadgeProps = Omit<
StatusBadgeProps, StatusBadgeProps,
...@@ -29,16 +61,8 @@ type GroupBadgeProps = Omit< ...@@ -29,16 +61,8 @@ type GroupBadgeProps = Omit<
group?: string | null group?: string | null
label?: string label?: string
ratio?: number | null ratio?: number | null
} ratioLabel?: string
containerClassName?: string
function getGroupRatioClassName(ratio: number): string {
if (ratio > 1) {
return 'bg-warning/10 text-warning'
}
if (ratio < 1) {
return 'bg-info/10 text-info'
}
return 'bg-muted text-muted-foreground'
} }
function getGroupLabel(params: { function getGroupLabel(params: {
...@@ -60,6 +84,8 @@ export function GroupBadge(props: GroupBadgeProps) { ...@@ -60,6 +84,8 @@ export function GroupBadge(props: GroupBadgeProps) {
group, group,
label: labelOverride, label: labelOverride,
ratio, ratio,
ratioLabel,
containerClassName,
copyable = false, copyable = false,
showDot, showDot,
className, className,
...@@ -89,21 +115,19 @@ export function GroupBadge(props: GroupBadgeProps) { ...@@ -89,21 +115,19 @@ export function GroupBadge(props: GroupBadgeProps) {
/> />
) )
if (ratio == null) { if (ratio == null && !ratioLabel) {
return badge return badge
} }
return ( return (
<span className='inline-flex max-w-full min-w-0 items-center gap-2 text-xs'> <span
className={cn(
'inline-flex max-w-full min-w-0 items-center gap-2 text-xs',
containerClassName
)}
>
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span> <span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
<span <GroupMultiplierBadge ratio={ratio} label={ratioLabel} />
className={cn(
'inline-flex h-5 shrink-0 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
getGroupRatioClassName(ratio)
)}
>
<span>{ratio}x</span>
</span>
</span> </span>
) )
} }
...@@ -30,10 +30,7 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) { ...@@ -30,10 +30,7 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
> >
<table <table
data-slot='table' data-slot='table'
className={cn( className={cn('w-full caption-bottom text-sm tabular-nums', className)}
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
className
)}
{...props} {...props}
/> />
</div> </div>
......
...@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { render } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test } from 'vitest' import { describe, expect, test } from 'vitest'
const { createInstance } = await import('i18next') const { createInstance } = await import('i18next')
...@@ -61,93 +62,99 @@ function CellHarness(props: { ...@@ -61,93 +62,99 @@ function CellHarness(props: {
} }
describe('API key group table cell', () => { describe('API key group table cell', () => {
test('renders an unclipped ring and a localized Auto ratio when API data uses a nonlocalized string', () => { test('keeps the group and compact localized multiplier together with one subtle flowing edge', () => {
const { container } = render( const { container } = render(
<CellHarness <CellHarness group='auto' ratio='自动' crossGroupRetry />
group='auto'
ratio='自动'
crossGroupRetry
shouldReduceMotion={false}
/>
) )
const group = screen.getByText('Cross-group')
const badgeCell = container.querySelector<HTMLElement>( const multiplier = screen
'[data-api-key-group-cell="auto"]' .getByText('Auto')
) .closest<HTMLElement>('[data-slot="badge"]')
expect(badgeCell).toHaveClass('overflow-visible') expect(group).toBeInTheDocument()
expect(badgeCell).not.toHaveClass('overflow-hidden') expect(multiplier).toHaveClass('h-5', 'min-w-12', 'rounded-md')
expect(multiplier).not.toHaveTextContent('Ratio')
const frames = container.querySelectorAll('[data-auto-group-frame]')
const movingRings = container.querySelectorAll(
'[data-auto-group-flow-border]'
)
expect(frames.length).toBe(1)
expect(movingRings.length).toBe(1)
for (const frame of frames) {
expect(frame).toHaveClass(
'relative',
'overflow-visible',
'rounded-4xl',
'p-px'
)
}
const ratio = container.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
expect(ratio).toHaveTextContent('Auto Ratio')
expect(ratio).not.toHaveTextContent('x')
expect(container).not.toHaveTextContent('自动') expect(container).not.toHaveTextContent('自动')
expect(container).toHaveTextContent('Cross-group') expect(container.querySelector('[data-auto-group-frame]')).toBeNull()
const flow = container.querySelector('[data-auto-group-flow-border]')
const crossGroupBadge = [ expect(flow).toHaveClass('auto-group-flow-border-subtle')
...container.querySelectorAll<HTMLElement>('[data-slot="status-badge"]'), expect(flow).toHaveAttribute('aria-hidden', 'true')
].find((badge) => badge.textContent === 'Cross-group') expect(group.closest('[data-api-key-group-cell]')).toContainElement(
expect(crossGroupBadge).not.toBeUndefined() multiplier
expect(crossGroupBadge?.closest('[data-auto-group-frame]')).toBeNull() )
}) })
test('keeps the static Auto ratio frame but omits its moving layer for reduced motion', () => { test('keeps the automatic tag visible but static when reduced motion is requested', () => {
const { container } = render( const { container } = render(
<CellHarness group='auto' ratio='Auto' shouldReduceMotion /> <CellHarness group='auto' ratio='Auto' shouldReduceMotion />
) )
expect(screen.getByText('Auto')).toBeInTheDocument()
expect(container.querySelector('[data-auto-group-flow-border]')).toBeNull()
})
expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(1) test('does not invent a multiplier while automatic ratio data is unavailable', () => {
expect( render(<CellHarness group='auto' />)
container.querySelectorAll('[data-auto-group-flow-border]').length expect(screen.getByText('Cross-group')).toBeInTheDocument()
).toBe(0) expect(screen.queryByText('Auto')).not.toBeInTheDocument()
}) })
test('shows only the cross-group badge when ratio data is unavailable', () => { test.each([
const { container } = render( [0.8, 'bg-info/10', 'text-info', 'border-info/30'],
<CellHarness group='auto' shouldReduceMotion={false} /> [1, 'bg-muted', 'text-muted-foreground', 'border-muted-foreground/30'],
) [3, 'bg-warning/10', 'text-warning', 'border-warning/30'],
])(
'preserves the original %s multiplier color in the compact layout',
(ratio, background, color, border) => {
const { container } = render(
<CellHarness group='default' ratio={ratio} />
)
const multiplier = screen.getByText(`${ratio}x`).parentElement
expect(multiplier).toHaveClass(
background,
color,
border,
'rounded-full',
'font-mono',
'h-5',
'min-w-12'
)
expect(
container.querySelector('[data-auto-group-flow-border]')
).toBeNull()
}
)
expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(0) test('labels the user group multiplier as inherited without inventing a numeric value', async () => {
expect( render(<CellHarness group='' />)
container.querySelectorAll('[data-auto-group-flow-border]').length expect(screen.getByText('User Group')).toBeInTheDocument()
).toBe(0) expect(screen.getByText('Inherited')).toBeInTheDocument()
expect(container.querySelector('[data-auto-group-effect="ratio"]')).toBe( expect(screen.getByText('Inherited').parentElement).toHaveClass(
null 'border-muted-foreground/30',
'rounded-full'
) )
expect(container).toHaveTextContent('Cross-group') expect(screen.queryByText('1x')).not.toBeInTheDocument()
expect(container).not.toHaveTextContent('Auto') await userEvent.tab()
expect(container).not.toHaveTextContent('Ratio') expect(await screen.findByText('Follow user group')).toBeVisible()
}) })
test('narrows normal group ratios to numbers and never applies Auto rings', () => { test('keeps a long group name and exact multiplier available through keyboard focus', async () => {
const { container, rerender } = render( const groupName = 'production-with-a-very-long-custom-group-name'
<CellHarness group='vip' ratio='自动' shouldReduceMotion={false} /> render(<CellHarness group={groupName} ratio={12.345678} />)
) expect(
screen.getByText(groupName).closest('[data-slot="tooltip-trigger"]')
expect(container).toHaveTextContent('vip') ).toHaveClass('max-w-50')
expect(container).not.toHaveTextContent('自动') expect(screen.getByText('12.345678x')).toBeInTheDocument()
expect(container.querySelector('[data-auto-group-frame]')).toBe(null) await userEvent.tab()
expect(container.querySelector('[data-auto-group-flow-border]')).toBe(null) expect(
await screen.findByText(groupName, {
rerender(<CellHarness group='vip' ratio={3} shouldReduceMotion={false} />) selector: '[data-slot="tooltip-content"]',
})
).toBeVisible()
})
expect(container).toHaveTextContent('3x') test('never turns a string-valued normal group ratio into an automatic multiplier', () => {
expect(container.querySelector('[data-auto-group-frame]')).toBe(null) render(<CellHarness group='vip' ratio='自动' />)
expect(screen.getByText('vip')).toBeInTheDocument()
expect(screen.queryByText('Auto')).not.toBeInTheDocument()
expect(screen.queryByText('自动')).not.toBeInTheDocument()
}) })
}) })
...@@ -97,7 +97,7 @@ function getCommandItem(label: string): HTMLElement { ...@@ -97,7 +97,7 @@ function getCommandItem(label: string): HTMLElement {
} }
describe('API key group combobox Auto effect', () => { describe('API key group combobox Auto effect', () => {
test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', () => { test('uses the compact table capsules in the selected group and dropdown options', () => {
setReducedMotion(false) setReducedMotion(false)
render(<Harness initialValue='auto' />) render(<Harness initialValue='auto' />)
...@@ -116,20 +116,23 @@ describe('API key group combobox Auto effect', () => { ...@@ -116,20 +116,23 @@ describe('API key group combobox Auto effect', () => {
'auto-group-flow-border' 'auto-group-flow-border'
) )
const triggerRatio = trigger.querySelector<HTMLElement>( const triggerRatio = within(trigger)
'[data-auto-group-effect="ratio"]' .getByText('Auto')
) .closest('[data-slot="badge"]')
expect(triggerRatio).toHaveTextContent('Auto Ratio') expect(triggerRatio).toHaveTextContent('Auto')
expect(triggerRatio).not.toHaveTextContent('Ratio')
expect(triggerRatio).not.toHaveTextContent('x') expect(triggerRatio).not.toHaveTextContent('x')
expect(trigger).not.toHaveTextContent('自动') expect(trigger).not.toHaveTextContent('自动')
expect(triggerRatio).toHaveClass( expect(triggerRatio).toHaveClass(
'relative', 'relative',
'overflow-visible', 'overflow-visible',
'rounded-4xl' 'rounded-md',
'h-5',
'min-w-12'
) )
expect( expect(
triggerRatio?.querySelector('[data-auto-group-flow-border]') triggerRatio?.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument() ).toHaveClass('auto-group-flow-border-subtle')
fireEvent.click(trigger) fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true') expect(trigger).toHaveAttribute('aria-expanded', 'true')
...@@ -142,20 +145,31 @@ describe('API key group combobox Auto effect', () => { ...@@ -142,20 +145,31 @@ describe('API key group combobox Auto effect', () => {
expect( expect(
autoOption.querySelector('[data-auto-group-flow-border]') autoOption.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument() ).toBeInTheDocument()
const optionRatio = autoOption.querySelector<HTMLElement>( const optionRatio = within(autoOption)
'[data-auto-group-effect="ratio"]' .getByText('Auto')
) .closest('[data-slot="badge"]')
expect(optionRatio).toHaveTextContent('Auto Ratio') expect(optionRatio).toHaveTextContent('Auto')
expect(optionRatio).not.toHaveTextContent('Ratio')
expect( expect(
optionRatio?.querySelector('[data-auto-group-flow-border]') optionRatio?.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument() ).toHaveClass('auto-group-flow-border-subtle')
const defaultOption = getCommandItem('User group') const defaultOption = getCommandItem('User group')
expect(defaultOption).not.toHaveAttribute('data-auto-group-effect') expect(defaultOption).not.toHaveAttribute('data-auto-group-effect')
expect(defaultOption.querySelector('[data-auto-group-flow-border]')).toBe( expect(defaultOption.querySelector('[data-auto-group-flow-border]')).toBe(
null null
) )
expect(defaultOption).toHaveTextContent('1x Ratio') const defaultRatio = within(defaultOption)
.getByText('1x')
.closest('[data-slot="badge"]')
expect(defaultRatio).toHaveClass(
'h-5',
'min-w-12',
'rounded-full',
'font-mono',
'border-muted-foreground/30'
)
expect(defaultRatio).not.toHaveTextContent('Ratio')
expect( expect(
defaultOption.querySelector('[data-auto-group-effect="ratio"]') defaultOption.querySelector('[data-auto-group-effect="ratio"]')
).toBe(null) ).toBe(null)
...@@ -198,17 +212,13 @@ describe('API key group combobox Auto effect', () => { ...@@ -198,17 +212,13 @@ describe('API key group combobox Auto effect', () => {
const trigger = getTrigger() const trigger = getTrigger()
expect(trigger).toHaveAttribute('data-auto-group-effect', 'trigger') expect(trigger).toHaveAttribute('data-auto-group-effect', 'trigger')
expect(trigger.querySelector('[data-auto-group-flow-border]')).toBe(null) expect(trigger.querySelector('[data-auto-group-flow-border]')).toBe(null)
expect( expect(within(trigger).getByText('Auto')).toBeInTheDocument()
trigger.querySelector('[data-auto-group-effect="ratio"]')
).toBeInTheDocument()
fireEvent.click(trigger) fireEvent.click(trigger)
const autoOption = getCommandItem('Global automatic routing') const autoOption = getCommandItem('Global automatic routing')
expect(autoOption).toHaveAttribute('data-auto-group-effect', 'option') expect(autoOption).toHaveAttribute('data-auto-group-effect', 'option')
expect(autoOption.querySelector('[data-auto-group-flow-border]')).toBe(null) expect(autoOption.querySelector('[data-auto-group-flow-border]')).toBe(null)
expect( expect(within(autoOption).getByText('Auto')).toBeInTheDocument()
autoOption.querySelector('[data-auto-group-effect="ratio"]')
).toBeInTheDocument()
setReducedMotion(false) setReducedMotion(false)
}) })
}) })
...@@ -256,21 +256,21 @@ describe('Auto group order editor', () => { ...@@ -256,21 +256,21 @@ describe('Auto group order editor', () => {
name: 'VIP', name: 'VIP',
title: 'Priority access', title: 'Priority access',
description: 'Priority access', description: 'Priority access',
ratio: '3x Ratio', ratio: '3x',
}, },
{ {
index: '2', index: '2',
name: 'Default', name: 'Default',
title: 'Standard access', title: 'Standard access',
description: 'Standard access', description: 'Standard access',
ratio: '1x Ratio', ratio: '1x',
}, },
{ {
index: '3', index: '3',
name: 'Team', name: 'Team',
title: 'Shared access', title: 'Shared access',
description: 'Shared access', description: 'Shared access',
ratio: '2x Ratio', ratio: '2x',
}, },
]) ])
......
...@@ -26,12 +26,10 @@ import { ...@@ -26,12 +26,10 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import { import { GroupRatioBadge, type GroupRatio } from './auto-group-visuals'
// AutoGroupBadge,
GroupRatioBadge,
type GroupRatio,
} from './auto-group-visuals'
type ApiKeyGroupCellProps = { type ApiKeyGroupCellProps = {
crossGroupRetry: boolean crossGroupRetry: boolean
...@@ -42,16 +40,26 @@ type ApiKeyGroupCellProps = { ...@@ -42,16 +40,26 @@ type ApiKeyGroupCellProps = {
export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) { export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 640px)')
if (props.group !== 'auto') { const group = props.group?.trim() || ''
const ratio = typeof props.ratio === 'number' ? props.ratio : undefined if (group !== 'auto') {
const ratio =
group && typeof props.ratio === 'number' ? props.ratio : undefined
return ( return (
<TruncatedCell <TruncatedCell
className='-ml-1.5' className={isMobile ? 'w-full' : 'max-w-50'}
tooltipContent={props.group || '-'} tabIndex={0}
tooltipContent={group || t('Follow user group')}
tooltipClassName='break-all' tooltipClassName='break-all'
> >
<GroupBadge group={props.group} ratio={ratio} /> <GroupBadge
group={group}
ratio={ratio}
ratioLabel={group ? undefined : t('Inherited')}
className='px-0'
containerClassName={cn('gap-3', isMobile && 'w-full justify-between')}
/>
</TruncatedCell> </TruncatedCell>
) )
} }
...@@ -62,7 +70,11 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) { ...@@ -62,7 +70,11 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
render={ render={
<BadgeCell <BadgeCell
data-api-key-group-cell='auto' data-api-key-group-cell='auto'
className='gap-1.5 overflow-visible text-xs' tabIndex={0}
className={cn(
'ml-0 gap-3 overflow-visible text-xs',
isMobile ? 'w-full justify-between' : 'max-w-50'
)}
/> />
} }
> >
...@@ -70,8 +82,8 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) { ...@@ -70,8 +82,8 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
label={t('Cross-group')} label={t('Cross-group')}
variant='info' variant='info'
copyable={false} copyable={false}
className='px-0'
/> />
{/*<AutoGroupBadge shouldReduceMotion={props.shouldReduceMotion} />*/}
<GroupRatioBadge <GroupRatioBadge
ratio={props.ratio} ratio={props.ratio}
isAuto isAuto
......
/*
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 { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { Progress } from '@/components/ui/progress'
import { toIntlLocale } from '@/i18n/languages'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { API_KEY_STATUS } from '../constants'
import type { ApiKey } from '../types'
type ApiKeyQuotaCellProps = {
apiKey: ApiKey
now: number
variant?: 'table' | 'card'
}
export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
const { t, i18n } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
const used = props.apiKey.used_quota
const remaining = props.apiKey.remain_quota
const total = used + remaining
const hasProgress = !props.apiKey.unlimited_quota && total > 0
const percentage = hasProgress
? Math.min(100, Math.max(0, (remaining / total) * 100))
: 0
const formattedUsed = formatQuotaWithCurrency(used, { showSymbol: false })
const formattedRemaining = formatQuotaWithCurrency(remaining, {
showSymbol: false,
})
const formattedTotal = formatQuotaWithCurrency(total, { showSymbol: false })
const formattedPercentage = new Intl.NumberFormat(
toIntlLocale(i18n.resolvedLanguage || i18n.language),
{ maximumFractionDigits: 1 }
).format(percentage)
const isInactive =
props.apiKey.status !== API_KEY_STATUS.ENABLED ||
remaining <= 0 ||
(props.apiKey.expired_time !== -1 &&
props.apiKey.expired_time * 1000 <= props.now)
let progressColor = 'text-emerald-500'
if (isInactive) progressColor = 'text-muted-foreground/60'
else if (percentage <= 10) progressColor = 'text-rose-500'
else if (percentage <= 30) progressColor = 'text-amber-500'
const usageDescription = `${t('Used amount')} ${formattedUsed}`
const remainingDescription = hasProgress
? `${t('Remaining')} ${formattedRemaining}; ${t('Remaining percentage')} ${formattedPercentage}%`
: `${t('Remaining')} ${formattedRemaining}`
const triggerLabel = props.apiKey.unlimited_quota
? `${t('Unlimited')}; ${usageDescription}`
: `${remainingDescription}; ${usageDescription}`
return (
<Popover>
<div
className={cn(
'w-full min-w-0',
props.variant === 'card' ? 'space-y-2.5' : 'space-y-1.5'
)}
>
<PopoverTrigger
render={
<Button
variant='ghost'
aria-label={
props.variant === 'card'
? `${t('Quota')} (${quotaUnit}); ${triggerLabel}`
: triggerLabel
}
className={cn(
'h-auto w-full min-w-0 justify-start px-0 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent',
props.variant === 'card' ? 'py-0' : 'py-0.5'
)}
/>
}
>
<span
data-slot='api-key-quota-values'
className='grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-2 gap-y-1 text-xs'
>
<span className='text-muted-foreground'>
{t('Remaining')}
{props.variant === 'card' && (
<span className='ml-1'>({quotaUnit})</span>
)}
</span>
<span
className={cn(
'min-w-0 truncate text-right',
props.variant === 'card'
? 'text-sm leading-5 font-normal'
: 'font-medium',
!props.apiKey.unlimited_quota && 'font-mono tabular-nums',
!props.apiKey.unlimited_quota &&
remaining < 0 &&
'text-destructive',
remaining === 0 &&
!props.apiKey.unlimited_quota &&
'text-muted-foreground'
)}
>
{props.apiKey.unlimited_quota
? t('Unlimited')
: formattedRemaining}
</span>
<span className='text-muted-foreground'>{t('Used amount')}</span>
<span
className={cn(
'text-muted-foreground min-w-0 truncate text-right font-mono tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)}
>
{formattedUsed}
</span>
</span>
</PopoverTrigger>
{!props.apiKey.unlimited_quota && (
<Progress
value={percentage}
aria-label={t('Remaining percentage')}
className={cn(
'w-full [&_[data-slot=progress-indicator]]:bg-current',
progressColor
)}
/>
)}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
>
<PopoverTitle>
{t('Quota')} ({quotaUnit})
</PopoverTitle>
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'>
{!props.apiKey.unlimited_quota && (
<>
<dt className='text-muted-foreground'>{t('Remaining')}</dt>
<dd className='text-right font-mono break-all'>
{formattedRemaining}
</dd>
</>
)}
<dt className='text-muted-foreground'>{t('Used amount')}</dt>
<dd className='text-right font-mono break-all'>{formattedUsed}</dd>
{!props.apiKey.unlimited_quota && (
<>
<dt className='text-muted-foreground'>
{t('Current total quota')}
</dt>
<dd className='text-right font-mono break-all'>
{formattedTotal}
</dd>
</>
)}
{hasProgress && (
<>
<dt className='text-muted-foreground'>
{t('Remaining percentage')}
</dt>
<dd className='text-right font-mono'>{formattedPercentage}%</dd>
</>
)}
</dl>
<p className='text-muted-foreground text-xs leading-relaxed'>
{props.apiKey.unlimited_quota
? t(
'This API key has no quota limit. Requests still require available wallet or subscription quota.'
)
: t(
'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
)}
</p>
</PopoverContent>
</Popover>
)
}
...@@ -16,14 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,14 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import dayjs from '@/lib/dayjs'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format' import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { ApiKey } from '../types'
interface ApiKeyTimestampCellProps { interface ApiKeyTimestampCellProps {
timestamp: number timestamp: number
now: number now: number
...@@ -42,6 +48,9 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) { ...@@ -42,6 +48,9 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
const relativeTime = isJustNow const relativeTime = isJustNow
? props.justNowLabel ? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale) : formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
const absoluteTime = formatTimestampToDate(props.timestamp) const absoluteTime = formatTimestampToDate(props.timestamp)
return ( return (
...@@ -51,14 +60,15 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) { ...@@ -51,14 +60,15 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
<time <time
dateTime={new Date(timestampMs).toISOString()} dateTime={new Date(timestampMs).toISOString()}
tabIndex={0} tabIndex={0}
className={cn( className={cn('block truncate text-xs', props.className)}
'block truncate font-mono text-xs tabular-nums',
props.className
)}
/> />
} }
> >
{relativeTime} {relativePrefix}
{relativeNumber && (
<span className='font-mono tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<span className='font-mono tabular-nums'>{absoluteTime}</span> <span className='font-mono tabular-nums'>{absoluteTime}</span>
...@@ -66,3 +76,50 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) { ...@@ -66,3 +76,50 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
</Tooltip> </Tooltip>
) )
} }
export function ApiKeyActivityCell(props: {
apiKey: ApiKey
now: number
layout?: 'rows' | 'columns'
}) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const accessedTime = props.apiKey.accessed_time
const isStale =
accessedTime > 0 &&
accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf()
return (
<div
className={cn(
'grid min-w-0 gap-y-1 text-xs',
props.layout === 'columns'
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3'
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2'
)}
>
<span className='text-muted-foreground'>{t('Created')}</span>
<ApiKeyTimestampCell
timestamp={props.apiKey.created_time}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{t('Last Used')}</span>
<ApiKeyTimestampCell
timestamp={accessedTime}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
isStale ? 'text-warning' : 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
)
}
...@@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button' ...@@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTitle,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { import {
...@@ -34,7 +35,6 @@ import { ...@@ -34,7 +35,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { copyToClipboard } from '@/lib/copy-to-clipboard' import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { formatQuota } from '@/lib/format'
import type { ApiKey } from '../types' import type { ApiKey } from '../types'
import { useApiKeys } from './api-keys-provider' import { useApiKeys } from './api-keys-provider'
...@@ -142,47 +142,65 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) { ...@@ -142,47 +142,65 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
) )
} }
type UnlimitedQuotaBadgeProps = { type ApiKeyRestrictionProps = {
used: number apiKey: ApiKey
detailsTrigger?: 'hover' | 'click'
} }
export function UnlimitedQuotaBadge(props: UnlimitedQuotaBadgeProps) { export function ModelLimitsCell(props: ApiKeyRestrictionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const formattedUsed = formatQuota(props.used) const models = props.apiKey.model_limits_enabled
? (props.apiKey.model_limits || '').split(',').filter(Boolean)
: []
return ( return (
<Popover> <ApiKeyRestrictionCell
<PopoverTrigger items={models}
render={ label={t('{{count}} models', { count: models.length })}
<button title={t('Models')}
type='button' emptyLabel={t('Unlimited')}
className='focus-visible:ring-ring/50 -ml-1.5 cursor-help rounded-4xl focus-visible:ring-[3px] focus-visible:outline-none' detailsTrigger={props.detailsTrigger}
aria-label={`${t('Unlimited')}; ${t('Used:')} ${formattedUsed}`} />
/>
}
>
<StatusBadge
label={t('Unlimited')}
variant='neutral'
copyable={false}
/>
</PopoverTrigger>
<PopoverContent className='w-auto p-2' side='top'>
<span className='text-xs'>
{t('Used:')} {formattedUsed}
</span>
</PopoverContent>
</Popover>
) )
} }
export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) { export function IpRestrictionsCell(props: ApiKeyRestrictionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const ips = (props.apiKey.allow_ips || '')
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean)
if (!apiKey.model_limits_enabled || !apiKey.model_limits) { return (
<ApiKeyRestrictionCell
items={ips}
label={t('{{count}} IP(s)', { count: ips.length })}
title={t('IP Restriction')}
emptyLabel={t('No restriction')}
detailsTrigger={props.detailsTrigger}
/>
)
}
function ApiKeyRestrictionCell(props: {
items: string[]
label: string
title: string
emptyLabel: string
detailsTrigger?: 'hover' | 'click'
}) {
if (!props.items.length) {
if (props.detailsTrigger === 'click') {
return (
<span className='inline-flex items-center gap-1.5 text-xs'>
<span className='text-muted-foreground'>{props.title}</span>
<span>{props.emptyLabel}</span>
</span>
)
}
return ( return (
<StatusBadge <StatusBadge
label={t('Unlimited')} label={props.emptyLabel}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
className='-ml-1.5' className='-ml-1.5'
...@@ -190,67 +208,46 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) { ...@@ -190,67 +208,46 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
) )
} }
const models = apiKey.model_limits.split(',').filter(Boolean) const details = (
<div className='max-h-[200px] space-y-1 overflow-y-auto text-xs'>
return ( {props.items.map((item) => (
<Tooltip> <div key={item} className='font-mono break-all'>
<TooltipTrigger render={<BadgeCell />}> {item}
<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> </div>
</TooltipContent> ))}
</Tooltip> </div>
) )
}
export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
const { t } = useTranslation()
const allowIps = apiKey.allow_ips?.trim()
if (!allowIps) { if (props.detailsTrigger === 'click') {
return ( return (
<StatusBadge <Popover>
label={t('No restriction')} <PopoverTrigger
variant='neutral' render={
copyable={false} <Button
className='-ml-1.5' variant='ghost'
/> size='sm'
aria-label={`${props.title}: ${props.label}`}
className='h-7 max-w-full justify-start px-0 text-xs font-normal underline decoration-dotted underline-offset-4'
/>
}
>
{props.label}
</PopoverTrigger>
<PopoverContent align='start' className='max-w-[calc(100vw-2rem)]'>
<PopoverTitle>{props.title}</PopoverTitle>
{details}
</PopoverContent>
</Popover>
) )
} }
const ips = allowIps
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean)
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger render={<BadgeCell />}> <TooltipTrigger render={<BadgeCell />}>
<StatusBadge <StatusBadge label={props.label} variant='neutral' copyable={false} />
label={t('{{count}} IP(s)', { count: ips.length })}
variant='neutral'
copyable={false}
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'> <TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'> {details}
{ips.map((ip) => (
<div key={ip} className='font-mono'>
{ip}
</div>
))}
</div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) )
......
...@@ -22,37 +22,27 @@ import { useTranslation } from 'react-i18next' ...@@ -22,37 +22,27 @@ import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { toIntlLocale } from '@/i18n/languages' import { toIntlLocale } from '@/i18n/languages'
import { getUserGroups } from '@/lib/api' import { getUserGroups } from '@/lib/api'
import dayjs from '@/lib/dayjs' import { getCurrencyDisplay } from '@/lib/currency'
import { formatQuota } from '@/lib/format' import { useSystemConfigStore } from '@/stores/system-config-store'
import { cn } from '@/lib/utils'
import { API_KEY_STATUSES } from '../constants' import { API_KEY_STATUSES } from '../constants'
import type { ApiKey } from '../types' import type { ApiKey } from '../types'
import { ApiKeyGroupCell } from './api-key-group-cell' import { ApiKeyGroupCell } from './api-key-group-cell'
import { ApiKeyTimestampCell } from './api-key-timestamp-cell' import { ApiKeyQuotaCell } from './api-key-quota-cell'
import {
ApiKeyActivityCell,
ApiKeyTimestampCell,
} from './api-key-timestamp-cell'
import { import {
ApiKeyCell, ApiKeyCell,
IpRestrictionsCell, IpRestrictionsCell,
ModelLimitsCell, ModelLimitsCell,
UnlimitedQuotaBadge,
} from './api-keys-cells' } from './api-keys-cells'
import { DataTableRowActions } from './data-table-row-actions' 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 | string> { function useGroupRatios(): Record<string, number | string> {
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['user-groups'], queryKey: ['user-groups'],
...@@ -75,11 +65,13 @@ function useGroupRatios(): Record<string, number | string> { ...@@ -75,11 +65,13 @@ function useGroupRatios(): Record<string, number | string> {
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] { export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
const groupRatios = useGroupRatios() const groupRatios = useGroupRatios()
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)') const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language) const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const justNowLabel = t('Just now') const justNowLabel = t('Just now')
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
return [ return [
{ {
id: 'select', id: 'select',
...@@ -88,7 +80,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] { ...@@ -88,7 +80,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
checked={table.getIsAllPageRowsSelected()} checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()} indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all' aria-label={t('Select all')}
className='translate-y-[2px]' className='translate-y-[2px]'
/> />
), ),
...@@ -96,7 +88,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] { ...@@ -96,7 +88,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row' aria-label={t('Select row')}
className='translate-y-[2px]' className='translate-y-[2px]'
/> />
), ),
...@@ -143,52 +135,10 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] { ...@@ -143,52 +135,10 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
{ {
id: 'quota', id: 'quota',
accessorKey: 'remain_quota', accessorKey: 'remain_quota',
header: t('Quota'), header: `${t('Quota')} (${quotaUnit})`,
cell: ({ row }) => { cell: ({ row }) => <ApiKeyQuotaCell apiKey={row.original} now={now} />,
const apiKey = row.original size: 220,
if (apiKey.unlimited_quota) { minSize: 220,
return <UnlimitedQuotaBadge used={apiKey.used_quota} />
}
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', accessorKey: 'group',
...@@ -227,39 +177,11 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] { ...@@ -227,39 +177,11 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
meta: { mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
id: 'activity_time',
accessorKey: 'created_time', accessorKey: 'created_time',
header: t('Created'), header: t('Time'),
cell: ({ row }) => ( cell: ({ row }) => <ApiKeyActivityCell apiKey={row.original} now={now} />,
<ApiKeyTimestampCell size: 220,
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 }, meta: { mobileHidden: true },
}, },
{ {
...@@ -277,16 +199,17 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] { ...@@ -277,16 +199,17 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
/> />
) )
} }
const isExpired = expiredTime * 1000 < now
return ( return (
<ApiKeyTimestampCell <ApiKeyTimestampCell
timestamp={expiredTime} timestamp={expiredTime}
now={now} now={now}
locale={locale} locale={locale}
justNowLabel={justNowLabel} justNowLabel={justNowLabel}
className={cn( className={
isExpired ? 'text-destructive' : 'text-muted-foreground' expiredTime * 1000 <= now
)} ? 'text-destructive'
: 'text-muted-foreground'
}
/> />
) )
}, },
......
...@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import type { Table as TanstackTable } from '@tanstack/react-table' import { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react' import { Database } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -42,7 +42,6 @@ import { ...@@ -42,7 +42,6 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useTableUrlState } from '@/hooks/use-table-url-state' import { useTableUrlState } from '@/hooks/use-table-url-state'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getApiKeys, searchApiKeys } from '../api' import { getApiKeys, searchApiKeys } from '../api'
...@@ -53,7 +52,13 @@ import { ...@@ -53,7 +52,13 @@ import {
ERROR_MESSAGES, ERROR_MESSAGES,
} from '../constants' } from '../constants'
import type { ApiKey } from '../types' import type { ApiKey } from '../types'
import { ApiKeyCell, UnlimitedQuotaBadge } from './api-keys-cells' import { ApiKeyQuotaCell } from './api-key-quota-cell'
import { ApiKeyActivityCell } from './api-key-timestamp-cell'
import {
ApiKeyCell,
ModelLimitsCell,
IpRestrictionsCell,
} from './api-keys-cells'
import { useApiKeysColumns } from './api-keys-columns' import { useApiKeysColumns } from './api-keys-columns'
import { useApiKeys } from './api-keys-provider' import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions' import { DataTableBulkActions } from './data-table-bulk-actions'
...@@ -72,11 +77,11 @@ function isDisabledApiKeyRow(apiKey: ApiKey) { ...@@ -72,11 +77,11 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
function ApiKeysMobileSkeleton() { function ApiKeysMobileSkeleton() {
return ( return (
<div className='divide-border overflow-hidden rounded-lg border'> <div className='min-w-0 space-y-3'>
{API_KEYS_MOBILE_SKELETON_IDS.map((id) => ( {API_KEYS_MOBILE_SKELETON_IDS.map((id) => (
<div <div
key={id} key={id}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0' className='border-border/60 bg-card space-y-2 rounded-xl border p-3.5'
> >
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' /> <Skeleton className='h-4 w-32' />
...@@ -96,9 +101,11 @@ function ApiKeysMobileSkeleton() { ...@@ -96,9 +101,11 @@ function ApiKeysMobileSkeleton() {
function ApiKeysMobileList({ function ApiKeysMobileList({
table, table,
isLoading, isLoading,
now,
}: { }: {
table: TanstackTable<ApiKey> table: TanstackTable<ApiKey>
isLoading: boolean isLoading: boolean
now: number
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const rows = table.getRowModel().rows const rows = table.getRowModel().rows
...@@ -126,34 +133,37 @@ function ApiKeysMobileList({ ...@@ -126,34 +133,37 @@ function ApiKeysMobileList({
} }
return ( return (
<div className='divide-border overflow-hidden rounded-lg border'> <div className='min-w-0 space-y-3'>
{rows.map((row) => { {rows.map((row) => {
const apiKey = row.original const apiKey = row.original
const statusConfig = API_KEY_STATUSES[apiKey.status] const statusConfig = API_KEY_STATUSES[apiKey.status]
const total = apiKey.used_quota + apiKey.remain_quota const groupCell = row
.getAllCells()
.find((cell) => cell.column.id === 'group')
const expiryCell = row
.getAllCells()
.find((cell) => cell.column.id === 'expired_time')
return ( return (
<div <div
key={row.id} key={row.id}
className={cn( className={cn(
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0', 'border-border/60 bg-card min-w-0 space-y-2 rounded-xl border p-3.5 text-xs leading-4',
isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE
)} )}
> >
<div className='flex items-start justify-between gap-3'> <div className='flex items-start justify-between gap-3'>
<div className='min-w-0'> <div className='min-w-0'>
<div className='truncate text-sm font-semibold'> <div className='text-sm leading-5 font-semibold break-words'>
{apiKey.name} {apiKey.name}
</div> </div>
<div className='text-muted-foreground text-[11px]'>
{t('API Key')}
</div>
</div> </div>
{statusConfig && ( {statusConfig && (
<StatusBadge <StatusBadge
label={t(statusConfig.label)} label={t(statusConfig.label)}
variant={statusConfig.variant} variant={statusConfig.variant}
copyable={false} copyable={false}
className='shrink-0 px-0 text-xs font-normal'
/> />
)} )}
</div> </div>
...@@ -165,19 +175,38 @@ function ApiKeysMobileList({ ...@@ -165,19 +175,38 @@ function ApiKeysMobileList({
<DataTableRowActions row={row} /> <DataTableRowActions row={row} />
</div> </div>
<div className='flex items-center justify-between gap-2 text-xs'> <div className='min-w-0 space-y-3 py-1'>
<span className='text-muted-foreground'>{t('Quota')}</span> <div className='min-w-0'>
{apiKey.unlimited_quota ? ( {groupCell &&
<UnlimitedQuotaBadge used={apiKey.used_quota} /> flexRender(
) : ( groupCell.column.columnDef.cell,
<span className='font-medium tabular-nums'> groupCell.getContext()
{formatQuota(apiKey.remain_quota)} )}
<span className='text-muted-foreground font-normal'> </div>
{' / '} <ApiKeyQuotaCell apiKey={apiKey} now={now} variant='card' />
{formatQuota(total)} </div>
</span>
</span> <div className='flex flex-wrap items-center gap-x-5 gap-y-1'>
)} <ModelLimitsCell apiKey={apiKey} detailsTrigger='click' />
<IpRestrictionsCell apiKey={apiKey} detailsTrigger='click' />
</div>
<div className='grid grid-cols-3 items-start gap-3 border-t pt-2'>
<div className='col-span-2 min-w-0'>
<ApiKeyActivityCell
apiKey={apiKey}
now={now}
layout='columns'
/>
</div>
<div className='min-w-0 space-y-1 [&_[data-slot=status-badge]]:text-xs [&_[data-slot=status-badge]]:font-normal'>
<div className='text-muted-foreground'>{t('Expires')}</div>
{expiryCell &&
flexRender(
expiryCell.column.columnDef.cell,
expiryCell.getContext()
)}
</div>
</div> </div>
</div> </div>
) )
...@@ -293,6 +322,23 @@ export function ApiKeysTable() { ...@@ -293,6 +322,23 @@ export function ApiKeysTable() {
ensurePageInRange, ensurePageInRange,
}) })
const columnVisibility = table.getState().columnVisibility
useEffect(() => {
// Restore the dates hidden by the previous default when adopting the combined time column.
if (
columnVisibility.activity_time === undefined &&
columnVisibility.created_time === false &&
columnVisibility.accessed_time === false &&
columnVisibility.expired_time === false
) {
table.setColumnVisibility((previous) => ({
...previous,
activity_time: true,
expired_time: true,
}))
}
}, [columnVisibility, table])
return ( return (
<DataTablePage <DataTablePage
table={table} table={table}
...@@ -305,6 +351,9 @@ export function ApiKeysTable() { ...@@ -305,6 +351,9 @@ export function ApiKeysTable() {
)} )}
skeletonKeyPrefix='api-keys-skeleton' skeletonKeyPrefix='api-keys-skeleton'
applyHeaderSize applyHeaderSize
getColumnClassName={(columnId) =>
columnId === 'quota' ? 'pr-8' : undefined
}
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name...'), searchPlaceholder: t('Filter by name...'),
searchDebounceMs: 500, searchDebounceMs: 500,
...@@ -326,7 +375,9 @@ export function ApiKeysTable() { ...@@ -326,7 +375,9 @@ export function ApiKeysTable() {
}, },
], ],
}} }}
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />} mobile={
<ApiKeysMobileList table={table} isLoading={isLoading} now={now} />
}
getRowClassName={(row) => getRowClassName={(row) =>
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
} }
......
...@@ -19,8 +19,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,8 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge, GroupMultiplierBadge } from '@/components/group-badge'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export type GroupRatio = number | string | null | undefined export type GroupRatio = number | string | null | undefined
...@@ -30,6 +29,7 @@ export const AUTO_GROUP_FRAME_CLASS_NAME = ...@@ -30,6 +29,7 @@ export const AUTO_GROUP_FRAME_CLASS_NAME =
type AutoGroupFlowBorderProps = { type AutoGroupFlowBorderProps = {
shouldReduceMotion: boolean shouldReduceMotion: boolean
appearance?: 'default' | 'subtle'
} }
export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) { export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) {
...@@ -39,7 +39,10 @@ export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) { ...@@ -39,7 +39,10 @@ export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) {
<span <span
aria-hidden='true' aria-hidden='true'
data-auto-group-flow-border='true' data-auto-group-flow-border='true'
className='auto-group-flow-border pointer-events-none absolute -inset-px' className={cn(
'auto-group-flow-border pointer-events-none absolute -inset-px',
props.appearance === 'subtle' && 'auto-group-flow-border-subtle'
)}
/> />
) )
} }
...@@ -68,22 +71,6 @@ export function AutoGroupFrame(props: AutoGroupFrameProps) { ...@@ -68,22 +71,6 @@ export function AutoGroupFrame(props: AutoGroupFrameProps) {
) )
} }
function getRatioBadgeClassName(ratio: GroupRatio, isAuto: boolean): string {
if (isAuto || typeof ratio !== 'number') {
return 'border-primary/30 bg-primary/10 text-primary'
}
if (ratio > 5) {
return 'border-destructive/30 bg-destructive/10 text-destructive'
}
if (ratio > 3) {
return 'border-warning/30 bg-warning/10 text-warning'
}
if (ratio > 1) {
return 'border-info/30 bg-info/10 text-info'
}
return 'border-success/30 bg-success/10 text-success'
}
type GroupRatioBadgeProps = { type GroupRatioBadgeProps = {
isAuto?: boolean isAuto?: boolean
ratio: GroupRatio ratio: GroupRatio
...@@ -97,38 +84,26 @@ export function GroupRatioBadge(props: GroupRatioBadgeProps) { ...@@ -97,38 +84,26 @@ export function GroupRatioBadge(props: GroupRatioBadgeProps) {
return null return null
} }
const label = return (
typeof props.ratio === 'number' <GroupMultiplierBadge
? `${props.ratio}x ${t('Ratio')}` ratio={typeof props.ratio === 'number' ? props.ratio : undefined}
: `${t('Auto')} ${t('Ratio')}` label={typeof props.ratio === 'number' ? undefined : t('Auto')}
const badge = (
<Badge
variant='outline'
className={cn( className={cn(
'max-w-full truncate text-[10px] sm:text-xs', props.isAuto &&
getRatioBadgeClassName(props.ratio, props.isAuto === true) 'overflow-visible rounded-md border-primary/30 bg-primary/10 text-primary'
)} )}
> >
{label} {props.isAuto && (
</Badge> <AutoGroupFlowBorder
) appearance='subtle'
shouldReduceMotion={props.shouldReduceMotion ?? false}
if (!props.isAuto) { />
return <span className='max-w-24 shrink-0 sm:max-w-none'>{badge}</span> )}
} </GroupMultiplierBadge>
return (
<AutoGroupFrame
effect='ratio'
shouldReduceMotion={props.shouldReduceMotion ?? false}
className='max-w-24 sm:max-w-none'
>
{badge}
</AutoGroupFrame>
) )
} }
export function AutoGroupBadge(props: AutoGroupFlowBorderProps) { export function AutoGroupBadge(props: { shouldReduceMotion: boolean }) {
return ( return (
<AutoGroupFrame <AutoGroupFrame
effect='badge' effect='badge'
......
...@@ -86,34 +86,16 @@ export function DataTableRowActions<TData>({ ...@@ -86,34 +86,16 @@ export function DataTableRowActions<TData>({
triggerRefresh, triggerRefresh,
setResolvedKey, setResolvedKey,
resolveRealKey, resolveRealKey,
resolvedKeys,
loadingKeys, loadingKeys,
} = useApiKeys() } = useApiKeys()
const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED
const { chatPresets, serverAddress } = useChatPresets() const { chatPresets, serverAddress } = useChatPresets()
const [isTogglingStatus, setIsTogglingStatus] = useState(false) const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const resolvedRealKey = resolvedKeys[apiKey.id]
const isRealKeyLoading = Boolean(loadingKeys[apiKey.id]) const isRealKeyLoading = Boolean(loadingKeys[apiKey.id])
const hasChatPresets = chatPresets.length > 0 const hasChatPresets = chatPresets.length > 0
const toggleLabel = isEnabled ? t('Disable') : t('Enable') 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( const handleOpenChatPreset = useCallback(
async (preset: ChatPreset) => { async (preset: ChatPreset) => {
const realKey = await resolveRealKey(apiKey.id) const realKey = await resolveRealKey(apiKey.id)
...@@ -156,9 +138,9 @@ export function DataTableRowActions<TData>({ ...@@ -156,9 +138,9 @@ export function DataTableRowActions<TData>({
) )
const handleToggleStatus = async ( const handleToggleStatus = async (
e?: React.MouseEvent<HTMLButtonElement> event?: React.MouseEvent<HTMLButtonElement>
) => { ) => {
e?.stopPropagation() event?.stopPropagation()
const newStatus = isEnabled const newStatus = isEnabled
? API_KEY_STATUS.DISABLED ? API_KEY_STATUS.DISABLED
: API_KEY_STATUS.ENABLED : API_KEY_STATUS.ENABLED
...@@ -236,11 +218,11 @@ export function DataTableRowActions<TData>({ ...@@ -236,11 +218,11 @@ export function DataTableRowActions<TData>({
ariaLabel={t('Open menu')} ariaLabel={t('Open menu')}
contentClassName='w-[200px]' contentClassName='w-[200px]'
modal={false} modal={false}
onOpenChange={handleMenuOpenChange}
> >
<DropdownMenuItem <DropdownMenuItem
disabled={isRealKeyLoading}
onClick={async () => { onClick={async () => {
const realKey = getCachedRealKey() const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return if (!realKey) return
const ok = await copyToClipboard(realKey) const ok = await copyToClipboard(realKey)
if (ok) toast.success(t('Copied')) if (ok) toast.success(t('Copied'))
...@@ -252,8 +234,9 @@ export function DataTableRowActions<TData>({ ...@@ -252,8 +234,9 @@ export function DataTableRowActions<TData>({
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
disabled={isRealKeyLoading}
onClick={async () => { onClick={async () => {
const realKey = getCachedRealKey() const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return if (!realKey) return
const connStr = encodeChannelConnectionInfo( const connStr = encodeChannelConnectionInfo(
realKey, realKey,
......
...@@ -19,34 +19,20 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,34 +19,20 @@ For commercial licensing, please contact support@quantumnous.com
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Progress } from '@/components/ui/progress' import { formatQuotaWithCurrency } from '@/lib/currency'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store'
type UserQuotaCellProps = { type UserQuotaCellProps = {
used: number
remaining: number remaining: number
} used: number
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'
} }
export function UserQuotaCell(props: UserQuotaCellProps) { export function UserQuotaCell(props: UserQuotaCellProps) {
const { t } = useTranslation() const { t } = useTranslation()
const total = props.used + props.remaining useSystemConfigStore((state) => state.config.currency)
const percentage = total > 0 ? (props.remaining / total) * 100 : 0
const formattedRemaining = formatQuota(props.remaining)
const formattedTotal = formatQuota(total)
if (total === 0) { if (props.remaining === 0 && props.used === 0) {
return ( return (
<StatusBadge <StatusBadge
label={t('No Quota')} label={t('No Quota')}
...@@ -58,41 +44,22 @@ export function UserQuotaCell(props: UserQuotaCellProps) { ...@@ -58,41 +44,22 @@ export function UserQuotaCell(props: UserQuotaCellProps) {
} }
return ( return (
<Tooltip> <div className='min-w-0 space-y-1 text-left tabular-nums'>
<TooltipTrigger <div
render={ className={cn(
<div className='w-full min-w-0 cursor-help space-y-1.5 overflow-hidden' /> 'font-mono text-sm font-semibold whitespace-nowrap',
} props.remaining < 0 && 'text-destructive',
props.remaining === 0 && 'text-muted-foreground'
)}
> >
<div className='grid min-w-0 grid-cols-2 gap-x-4 text-xs'> {formatQuotaWithCurrency(props.remaining, { showSymbol: false })}
<span className='min-w-0 truncate font-medium tabular-nums'> </div>
{formattedRemaining} <div className='text-muted-foreground flex items-baseline gap-1 text-xs whitespace-nowrap'>
</span> <span>{t('Used amount')}</span>
<span className='text-muted-foreground min-w-0 truncate text-right tabular-nums'> <span className='font-mono'>
{formattedTotal} {formatQuotaWithCurrency(props.used, { showSymbol: false })}
</span> </span>
</div> </div>
<Progress </div>
value={percentage}
className={cn('h-1.5', getQuotaProgressColor(percentage))}
/>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1 text-xs'>
<div>
{t('Used:')} {formatQuota(props.used)}
</div>
<div>
{t('Remaining:')} {formattedRemaining}
</div>
<div>
{t('Total:')} {formattedTotal}
</div>
<div>
{t('Percentage:')} {percentage.toFixed(1)}%
</div>
</div>
</TooltipContent>
</Tooltip>
) )
} }
...@@ -30,7 +30,9 @@ import { ...@@ -30,7 +30,9 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { getCurrencyDisplay } from '@/lib/currency'
import { formatQuota, formatTimestamp } from '@/lib/format' import { formatQuota, formatTimestamp } from '@/lib/format'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { import {
USER_STATUS, USER_STATUS,
...@@ -44,6 +46,9 @@ import { UserQuotaCell } from './user-quota-cell' ...@@ -44,6 +46,9 @@ import { UserQuotaCell } from './user-quota-cell'
export function useUsersColumns(): ColumnDef<User>[] { export function useUsersColumns(): ColumnDef<User>[] {
const { t } = useTranslation() const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
return [ return [
{ {
id: 'select', id: 'select',
...@@ -52,7 +57,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -52,7 +57,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
checked={table.getIsAllPageRowsSelected()} checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()} indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all' aria-label={t('Select all')}
className='translate-y-[2px]' className='translate-y-[2px]'
/> />
), ),
...@@ -60,7 +65,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -60,7 +65,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row' aria-label={t('Select row')}
className='translate-y-[2px]' className='translate-y-[2px]'
/> />
), ),
...@@ -141,7 +146,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -141,7 +146,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
<TooltipTrigger render={<div className='-ml-1.5 cursor-help' />}> <TooltipTrigger render={<div className='-ml-1.5 cursor-help' />}>
<StatusBadge <StatusBadge
label={t(statusConfig.labelKey)} label={t(statusConfig.labelKey)}
variant={statusConfig.variant} variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant}
copyable={false} copyable={false}
/> />
</TooltipTrigger> </TooltipTrigger>
...@@ -163,18 +168,18 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -163,18 +168,18 @@ export function useUsersColumns(): ColumnDef<User>[] {
{ {
id: 'quota', id: 'quota',
accessorKey: 'quota', accessorKey: 'quota',
header: t('Quota'), header: `${t('Available Balance')} (${quotaUnit})`,
cell: ({ row }) => { cell: ({ row }) => {
const user = row.original const user = row.original
return <UserQuotaCell used={user.used_quota} remaining={user.quota} /> return <UserQuotaCell remaining={user.quota} used={user.used_quota} />
}, },
size: 300, size: 180,
minSize: 260, minSize: 160,
meta: { mobileOrder: 40 }, meta: { mobileOrder: 40 },
}, },
{ {
accessorKey: 'group', accessorKey: 'group',
header: t('Group'), header: t('User Group'),
cell: ({ row }) => { cell: ({ row }) => {
const group = row.getValue('group') as string const group = row.getValue('group') as string
return ( return (
...@@ -202,14 +207,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -202,14 +207,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return null return null
} }
return ( return <span className='text-sm'>{t(roleConfig.labelKey)}</span>
<div className='flex items-center gap-x-2'>
{roleConfig.icon && (
<roleConfig.icon size={16} className='text-muted-foreground' />
)}
<span className='text-sm'>{t(roleConfig.labelKey)}</span>
</div>
)
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
return value.includes(String(row.getValue(id))) return value.includes(String(row.getValue(id)))
...@@ -227,63 +225,25 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -227,63 +225,25 @@ export function useUsersColumns(): ColumnDef<User>[] {
const affHistoryQuota = user.aff_history_quota || 0 const affHistoryQuota = user.aff_history_quota || 0
const inviterId = user.inviter_id || 0 const inviterId = user.inviter_id || 0
if (affCount === 0 && affHistoryQuota === 0 && inviterId === 0) {
return <span className='text-muted-foreground text-sm'></span>
}
return ( return (
<div className='flex max-w-full min-w-0 flex-wrap items-center gap-1 overflow-hidden'> <div className='min-w-0 space-y-1 text-xs'>
<Tooltip> {(affCount > 0 || affHistoryQuota !== 0) && (
<TooltipTrigger <LongText>
render={ {t('Invited {{count}} users', { count: affCount })} ·{' '}
<StatusBadge {t('Earnings')}:{' '}
label={`${t('Invited')}: ${affCount}`} <span className='tabular-nums'>
variant='neutral' {formatQuota(affHistoryQuota)}
copyable={false} </span>
className='cursor-help' </LongText>
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Number of users invited')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Revenue')}: ${formatQuota(affHistoryQuota)}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Total invitation revenue')}</p>
</TooltipContent>
</Tooltip>
{inviterId > 0 && (
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Inviter')}: ${inviterId}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>
{t('Invited by user ID')} {inviterId}
</p>
</TooltipContent>
</Tooltip>
)} )}
{inviterId === 0 && ( {inviterId > 0 && (
<StatusBadge <LongText className='text-muted-foreground'>
label={t('No Inviter')} {t('Inviter')} ID: {inviterId}
variant='neutral' </LongText>
copyable={false}
/>
)} )}
</div> </div>
) )
......
...@@ -174,6 +174,7 @@ export function UsersTable() { ...@@ -174,6 +174,7 @@ export function UsersTable() {
data: users, data: users,
columns, columns,
enableRowSelection: true, enableRowSelection: true,
initialColumnVisibility: { created_at: false, last_login_at: false },
columnFilters, columnFilters,
globalFilter, globalFilter,
pagination, pagination,
...@@ -232,13 +233,10 @@ export function UsersTable() { ...@@ -232,13 +233,10 @@ export function UsersTable() {
}, },
], ],
}} }}
getRowClassName={(row, { isMobile }) => getRowClassName={(row, { isMobile }) => {
isDisabledUserRow(row.original) if (!isDisabledUserRow(row.original)) return undefined
? isMobile return isMobile ? DISABLED_ROW_MOBILE : DISABLED_ROW_DESKTOP
? DISABLED_ROW_MOBILE }}
: DISABLED_ROW_DESKTOP
: undefined
}
bulkActions={<DataTableBulkActions table={table} />} bulkActions={<DataTableBulkActions table={table} />}
/> />
) )
......
...@@ -713,6 +713,18 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -713,6 +713,18 @@ For commercial licensing, please contact support@quantumnous.com
animation: auto-group-flow-border-travel 3.2s linear infinite; animation: auto-group-flow-border-travel 3.2s linear infinite;
} }
.auto-group-flow-border-subtle {
padding: 1px;
background: conic-gradient(
from var(--auto-group-flow-angle),
transparent 0deg 300deg,
color-mix(in oklch, var(--primary) 30%, transparent) 318deg,
var(--primary) 348deg,
transparent 360deg
);
animation-duration: 3s;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.auto-group-flow-border { .auto-group-flow-border {
display: none; display: none;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment