From a6b6a424e0bb06b9386a561457f1d090cc9f353c Mon Sep 17 00:00:00 2001 From: Romuald Wandji Date: Mon, 5 Oct 2026 10:52:12 +0200 Subject: [PATCH 1/2] feat: add balance management --- src/components/Sidebar.tsx | 38 ++- src/components/balance/BalanceTab.test.tsx | 294 ++++++++++++++++ src/components/balance/BalanceTab.tsx | 319 ++++++++++++++++++ .../balance/ResetLimitDialog.test.tsx | 168 +++++++++ src/components/balance/ResetLimitDialog.tsx | 106 ++++++ src/components/balance/index.ts | 2 + src/components/dashboard/DashboardPage.tsx | 5 +- src/constants/capabilities.ts | 56 ++- src/hooks/index.ts | 1 + src/hooks/useAddCredit.ts | 35 ++ src/locales/en/translation.json | 34 ++ src/routeTree.gen.ts | 21 ++ src/routes/_app.tsx | 2 +- src/routes/_app/balance.tsx | 19 ++ src/server/balance.ts | 114 +++++++ src/server/index.ts | 1 + src/types/balance.ts | 32 ++ src/types/index.ts | 1 + src/types/layout.ts | 6 +- src/utils/format.test.ts | 43 ++- src/utils/format.ts | 24 ++ 21 files changed, 1302 insertions(+), 19 deletions(-) create mode 100644 src/components/balance/BalanceTab.test.tsx create mode 100644 src/components/balance/BalanceTab.tsx create mode 100644 src/components/balance/ResetLimitDialog.test.tsx create mode 100644 src/components/balance/ResetLimitDialog.tsx create mode 100644 src/components/balance/index.ts create mode 100644 src/hooks/useAddCredit.ts create mode 100644 src/routes/_app/balance.tsx create mode 100644 src/server/balance.ts create mode 100644 src/types/balance.ts diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ce99c1e2..868cadbe 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -3,12 +3,34 @@ import { Icon, Dropdown } from '@clickhouse/click-ui'; import { Link, useRouter } from '@tanstack/react-router'; import type * as t from '@/types'; import { useStripAriaExpanded, useCapabilities, useLocalize } from '@/hooks'; +import { SystemCapabilities, READ_BALANCES_CAPABILITY } from '@/constants'; import libreChatLogo from '@/assets/librechat.svg'; import { SettingsDialog } from './SettingsDialog'; -import { SystemCapabilities } from '@/constants'; import { getInitials, cn } from '@/utils'; import { adminLogoutFn } from '@/server'; +/** + * click-ui's built-in icon set has no coin/token glyph, so Balance gets a + * purpose-drawn one instead of settling for an unrelated shape (hexagon, + * dollar, …). Matches the rest of the set's style: 24x24, 1.5 stroke, + * `currentColor` so it inherits the nav item's active/hover color. + */ +function CreditsIcon() { + return ( + // size-4 (1rem/16px) matches click-ui's Icon size="sm" rendered size exactly. + + + + + ); +} + const navItems: t.NavItem[] = [ { labelKey: 'com_nav_dashboard', path: '/', icon: 'home' }, { @@ -31,6 +53,12 @@ const navItems: t.NavItem[] = [ capability: [SystemCapabilities.READ_ROLES, SystemCapabilities.READ_GROUPS], }, { labelKey: 'com_nav_grants', path: '/grants', icon: 'lock' }, + { + labelKey: 'com_nav_balance', + path: '/balance', + customIcon: , + capability: READ_BALANCES_CAPABILITY, + }, { labelKey: 'com_nav_help', path: '/help', icon: 'question' }, ]; @@ -88,7 +116,11 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) { >
- {localize('com_a11y_logo_alt')} + {localize('com_a11y_logo_alt')} {localize('com_auth_title')} @@ -112,7 +144,7 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) { )} > {localize(item.labelKey)} diff --git a/src/components/balance/BalanceTab.test.tsx b/src/components/balance/BalanceTab.test.tsx new file mode 100644 index 00000000..f9bc1e2d --- /dev/null +++ b/src/components/balance/BalanceTab.test.tsx @@ -0,0 +1,294 @@ +import { createContext, useContext } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, within, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { AdminBalanceListItem } from '@/types'; +import { BalanceTab } from './BalanceTab'; + +const mockHasCapability = vi.fn(); +const mockGetBalanceListFn = vi.fn(); + +vi.mock('@/hooks', () => ({ + useLocalize: () => (key: string, opts?: Record) => + opts ? `${key} ${JSON.stringify(opts)}` : key, + useCapabilities: () => ({ hasCapability: mockHasCapability }), + useDebouncedFilter: (initial: string) => ({ + value: initial, + debouncedValue: initial, + onChange: vi.fn(), + }), + useAddCredit: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/utils', () => ({ + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + formatRelativeTime: () => '2h ago', +})); + +vi.mock('@/server', () => ({ + balanceListQueryOptions: (page: number, search: string, view: string) => ({ + queryKey: ['balanceRequests', page, search, view], + queryFn: () => mockGetBalanceListFn({ data: { search, view } }), + }), + BALANCE_PAGE_SIZE: 50, +})); + +vi.mock('@/constants', () => ({ + MANAGE_BALANCES_CAPABILITY: 'manage:balances', +})); + +vi.mock('@/components/shared', () => ({ + Avatar: ({ name }: { name: string }) => {name}, + EmptyState: ({ message }: { message: string }) =>
{message}
, + LoadingState: () =>
loading
, + Pagination: () =>
, + SearchInput: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( + onChange(e.target.value)} /> + ), +})); + +const TabsCtx = createContext<{ onValueChange?: (v: string) => void }>({}); + +function MockTabs({ + value, + onValueChange, + children, + ariaLabel, +}: { + value?: string; + onValueChange?: (v: string) => void; + children?: React.ReactNode; + ariaLabel?: string; +}) { + return ( + +
+ {children} +
+
+ ); +} +MockTabs.TriggersList = ({ children }: { children?: React.ReactNode }) =>
{children}
; +MockTabs.Trigger = ({ value, children }: { value: string; children?: React.ReactNode }) => { + const { onValueChange } = useContext(TabsCtx); + return ( + + ); +}; +MockTabs.Content = () => null; + +function MockTooltip({ children }: { children?: React.ReactNode }) { + return <>{children}; +} +MockTooltip.Trigger = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) =>
{children}
; +MockTooltip.Content = ({ children }: { children?: React.ReactNode }) =>
{children}
; + +vi.mock('@clickhouse/click-ui', () => ({ + Button: ({ + label, + onClick, + disabled, + ...props + }: { + label?: string; + onClick?: () => void; + disabled?: boolean; + [key: string]: unknown; + }) => ( + + ), + Badge: ({ text, state }: { text: string; state?: string }) => ( + + {text} + + ), + Tabs: MockTabs, + Tooltip: MockTooltip, +})); + +vi.mock('./ResetLimitDialog', () => ({ + ResetLimitDialog: ({ target }: { target: AdminBalanceListItem | null }) => + target ?
{target.name}
: null, +})); + +function renderWithClient(children: React.ReactNode) { + const queryClient = new QueryClient(); + return render({children}); +} + +const items: AdminBalanceListItem[] = [ + { + id: 'u1', + name: 'Alice', + email: 'alice@example.com', + avatar: '', + tokenCredits: 500, + balanceEnabled: true, + refillAmount: 2000, + }, + { + id: 'u2', + name: 'Bob', + email: 'bob@example.com', + avatar: '', + tokenCredits: 0, + balanceEnabled: true, + refillAmount: 1500, + pendingRequest: { + requestId: 'req-1', + requestedAt: '2026-01-01T00:00:00.000Z', + reason: 'ran out', + }, + }, + { + id: 'u3', + name: 'Cara', + email: 'cara@example.com', + avatar: '', + tokenCredits: 0, + balanceEnabled: true, + refillAmount: 1000, + }, + { + id: 'u4', + name: 'Dee', + email: 'dee@example.com', + avatar: '', + tokenCredits: 0, + balanceEnabled: false, + refillAmount: 500, + }, +]; + +describe('BalanceTab', () => { + beforeEach(() => { + mockHasCapability.mockReturnValue(true); + mockGetBalanceListFn.mockResolvedValue({ items, total: items.length, pendingCount: 1 }); + }); + + it('renders each user with their balance, status, and visible (non-tooltip) request details', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const table = within(screen.getByRole('table')); + + expect(table.getByText('alice@example.com')).toBeInTheDocument(); + expect(table.getByText('500')).toBeInTheDocument(); + expect(table.getByText('bob@example.com')).toBeInTheDocument(); + + // Pending-request details are plain visible text, not hover-only. + expect(table.getByText('com_balance_status_requested')).toBeInTheDocument(); + expect(table.getByText('· 2h ago')).toBeInTheDocument(); + expect(table.getByText('“ran out”')).toBeInTheDocument(); + }); + + it('shows "Out of credits" for an enabled user with zero balance and no pending request', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const table = within(screen.getByRole('table')); + + expect(table.getByText('com_balance_status_out_of_credits')).toBeInTheDocument(); + }); + + it('shows "Not enabled" — and disables the action with a visible reason — for a user with balanceEnabled: false, regardless of capability', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const table = within(screen.getByRole('table')); + const deeRow = table.getByText('dee@example.com').closest('tr') as HTMLElement; + expect(deeRow).not.toBeNull(); + + const statusCell = deeRow.querySelectorAll('td')[1]; + expect(within(statusCell).getByText('com_balance_status_not_enabled')).toBeInTheDocument(); + + const actionButton = within(deeRow).getByRole('button'); + expect(actionButton).toBeDisabled(); + // The disabled reason renders via a real tooltip component (not a + // native `title` on the disabled button, which no browser ever shows) + // — so the status badge's text and the tooltip's reason both appear in + // this row, once each. + expect(within(deeRow).getAllByText('com_balance_status_not_enabled')).toHaveLength(2); + }); + + it('shows a success-colored "Available" badge for an available user (enabled, positive balance, no pending request)', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const table = within(screen.getByRole('table')); + const aliceRow = table.getByText('alice@example.com').closest('tr') as HTMLElement; + expect(aliceRow).not.toBeNull(); + const statusCell = aliceRow.querySelectorAll('td')[1]; + const badge = within(statusCell).getByTestId('badge'); + expect(badge).toHaveTextContent('com_balance_status_available'); + expect(badge).toHaveAttribute('data-state', 'success'); + }); + + it('disables every Reset limit action when the caller lacks MANAGE_BALANCES, even for a user with a pending request', async () => { + mockHasCapability.mockReturnValue(false); + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const buttons = screen.getAllByRole('button', { name: /com_balance_reset_limit Bob/ }); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((b) => b.hasAttribute('disabled'))).toBe(true); + }); + + it('disables Reset limit for a user with no configured refill amount', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const table = within(screen.getByRole('table')); + const deeRow = table.getByText('dee@example.com').closest('tr') as HTMLElement; + // Dee is already disabled via balanceEnabled: false; also exercise a + // refillAmount: 0 case directly against the button's own gating logic. + expect(within(deeRow).getByRole('button')).toBeDisabled(); + }); + + it('disables Reset limit for an otherwise-eligible user with no pending request', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const table = within(screen.getByRole('table')); + // Alice is enabled with a configured refillAmount, but has no pendingRequest. + const aliceRow = table.getByText('alice@example.com').closest('tr') as HTMLElement; + const actionButton = within(aliceRow).getByRole('button'); + expect(actionButton).toBeDisabled(); + expect(within(aliceRow).getByText('com_balance_no_pending_request')).toBeInTheDocument(); + }); + + it('opens the Reset limit dialog for a user with a pending request', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const table = within(screen.getByRole('table')); + const bobRow = table.getByText('bob@example.com').closest('tr') as HTMLElement; + within(bobRow).getByRole('button').click(); + + await waitFor(() => expect(screen.getByTestId('reset-limit-dialog')).toBeInTheDocument()); + }); + + it('shows the global pending count on the Requests tab and switches the query view on click', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + expect(screen.getByText('com_balance_tab_requests {"count":1}')).toBeInTheDocument(); + + mockGetBalanceListFn.mockClear(); + screen.getByText('com_balance_tab_requests {"count":1}').click(); + + await waitFor(() => + expect(mockGetBalanceListFn).toHaveBeenCalledWith({ data: { search: '', view: 'requests' } }), + ); + }); +}); diff --git a/src/components/balance/BalanceTab.tsx b/src/components/balance/BalanceTab.tsx new file mode 100644 index 00000000..b293fbaa --- /dev/null +++ b/src/components/balance/BalanceTab.tsx @@ -0,0 +1,319 @@ +import { useState } from 'react'; +import { Button, Badge, Tabs, Tooltip } from '@clickhouse/click-ui'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import type { AdminBalanceListItem, BalanceListView } from '@/types'; +import { Avatar, EmptyState, LoadingState, Pagination, SearchInput } from '@/components/shared'; +import { useCapabilities, useLocalize, useDebouncedFilter } from '@/hooks'; +import { balanceListQueryOptions, BALANCE_PAGE_SIZE } from '@/server'; +import { MANAGE_BALANCES_CAPABILITY } from '@/constants'; +import { ResetLimitDialog } from './ResetLimitDialog'; +import { cn, formatRelativeTime } from '@/utils'; + +type Localize = ReturnType; + +/** + * Four mutually-exclusive display states, checked in this order: + * `balanceEnabled: false` wins regardless of a pending request, because it's + * what determines whether "Add credits" can succeed at all — the other + * states only matter once the feature is actually usable for this user. + */ +function statusFor( + item: AdminBalanceListItem, +): 'not_enabled' | 'requested' | 'out_of_credits' | 'available' { + if (!item.balanceEnabled) return 'not_enabled'; + if (item.pendingRequest) return 'requested'; + if (item.tokenCredits === 0) return 'out_of_credits'; + return 'available'; +} + +function StatusCell({ item, localize }: { item: AdminBalanceListItem; localize: Localize }) { + const status = statusFor(item); + + if (status === 'available') { + return ; + } + if (status === 'not_enabled') { + return ; + } + if (status === 'out_of_credits') { + return ; + } + + const pending = item.pendingRequest; + return ( +
+
+ + {pending && ( + + · {formatRelativeTime(pending.requestedAt)} + + )} +
+ {pending?.reason && ( + + “{pending.reason}” + + )} +
+ ); +} + +function ResetLimitButton({ + item, + canManage, + fillWidth, + localize, + onClick, +}: { + item: AdminBalanceListItem; + canManage: boolean; + fillWidth?: boolean; + localize: Localize; + onClick: () => void; +}) { + const hasRefillAmount = (item.refillAmount ?? 0) > 0; + const hasPendingRequest = item.pendingRequest != null; + const disabled = !canManage || !item.balanceEnabled || !hasPendingRequest || !hasRefillAmount; + let title: string | undefined; + if (!canManage) { + title = localize('com_cap_no_permission', { cap: MANAGE_BALANCES_CAPABILITY }); + } else if (!item.balanceEnabled) { + title = localize('com_balance_status_not_enabled'); + } else if (!hasPendingRequest) { + title = localize('com_balance_no_pending_request'); + } else if (!hasRefillAmount) { + title = localize('com_balance_no_refill_amount'); + } + + const button = ( + + +
+ ), +})); + +const target: AdminBalanceListItem = { + id: 'u1', + name: 'Alice', + email: 'alice@example.com', + avatar: '', + tokenCredits: 500, + balanceEnabled: true, + refillAmount: 2000, + // The row action that opens this dialog is itself gated on a pending + // request existing, so every "normal" scenario here has one by default. + pendingRequest: { requestId: 'req-base', requestedAt: '2026-01-01T00:00:00.000Z' }, +}; + +describe('ResetLimitDialog', () => { + beforeEach(() => { + mockMutate.mockClear(); + if (!globalThis.crypto?.randomUUID) { + // @ts-expect-error -- test shim + globalThis.crypto = { randomUUID: () => 'test-uuid' }; + } + }); + + it('renders nothing when there is no target', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the refill amount and resulting balance, with confirm enabled', () => { + render(); + + expect( + screen.getByText('com_balance_reset_limit_confirm {"amount":"2,000","name":"Alice"}'), + ).toBeInTheDocument(); + expect( + screen.getByText('com_balance_resulting_balance {"amount":"2,500"}'), + ).toBeInTheDocument(); + const confirmButton = screen.getByText('com_balance_reset_limit'); + expect(confirmButton).not.toBeDisabled(); + }); + + it('disables confirm and shows a warning when no refill amount is configured', () => { + const noRefill: AdminBalanceListItem = { ...target, refillAmount: 0 }; + render(); + + expect(screen.getByText('com_balance_no_refill_amount')).toBeInTheDocument(); + expect(screen.getByText('com_balance_reset_limit')).toBeDisabled(); + }); + + it('shows the pending request reason when present', () => { + const withPending: AdminBalanceListItem = { + ...target, + pendingRequest: { + requestId: 'req-1', + requestedAt: '2026-01-01T00:00:00.000Z', + reason: 'need more for a project', + }, + }; + render(); + + expect(screen.getByText('com_balance_request_reason')).toBeInTheDocument(); + expect(screen.getByText('need more for a project')).toBeInTheDocument(); + }); + + it('confirms with the refillAmount, target userId, and a pending requestId when present', () => { + const withPending: AdminBalanceListItem = { + ...target, + pendingRequest: { requestId: 'req-1', requestedAt: '2026-01-01T00:00:00.000Z' }, + }; + render(); + + fireEvent.click(screen.getByText('com_balance_reset_limit')); + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'u1', amount: 2000, requestId: 'req-1' }), + expect.anything(), + ); + }); + + it('does not confirm when there is no usable refill amount', () => { + const noRefill: AdminBalanceListItem = { ...target, refillAmount: 0 }; + render(); + + fireEvent.click(screen.getByText('com_balance_reset_limit')); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it('disables confirm and shows a warning if the dialog is somehow open without a pending request', () => { + const noPending: AdminBalanceListItem = { ...target, pendingRequest: undefined }; + render(); + + expect(screen.getByText('com_balance_no_pending_request')).toBeInTheDocument(); + const confirmButton = screen.getByText('com_balance_reset_limit'); + expect(confirmButton).toBeDisabled(); + + fireEvent.click(confirmButton); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it('reuses the same idempotency key across repeated confirms of the same dialog', () => { + render(); + + const confirmButton = screen.getByText('com_balance_reset_limit'); + fireEvent.click(confirmButton); + fireEvent.click(confirmButton); + + expect(mockMutate).toHaveBeenCalledTimes(2); + const firstKey = mockMutate.mock.calls[0][0].idempotencyKey; + const secondKey = mockMutate.mock.calls[1][0].idempotencyKey; + expect(firstKey).toBe(secondKey); + }); + + it('generates a fresh idempotency key when a new target is opened', () => { + const { rerender } = render(); + fireEvent.click(screen.getByText('com_balance_reset_limit')); + const firstKey = mockMutate.mock.calls[0][0].idempotencyKey; + + const otherTarget: AdminBalanceListItem = { ...target, id: 'u2', name: 'Bob' }; + rerender(); + fireEvent.click(screen.getByText('com_balance_reset_limit')); + + expect(mockMutate).toHaveBeenCalledTimes(2); + const secondKey = mockMutate.mock.calls[1][0].idempotencyKey; + expect(secondKey).not.toBe(firstKey); + }); +}); diff --git a/src/components/balance/ResetLimitDialog.tsx b/src/components/balance/ResetLimitDialog.tsx new file mode 100644 index 00000000..a6bd7f95 --- /dev/null +++ b/src/components/balance/ResetLimitDialog.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { ConfirmationDialog } from '@clickhouse/click-ui'; +import type { AdminBalanceListItem } from '@/types'; +import { useAddCredit, useLocalize } from '@/hooks'; + +export function ResetLimitDialog({ + target, + onClose, +}: { + target: AdminBalanceListItem | null; + onClose: () => void; +}) { + const localize = useLocalize(); + const { mutate, isPending } = useAddCredit(); + // Generated once per dialog "session" — a slow-network double-click on the + // same open dialog reuses this key rather than minting a new one, so the + // backend's idempotency check actually has something to catch. + const [idempotencyKey, setIdempotencyKey] = useState(''); + + useEffect(() => { + if (target) { + setIdempotencyKey(crypto.randomUUID()); + } + }, [target]); + + if (!target) { + return null; + } + + // The row action is disabled whenever either of these is falsy, so + // reaching here without them would only happen if the underlying data + // changed out from under an already-open dialog (e.g. another admin just + // resolved the request) — guard defensively rather than assume the + // disabled button always wins the race. + const refillAmount = target.refillAmount ?? 0; + const hasPendingRequest = target.pendingRequest != null; + const canReset = refillAmount > 0 && hasPendingRequest; + const resultingBalance = target.tokenCredits + refillAmount; + + const handleConfirm = () => { + if (!canReset) return; + mutate( + { + userId: target.id, + amount: refillAmount, + idempotencyKey, + requestId: target.pendingRequest?.requestId, + name: target.name, + }, + { onSuccess: onClose }, + ); + }; + + return ( + +
+ {target.pendingRequest?.reason && ( +
+ + {localize('com_balance_request_reason')} + + + {target.pendingRequest.reason} + +
+ )} + + {canReset ? ( + <> +

+ {localize('com_balance_reset_limit_confirm', { + amount: refillAmount.toLocaleString(), + name: target.name, + })} +

+ + {localize('com_balance_col_current_balance')}: {target.tokenCredits.toLocaleString()} + + + {localize('com_balance_resulting_balance', { + amount: resultingBalance.toLocaleString(), + })} + + + ) : ( +

+ {localize( + hasPendingRequest ? 'com_balance_no_refill_amount' : 'com_balance_no_pending_request', + )} +

+ )} +
+
+ ); +} diff --git a/src/components/balance/index.ts b/src/components/balance/index.ts new file mode 100644 index 00000000..bf707acd --- /dev/null +++ b/src/components/balance/index.ts @@ -0,0 +1,2 @@ +export { BalanceTab } from './BalanceTab'; +export { ResetLimitDialog } from './ResetLimitDialog'; diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 36507312..58f9da27 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -5,7 +5,10 @@ import type * as t from '@/types'; import { useCapabilities, useLocalize } from '@/hooks'; import { SystemCapabilities } from '@/constants'; -const QUICK_LINKS: (t.NavItem & { descKey: string })[] = [ +const QUICK_LINKS: (Omit & { + icon: t.IconName; + descKey: string; +})[] = [ { labelKey: 'com_nav_configuration', path: '/configuration', diff --git a/src/constants/capabilities.ts b/src/constants/capabilities.ts index c06c2d06..924b31d1 100644 --- a/src/constants/capabilities.ts +++ b/src/constants/capabilities.ts @@ -1,10 +1,12 @@ -import { CAPABILITY_CATEGORIES as UPSTREAM_CAPABILITY_CATEGORIES } from '@librechat/data-schemas/capabilities'; +import { + CAPABILITY_CATEGORIES as UPSTREAM_CAPABILITY_CATEGORIES, + CapabilityImplications as UPSTREAM_CAPABILITY_IMPLICATIONS, +} from '@librechat/data-schemas/capabilities'; export { SystemCapabilities, expandImplications, hasImpliedCapability, - CapabilityImplications, } from '@librechat/data-schemas/capabilities'; /** @@ -19,24 +21,56 @@ export { */ export const READ_AUDIT_LOG_CAPABILITY = 'read:audit_log' as const; +/** + * Forward-compat shim: the LibreChat backend gates `/api/admin/balance` on + * these two capabilities (`READ_BALANCES`/`MANAGE_BALANCES`). They don't exist + * yet in the pinned `@librechat/data-schemas` version — byte-identical to what + * the upstream constants will resolve to post-publish. Drop these two + * constants, the `CAPABILITY_CATEGORIES` addition below, and the + * `CapabilityImplications` override below in a one-line follow-up once the + * data-schemas pin includes them natively. + */ +export const READ_BALANCES_CAPABILITY = 'read:balances' as const; +export const MANAGE_BALANCES_CAPABILITY = 'manage:balances' as const; + /** * Local override of the upstream `CAPABILITY_CATEGORIES` so the System - * category surfaces `READ_AUDIT_LOG` in the grants editing UI even while the - * dep is pinned to `data-schemas@0.0.52` (which predates the category entry). - * Without this, only seeded admins could ever hold the capability — the - * grants `CapabilityPanel` had no row to toggle. + * category surfaces `READ_AUDIT_LOG`/`READ_BALANCES`/`MANAGE_BALANCES` in the + * grants editing UI even while the dep is pinned to a version that predates + * these category entries. Without this, only seeded admins could ever hold + * these capabilities — the grants `CapabilityPanel` would have no row to + * toggle. * - * Drops to a no-op once `0.0.53+` is pinned because the upstream array already - * contains `READ_AUDIT_LOG`; the dedupe pass below keeps it safe to keep - * shipped until the shim itself is removed. + * Drops to a no-op once each capability's real upstream version is pinned, + * because the upstream array will already contain it; the dedupe pass below + * keeps this safe to leave shipped until the shims themselves are removed. */ +const SHIMMED_SYSTEM_CAPABILITIES = [ + READ_AUDIT_LOG_CAPABILITY, + READ_BALANCES_CAPABILITY, + MANAGE_BALANCES_CAPABILITY, +] as const; + export const CAPABILITY_CATEGORIES: typeof UPSTREAM_CAPABILITY_CATEGORIES = UPSTREAM_CAPABILITY_CATEGORIES.map((cat) => { if (cat.key !== 'system') return cat; const caps = cat.capabilities as readonly string[]; - if (caps.includes(READ_AUDIT_LOG_CAPABILITY)) return cat; + const missing = SHIMMED_SYSTEM_CAPABILITIES.filter((cap) => !caps.includes(cap)); + if (missing.length === 0) return cat; return { ...cat, - capabilities: [...cat.capabilities, READ_AUDIT_LOG_CAPABILITY], + capabilities: [...cat.capabilities, ...missing], } as typeof cat; }); + +/** + * Local override of the upstream `CapabilityImplications` map so the grants + * editor knows `MANAGE_BALANCES` implies `READ_BALANCES` (toggling manage on + * auto-checks read) even while the dep is pinned to a version that predates + * this implication entry. Drops to a no-op merge once the real pin lands, + * since the upstream map will already have the same entry. + */ +export const CapabilityImplications: Record = { + ...UPSTREAM_CAPABILITY_IMPLICATIONS, + [MANAGE_BALANCES_CAPABILITY]: [READ_BALANCES_CAPABILITY], +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 8991119a..bc2b3296 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,5 @@ export * from './useActiveSection'; +export * from './useAddCredit'; export * from './useAnnouncement'; export * from './useCapabilities'; export * from './useCommandMenu'; diff --git a/src/hooks/useAddCredit.ts b/src/hooks/useAddCredit.ts new file mode 100644 index 00000000..039a1e6e --- /dev/null +++ b/src/hooks/useAddCredit.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { notifySuccess, notifyError } from '@/utils'; +import { useLocalize } from './useLocalize'; +import { addCreditFn } from '@/server'; + +interface AddCreditParams { + userId: string; + amount: number; + idempotencyKey: string; + requestId?: string; + /** User-facing name, used only for the success toast — not sent to the server. */ + name: string; +} + +export function useAddCredit(): { + mutate: (params: AddCreditParams, options?: { onSuccess?: () => void }) => void; + isPending: boolean; +} { + const queryClient = useQueryClient(); + const localize = useLocalize(); + + const mutation = useMutation({ + mutationFn: ({ name: _name, ...params }: AddCreditParams) => addCreditFn({ data: params }), + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ queryKey: ['balanceRequests'] }); + notifySuccess(localize('com_toast_credit_added', { amount: vars.amount, name: vars.name })); + }, + onError: (err: Error) => notifyError(err.message), + }); + + return { + mutate: (params, options) => mutation.mutate(params, options), + isPending: mutation.isPending, + }; +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e3922958..ae159c56 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -43,6 +43,7 @@ "com_nav_users": "Users", "com_nav_groups": "Groups", "com_nav_access": "Access", + "com_nav_balance": "Balance", "com_nav_configuration": "Configuration", "com_nav_all_configuration": "All configuration", "com_nav_help": "Help", @@ -125,6 +126,7 @@ "com_toast_group_created": "Group \"{{name}}\" created", "com_toast_group_updated": "Group \"{{name}}\" updated", "com_toast_group_deleted": "Group \"{{name}}\" deleted", + "com_toast_credit_added": "Added {{amount}} credit to \"{{name}}\"", "com_toast_role_created": "Role \"{{name}}\" created", "com_toast_role_updated": "Role \"{{name}}\" updated", "com_toast_role_deleted": "Role \"{{name}}\" deleted", @@ -912,6 +914,33 @@ "com_users_recent": "Recent signups", "com_users_list": "Users list", "com_users_search": "Search users...", + "com_balance_title": "Balance", + "com_balance_subtitle": "View and top up user token-credit balances", + "com_balance_search": "Search users...", + "com_balance_view_label": "Balance view", + "com_balance_tab_all": "All users", + "com_balance_tab_requests": "Requests ({{count}})", + "com_balance_col_user": "User", + "com_balance_col_status": "Status", + "com_balance_col_current_balance": "Current balance", + "com_balance_col_last_refill": "Last refill", + "com_balance_status_not_enabled": "Not enabled", + "com_balance_status_requested": "Top-up requested", + "com_balance_status_out_of_credits": "Out of credits", + "com_balance_status_available": "Available", + "com_balance_empty": "No users found", + "com_balance_requested_ago": "Requested {{time}}", + "com_balance_add_credit": "Add credit", + "com_balance_add_credit_title": "Add credit for {{name}}", + "com_balance_amount_placeholder": "Amount in credits", + "com_balance_credits_to_add": "Credits to add", + "com_balance_resulting_balance": "Resulting balance: {{amount}}", + "com_balance_request_reason": "Reason", + "com_balance_reset_limit": "Reset limit", + "com_balance_reset_limit_title": "Reset limit for {{name}}", + "com_balance_reset_limit_confirm": "This will add {{amount}} credits to {{name}}'s balance.", + "com_balance_no_refill_amount": "No refill amount is configured for this user.", + "com_balance_no_pending_request": "This user has no pending top-up request.", "com_users_role_filter": "Filter by role", "com_users_col_user": "User", "com_users_col_role": "Role", @@ -1135,6 +1164,8 @@ "com_cap_read_assistants": "Read assistants", "com_cap_manage_assistants": "Manage assistants", "com_cap_read_audit_log": "Read audit log", + "com_cap_read_balances": "Read balances", + "com_cap_manage_balances": "Manage balances", "com_cap_desc_access_admin": "Full admin panel access", "com_cap_desc_read_users": "View user list and profiles", "com_cap_desc_manage_users": "Create, edit, and delete users", @@ -1158,6 +1189,8 @@ "com_cap_desc_read_assistants": "View assistant configurations", "com_cap_desc_manage_assistants": "Create, edit, and delete assistants", "com_cap_desc_read_audit_log": "View and export the audit log of grant changes", + "com_cap_desc_read_balances": "View user token-credit balances and pending top-up requests", + "com_cap_desc_manage_balances": "Add credit to user balances and resolve pending requests", "com_access_denied_title": "Access denied", "com_access_denied_description": "You don't have permission to view this page. Contact your administrator.", "com_cap_no_permission": "You need {{cap}} to perform this action", @@ -1232,6 +1265,7 @@ "com_grants_assign_hint": "Roles and groups can be edited directly from the table above.", "com_grants_assign_no_users": "No eligible users found", "com_error_load_groups": "Failed to load groups. Please try again.", + "com_error_load_balances": "Failed to load balances. Please try again.", "com_error_load_members": "Failed to load members. Please try again.", "com_perm_unavailable_title": "Could not verify permissions", "com_perm_unavailable_desc": "The permissions service is temporarily unavailable. Please reload the page to try again.", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5a003893..188c19ba 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AppUsersRouteImport } from './routes/_app/users' import { Route as AppHelpRouteImport } from './routes/_app/help' import { Route as AppGrantsRouteImport } from './routes/_app/grants' +import { Route as AppBalanceRouteImport } from './routes/_app/balance' import { Route as AppAccessRouteImport } from './routes/_app/access' import { Route as AppConfigurationIndexRouteImport } from './routes/_app/configuration/index' import { Route as AuthOpenidCallbackRouteImport } from './routes/auth/openid/callback' @@ -48,6 +49,11 @@ const AppGrantsRoute = AppGrantsRouteImport.update({ path: '/grants', getParentRoute: () => AppRoute, } as any) +const AppBalanceRoute = AppBalanceRouteImport.update({ + id: '/balance', + path: '/balance', + getParentRoute: () => AppRoute, +} as any) const AppAccessRoute = AppAccessRouteImport.update({ id: '/access', path: '/access', @@ -68,6 +74,7 @@ export interface FileRoutesByFullPath { '/': typeof AppIndexRoute '/login': typeof LoginRoute '/access': typeof AppAccessRoute + '/balance': typeof AppBalanceRoute '/grants': typeof AppGrantsRoute '/help': typeof AppHelpRoute '/users': typeof AppUsersRoute @@ -77,6 +84,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/login': typeof LoginRoute '/access': typeof AppAccessRoute + '/balance': typeof AppBalanceRoute '/grants': typeof AppGrantsRoute '/help': typeof AppHelpRoute '/users': typeof AppUsersRoute @@ -89,6 +97,7 @@ export interface FileRoutesById { '/_app': typeof AppRouteWithChildren '/login': typeof LoginRoute '/_app/access': typeof AppAccessRoute + '/_app/balance': typeof AppBalanceRoute '/_app/grants': typeof AppGrantsRoute '/_app/help': typeof AppHelpRoute '/_app/users': typeof AppUsersRoute @@ -102,6 +111,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/access' + | '/balance' | '/grants' | '/help' | '/users' @@ -111,6 +121,7 @@ export interface FileRouteTypes { to: | '/login' | '/access' + | '/balance' | '/grants' | '/help' | '/users' @@ -122,6 +133,7 @@ export interface FileRouteTypes { | '/_app' | '/login' | '/_app/access' + | '/_app/balance' | '/_app/grants' | '/_app/help' | '/_app/users' @@ -180,6 +192,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppGrantsRouteImport parentRoute: typeof AppRoute } + '/_app/balance': { + id: '/_app/balance' + path: '/balance' + fullPath: '/balance' + preLoaderRoute: typeof AppBalanceRouteImport + parentRoute: typeof AppRoute + } '/_app/access': { id: '/_app/access' path: '/access' @@ -206,6 +225,7 @@ declare module '@tanstack/react-router' { interface AppRouteChildren { AppAccessRoute: typeof AppAccessRoute + AppBalanceRoute: typeof AppBalanceRoute AppGrantsRoute: typeof AppGrantsRoute AppHelpRoute: typeof AppHelpRoute AppUsersRoute: typeof AppUsersRoute @@ -215,6 +235,7 @@ interface AppRouteChildren { const AppRouteChildren: AppRouteChildren = { AppAccessRoute: AppAccessRoute, + AppBalanceRoute: AppBalanceRoute, AppGrantsRoute: AppGrantsRoute, AppHelpRoute: AppHelpRoute, AppUsersRoute: AppUsersRoute, diff --git a/src/routes/_app.tsx b/src/routes/_app.tsx index 4eeb0421..a718f9c2 100644 --- a/src/routes/_app.tsx +++ b/src/routes/_app.tsx @@ -16,10 +16,10 @@ const ROUTE_TITLE_KEYS: Record = { '/users': 'com_users_title', '/access': 'com_access_title', '/grants': 'com_grants_title', + '/balance': 'com_balance_title', '/help': 'com_help_title', }; - export const Route = createFileRoute('/_app')({ beforeLoad: async ({ location }) => { const result = await verifyAdminTokenFn(); diff --git a/src/routes/_app/balance.tsx b/src/routes/_app/balance.tsx new file mode 100644 index 00000000..56091cba --- /dev/null +++ b/src/routes/_app/balance.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { AccessDenied, PermissionsUnavailable } from '@/components/shared'; +import { READ_BALANCES_CAPABILITY } from '@/constants'; +import { BalanceTab } from '@/components/balance'; +import { useCapabilities } from '@/hooks'; + +export const Route = createFileRoute('/_app/balance')({ + component: BalanceRoute, +}); + +function BalanceRoute() { + const { hasCapability, isLoading, isError } = useCapabilities(); + + if (isLoading) return null; + if (isError) return ; + if (!hasCapability(READ_BALANCES_CAPABILITY)) return ; + + return ; +} diff --git a/src/server/balance.ts b/src/server/balance.ts new file mode 100644 index 00000000..8f5edf6b --- /dev/null +++ b/src/server/balance.ts @@ -0,0 +1,114 @@ +/** + * Server functions for the admin Balance tab. + * + * Calls the LibreChat Admin API (/api/admin/balance) for the paginated + * user-balance list and the "Add credit" action. No direct DB access. + */ + +import { z } from 'zod'; +import { queryOptions } from '@tanstack/react-query'; +import { createServerFn } from '@tanstack/react-start'; +import type { AdminBalanceListItem, BalanceListView } from '@/types'; +import { READ_BALANCES_CAPABILITY, MANAGE_BALANCES_CAPABILITY } from '@/constants'; +import { apiFetch, extractApiError } from './utils/api'; +import { requireCapability } from './capabilities'; + +export const BALANCE_PAGE_SIZE = 50; + +interface BalanceListResponse { + items: AdminBalanceListItem[]; + total: number; + /** Global pending-request count — independent of the current view/search, + * drives the "Requests (N)" tab badge. */ + pendingCount: number; +} + +export const getBalanceListFn = createServerFn({ method: 'GET' }) + .inputValidator( + z.object({ + search: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + view: z.enum(['all', 'requests']).optional(), + }), + ) + .handler( + async ({ + data, + }: { + data: { search?: string; limit?: number; offset?: number; view?: BalanceListView }; + }): Promise => { + await requireCapability(READ_BALANCES_CAPABILITY); + + const params = new URLSearchParams(); + if (data.search) params.set('search', data.search); + if (data.limit != null) params.set('limit', String(data.limit)); + if (data.offset != null) params.set('offset', String(data.offset)); + if (data.view === 'requests') params.set('filter', 'requests'); + const qs = params.toString(); + const response = await apiFetch(`/api/admin/balance${qs ? `?${qs}` : ''}`); + if (!response.ok) { + throw new Error(`Failed to fetch balances: ${response.status}`); + } + const json = (await response.json()) as BalanceListResponse; + return { items: json.items, total: json.total, pendingCount: json.pendingCount ?? 0 }; + }, + ); + +export const balanceListQueryOptions = (page = 1, search = '', view: BalanceListView = 'all') => + queryOptions({ + queryKey: ['balanceRequests', page, search, view], + queryFn: () => + getBalanceListFn({ + data: { + search: search || undefined, + limit: BALANCE_PAGE_SIZE, + offset: (page - 1) * BALANCE_PAGE_SIZE, + view, + }, + }), + staleTime: 30_000, + }); + +export const addCreditFn = createServerFn({ method: 'POST' }) + .inputValidator( + z.object({ + userId: z.string(), + amount: z.number(), + idempotencyKey: z.string(), + requestId: z.string().optional(), + }), + ) + .handler( + async ({ + data, + }: { + data: { userId: string; amount: number; idempotencyKey: string; requestId?: string }; + }): Promise<{ + resultingBalance: number; + creditAlreadyApplied: boolean; + requestAlreadyResolved: boolean; + }> => { + await requireCapability(MANAGE_BALANCES_CAPABILITY); + + const response = await apiFetch( + `/api/admin/balance/${encodeURIComponent(data.userId)}/credit`, + { + method: 'POST', + body: JSON.stringify({ + amount: data.amount, + idempotencyKey: data.idempotencyKey, + requestId: data.requestId, + }), + }, + ); + if (!response.ok) { + await extractApiError(response, 'Failed to add credit'); + } + return (await response.json()) as { + resultingBalance: number; + creditAlreadyApplied: boolean; + requestAlreadyResolved: boolean; + }; + }, + ); diff --git a/src/server/index.ts b/src/server/index.ts index ad8cf1d7..0ee06d5a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,5 @@ export * from './auth'; +export * from './balance'; export * from './capabilities'; export * from './config'; export * from './groups'; diff --git a/src/types/balance.ts b/src/types/balance.ts new file mode 100644 index 00000000..ca28799f --- /dev/null +++ b/src/types/balance.ts @@ -0,0 +1,32 @@ +/** + * Local shim for `AdminBalanceListItem` — not yet part of the pinned + * `librechat-data-provider` version. Byte-identical to the real type added + * to that package; delete this file and import from `librechat-data-provider` + * once the pin is bumped past the version that includes it. + */ +export type AdminBalanceListItem = { + id: string; + name: string; + email: string; + avatar: string; + /** + * 0 when the user has no Balance document yet OR when `balanceEnabled` is + * false — always check `balanceEnabled` before treating this as a real, + * exhausted balance. + */ + tokenCredits: number; + /** Whether the balance feature is actually enabled for this user's + * effective (role/user-override-resolved) config. */ + balanceEnabled: boolean; + lastRefill?: string; + /** The user's configured auto-refill amount, if any — used as a sensible + * per-user default top-up amount in the Add credit dialog. */ + refillAmount?: number; + pendingRequest?: { + requestId: string; + requestedAt: string; + reason?: string; + }; +}; + +export type BalanceListView = 'all' | 'requests'; diff --git a/src/types/index.ts b/src/types/index.ts index 33559d05..77e62f2a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ export type * from './access'; export type * from './auth'; +export type * from './balance'; export type * from './config'; export type * from './config-ui'; export type * from './fields'; diff --git a/src/types/layout.ts b/src/types/layout.ts index 7563e578..4cd8b6be 100644 --- a/src/types/layout.ts +++ b/src/types/layout.ts @@ -13,7 +13,11 @@ export interface SidebarProps { export interface NavItem { labelKey: string; path: string; - icon: IconName; + /** One of click-ui's built-in icons. Ignored when `customIcon` is set. */ + icon?: IconName; + /** A custom icon, for cases click-ui's built-in set has no good match for + * (e.g. a coin/token glyph for Balance) — rendered in place of `icon`. */ + customIcon?: ReactNode; capability?: string | string[]; } diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts index 19843bc0..e30a8bd3 100644 --- a/src/utils/format.test.ts +++ b/src/utils/format.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { serializeKVPairs, deepSerializeKVPairs } from './format'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { serializeKVPairs, deepSerializeKVPairs, formatRelativeTime } from './format'; describe('serializeKVPairs', () => { it('converts KV pairs to a record', () => { @@ -143,3 +143,42 @@ describe('deepSerializeKVPairs', () => { expect(result.dropParams).toEqual(['stop', 'presence_penalty']); }); }); + +describe('formatRelativeTime', () => { + const NOW = new Date('2026-06-15T12:00:00.000Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "just now" for under a minute', () => { + expect(formatRelativeTime(new Date(NOW.getTime() - 30 * 1000))).toBe('just now'); + }); + + it('formats minutes', () => { + expect(formatRelativeTime(new Date(NOW.getTime() - 5 * 60 * 1000))).toBe('5m ago'); + }); + + it('formats hours', () => { + expect(formatRelativeTime(new Date(NOW.getTime() - 3 * 60 * 60 * 1000))).toBe('3h ago'); + }); + + it('formats days', () => { + expect(formatRelativeTime(new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000))).toBe('2d ago'); + }); + + it('falls back to a locale date beyond 7 days', () => { + const eightDaysAgo = new Date(NOW.getTime() - 8 * 24 * 60 * 60 * 1000); + expect(formatRelativeTime(eightDaysAgo)).toBe(eightDaysAgo.toLocaleDateString()); + }); + + it('accepts an ISO string as well as a Date', () => { + const fiveMinutesAgo = new Date(NOW.getTime() - 5 * 60 * 1000).toISOString(); + expect(formatRelativeTime(fiveMinutesAgo)).toBe('5m ago'); + }); +}); diff --git a/src/utils/format.ts b/src/utils/format.ts index 9a3ce6a7..a53e7484 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -62,6 +62,30 @@ export function getInitials(name: string): string { return (parts[0]?.[0] ?? '').toUpperCase(); } +/** + * Bucketed relative-time formatter for the balance-request "Requested Xh ago" + * badge. Falls back to a locale date string beyond ~7 days — no date-library + * dependency needed for this. + */ +export function formatRelativeTime(date: string | Date): string { + const target = typeof date === 'string' ? new Date(date) : date; + const diffMs = Date.now() - target.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + + if (diffSeconds < 60) return 'just now'; + + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes}m ago`; + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + + return target.toLocaleDateString(); +} + /** * Recursively flattens a nested object into dot-separated paths. * Plain objects become prefixes; arrays, primitives, and null become leaf entries. From 360e78d5b7d71b2cc6cbfd22797f2b1ebe4f5e54 Mon Sep 17 00:00:00 2001 From: Romuald Wandji Date: Tue, 6 Oct 2026 10:34:27 +0200 Subject: [PATCH 2/2] fix(balance): allow resets for out-of-credit users --- src/components/balance/BalanceTab.test.tsx | 16 ++++++++++++- src/components/balance/BalanceTab.tsx | 6 +++-- .../balance/ResetLimitDialog.test.tsx | 22 +++++++++++++++--- src/components/balance/ResetLimitDialog.tsx | 23 +++++++++++-------- src/components/balance/eligibility.ts | 6 +++++ 5 files changed, 57 insertions(+), 16 deletions(-) create mode 100644 src/components/balance/eligibility.ts diff --git a/src/components/balance/BalanceTab.test.tsx b/src/components/balance/BalanceTab.test.tsx index f9bc1e2d..21790b64 100644 --- a/src/components/balance/BalanceTab.test.tsx +++ b/src/components/balance/BalanceTab.test.tsx @@ -201,6 +201,20 @@ describe('BalanceTab', () => { expect(table.getByText('com_balance_status_out_of_credits')).toBeInTheDocument(); }); + it('enables Reset limit for an out-of-credit user without a pending request', async () => { + renderWithClient(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const table = within(screen.getByRole('table')); + const caraRow = table.getByText('cara@example.com').closest('tr') as HTMLElement; + const actionButton = within(caraRow).getByRole('button'); + + expect(actionButton).not.toBeDisabled(); + actionButton.click(); + + await waitFor(() => expect(screen.getByTestId('reset-limit-dialog')).toHaveTextContent('Cara')); + }); + it('shows "Not enabled" — and disables the action with a visible reason — for a user with balanceEnabled: false, regardless of capability', async () => { renderWithClient(); @@ -255,7 +269,7 @@ describe('BalanceTab', () => { expect(within(deeRow).getByRole('button')).toBeDisabled(); }); - it('disables Reset limit for an otherwise-eligible user with no pending request', async () => { + it('disables Reset limit for a positive-balance user with no pending request', async () => { renderWithClient(); await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); diff --git a/src/components/balance/BalanceTab.tsx b/src/components/balance/BalanceTab.tsx index b293fbaa..7f5a6505 100644 --- a/src/components/balance/BalanceTab.tsx +++ b/src/components/balance/BalanceTab.tsx @@ -7,6 +7,7 @@ import { useCapabilities, useLocalize, useDebouncedFilter } from '@/hooks'; import { balanceListQueryOptions, BALANCE_PAGE_SIZE } from '@/server'; import { MANAGE_BALANCES_CAPABILITY } from '@/constants'; import { ResetLimitDialog } from './ResetLimitDialog'; +import { canResetLimit } from './eligibility'; import { cn, formatRelativeTime } from '@/utils'; type Localize = ReturnType; @@ -74,13 +75,14 @@ function ResetLimitButton({ }) { const hasRefillAmount = (item.refillAmount ?? 0) > 0; const hasPendingRequest = item.pendingRequest != null; - const disabled = !canManage || !item.balanceEnabled || !hasPendingRequest || !hasRefillAmount; + const outOfCredits = item.tokenCredits === 0; + const disabled = !canManage || !canResetLimit(item); let title: string | undefined; if (!canManage) { title = localize('com_cap_no_permission', { cap: MANAGE_BALANCES_CAPABILITY }); } else if (!item.balanceEnabled) { title = localize('com_balance_status_not_enabled'); - } else if (!hasPendingRequest) { + } else if (!hasPendingRequest && !outOfCredits) { title = localize('com_balance_no_pending_request'); } else if (!hasRefillAmount) { title = localize('com_balance_no_refill_amount'); diff --git a/src/components/balance/ResetLimitDialog.test.tsx b/src/components/balance/ResetLimitDialog.test.tsx index 9a5e1092..76048e77 100644 --- a/src/components/balance/ResetLimitDialog.test.tsx +++ b/src/components/balance/ResetLimitDialog.test.tsx @@ -48,8 +48,6 @@ const target: AdminBalanceListItem = { tokenCredits: 500, balanceEnabled: true, refillAmount: 2000, - // The row action that opens this dialog is itself gated on a pending - // request existing, so every "normal" scenario here has one by default. pendingRequest: { requestId: 'req-base', requestedAt: '2026-01-01T00:00:00.000Z' }, }; @@ -118,6 +116,24 @@ describe('ResetLimitDialog', () => { ); }); + it('confirms for an out-of-credit target without a pending request', () => { + const outOfCredits: AdminBalanceListItem = { + ...target, + tokenCredits: 0, + pendingRequest: undefined, + }; + render(); + + const confirmButton = screen.getByText('com_balance_reset_limit'); + expect(confirmButton).not.toBeDisabled(); + fireEvent.click(confirmButton); + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'u1', amount: 2000, requestId: undefined }), + expect.anything(), + ); + }); + it('does not confirm when there is no usable refill amount', () => { const noRefill: AdminBalanceListItem = { ...target, refillAmount: 0 }; render(); @@ -127,7 +143,7 @@ describe('ResetLimitDialog', () => { expect(mockMutate).not.toHaveBeenCalled(); }); - it('disables confirm and shows a warning if the dialog is somehow open without a pending request', () => { + it('disables confirm for a positive-balance target without a pending request', () => { const noPending: AdminBalanceListItem = { ...target, pendingRequest: undefined }; render(); diff --git a/src/components/balance/ResetLimitDialog.tsx b/src/components/balance/ResetLimitDialog.tsx index a6bd7f95..f719ca96 100644 --- a/src/components/balance/ResetLimitDialog.tsx +++ b/src/components/balance/ResetLimitDialog.tsx @@ -2,6 +2,17 @@ import { useEffect, useState } from 'react'; import { ConfirmationDialog } from '@clickhouse/click-ui'; import type { AdminBalanceListItem } from '@/types'; import { useAddCredit, useLocalize } from '@/hooks'; +import { canResetLimit } from './eligibility'; + +function unavailableMessage(item: AdminBalanceListItem) { + if (!item.balanceEnabled) { + return 'com_balance_status_not_enabled' as const; + } + if ((item.refillAmount ?? 0) <= 0) { + return 'com_balance_no_refill_amount' as const; + } + return 'com_balance_no_pending_request' as const; +} export function ResetLimitDialog({ target, @@ -27,14 +38,8 @@ export function ResetLimitDialog({ return null; } - // The row action is disabled whenever either of these is falsy, so - // reaching here without them would only happen if the underlying data - // changed out from under an already-open dialog (e.g. another admin just - // resolved the request) — guard defensively rather than assume the - // disabled button always wins the race. const refillAmount = target.refillAmount ?? 0; - const hasPendingRequest = target.pendingRequest != null; - const canReset = refillAmount > 0 && hasPendingRequest; + const canReset = canResetLimit(target); const resultingBalance = target.tokenCredits + refillAmount; const handleConfirm = () => { @@ -95,9 +100,7 @@ export function ResetLimitDialog({ ) : (

- {localize( - hasPendingRequest ? 'com_balance_no_refill_amount' : 'com_balance_no_pending_request', - )} + {localize(unavailableMessage(target))}

)}
diff --git a/src/components/balance/eligibility.ts b/src/components/balance/eligibility.ts new file mode 100644 index 00000000..2113bb2e --- /dev/null +++ b/src/components/balance/eligibility.ts @@ -0,0 +1,6 @@ +import type { AdminBalanceListItem } from '@/types'; + +export function canResetLimit(item: AdminBalanceListItem): boolean { + const hasResetTrigger = item.pendingRequest != null || item.tokenCredits === 0; + return item.balanceEnabled && (item.refillAmount ?? 0) > 0 && hasResetTrigger; +}