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_auth_title')}
@@ -112,7 +144,7 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
)}
>
-
+ {item.customIcon ?? (item.icon && )}
{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..21790b64
--- /dev/null
+++ b/src/components/balance/BalanceTab.test.tsx
@@ -0,0 +1,308 @@
+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('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();
+
+ 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 a positive-balance 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..7f5a6505
--- /dev/null
+++ b/src/components/balance/BalanceTab.tsx
@@ -0,0 +1,321 @@
+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 { canResetLimit } from './eligibility';
+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 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 && !outOfCredits) {
+ title = localize('com_balance_no_pending_request');
+ } else if (!hasRefillAmount) {
+ title = localize('com_balance_no_refill_amount');
+ }
+
+ const button = (
+
+ );
+
+ if (!title) {
+ return button;
+ }
+
+ // The native `title` attribute never shows on a disabled