fix(ui): show selected user's Max Budget on Usage page, not admin's

The Usage page Max Budget tile fed `userMaxBudget` from `currentUser`
(the logged-in admin, via a self-only `/v2/user/info`) instead of the
selected user's budget. This made every filtered user display the
admin's cap, showed a number for users with no limit, and never
rendered the budget period.

Fetch the selected user's info via a new `useUserInfo(userId)` hook
(mirroring `useCurrentUser`) and feed its `max_budget` +
`budget_duration` to `ViewUserSpend`. The global view still falls back
to `currentUser`. `ViewUserSpend` gains an optional `budgetDuration`
prop and renders "over <period>" via the canonical
`getBudgetDurationLabel`.

Adds tests for the hook and the period-aware tile rendering.
This commit is contained in:
Trevor Mack 2026-09-16 15:05:28 -04:00
parent b04d530ecf
commit 3c7d3e0c57
No known key found for this signature in database
5 changed files with 196 additions and 3 deletions

View file

@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useUserInfo } from "./useUserInfo";
import { userGetInfoV2 } from "@/components/networking";
import type { UserInfoV2Response } from "@/components/networking";
vi.mock("@/components/networking", () => ({
userGetInfoV2: vi.fn(),
}));
vi.mock("../common/queryKeysFactory", () => ({
createQueryKeys: vi.fn((resource: string) => ({
all: [resource],
lists: () => [resource, "list"],
list: (params?: any) => [resource, "list", { params }],
details: () => [resource, "detail"],
detail: (uid: string) => [resource, "detail", uid],
})),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockSelectedUser: UserInfoV2Response = {
user_id: "selected-user-id",
user_email: "selected@example.com",
user_alias: "Selected User",
user_role: "internal_user",
spend: 42.0,
max_budget: 600.0,
models: [],
budget_duration: "30d",
budget_reset_at: null,
metadata: null,
created_at: null,
updated_at: null,
sso_user_id: null,
teams: [],
};
describe("useUserInfo", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "admin-user-id",
userRole: "Admin",
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("fetches the specified user's info by user_id (not the caller's)", async () => {
(userGetInfoV2 as any).mockResolvedValue(mockSelectedUser);
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockSelectedUser);
// The whole point of the fix: the target user_id is passed through.
expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token", "selected-user-id");
expect(userGetInfoV2).toHaveBeenCalledTimes(1);
});
it("does not execute when userId is null (global view has no single user)", async () => {
const { result } = renderHook(() => useUserInfo(null), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userGetInfoV2).not.toHaveBeenCalled();
});
it("does not execute when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({ accessToken: null, userId: "admin-user-id", userRole: "Admin" });
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
expect(result.current.isFetched).toBe(false);
expect(userGetInfoV2).not.toHaveBeenCalled();
});
it("surfaces the error when the lookup fails", async () => {
const err = new Error("forbidden");
(userGetInfoV2 as any).mockRejectedValue(err);
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(err);
});
});

View file

@ -0,0 +1,25 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { UserInfoV2Response, userGetInfoV2 } from "@/components/networking";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
const userKeys = createQueryKeys("users");
/**
* Fetch a SPECIFIC user's info from /v2/user/info?user_id=<userId>.
*
* Companion to useCurrentUser (which self-looks-up the caller): pass the user
* you actually want to display. Disabled when userId is null so the caller can
* fall back to a global/unfiltered view without firing a request. Admins may
* query any user; the backend authorizes the lookup.
*/
export const useUserInfo = (userId: string | null): UseQueryResult<UserInfoV2Response> => {
const { accessToken } = useAuthorized();
return useQuery<UserInfoV2Response>({
queryKey: userKeys.detail(userId ?? ""),
queryFn: async () => {
return await userGetInfoV2(accessToken!, userId!);
},
enabled: Boolean(accessToken && userId),
});
};

View file

@ -23,6 +23,7 @@ import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useUserInfo } from "@/app/(dashboard)/hooks/users/useUserInfo";
import { hasCapability } from "@/utils/capabilities";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
@ -140,6 +141,16 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
// For non-admins or "my-usage" view, always pass their own user_id
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
// Budget shown in the header tile must reflect the SELECTED user, not the
// logged-in admin (currentUser). Fetch the effective user's own record so the
// Max Budget tile and its reset period track "Filter by user". Falls back to
// currentUser only while the per-user fetch is unresolved or when no user is
// selected (global view has no single budget to show).
const { data: selectedUserInfo } = useUserInfo(effectiveUserId);
const effectiveMaxBudget =
effectiveUserId !== null ? selectedUserInfo?.max_budget ?? null : currentUser?.max_budget ?? null;
const effectiveBudgetDuration = effectiveUserId !== null ? selectedUserInfo?.budget_duration ?? null : null;
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
@ -578,7 +589,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<ViewUserSpend
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={currentUser?.max_budget || null}
userMaxBudget={effectiveMaxBudget}
budgetDuration={effectiveBudgetDuration}
/>
</div>

View file

@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import ViewUserSpend from "./view_user_spend";
// ViewUserSpend fetches available models in an effect; stub the network call.
vi.mock("./networking", () => ({
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "tok", userRole: "Admin", userId: "admin-id" }),
}));
describe("ViewUserSpend — Max Budget tile", () => {
beforeEach(() => vi.clearAllMocks());
it("shows a finite cap with its reset period when budgetDuration is set", () => {
render(<ViewUserSpend userSpend={10} userMaxBudget={600} selectedTeam={null} budgetDuration="30d" />);
expect(screen.getByText(/\$600\.0000 limit/)).toBeInTheDocument();
// 30d maps to "monthly" via getBudgetDurationLabel
expect(screen.getByText(/over monthly/)).toBeInTheDocument();
});
it('shows "No limit" for an unlimited (null) budget and no period', () => {
render(<ViewUserSpend userSpend={10} userMaxBudget={null} selectedTeam={null} budgetDuration="30d" />);
expect(screen.getByText("No limit")).toBeInTheDocument();
expect(screen.queryByText(/over/)).not.toBeInTheDocument();
});
it("shows a finite cap with no period when budgetDuration is absent", () => {
render(<ViewUserSpend userSpend={10} userMaxBudget={300} selectedTeam={null} />);
expect(screen.getByText(/\$300\.0000 limit/)).toBeInTheDocument();
expect(screen.queryByText(/over/)).not.toBeInTheDocument();
});
});

View file

@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react";
import { modelAvailableCall } from "./networking";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
// Define the props type
@ -9,8 +10,16 @@ interface ViewUserSpendProps {
userSpend: number | null;
userMaxBudget: number | null;
selectedTeam: any | null;
// Optional reset period paired with userMaxBudget (e.g. "24h", "30d"), rendered
// as a human-readable window next to the cap. Omitted/null → no period shown.
budgetDuration?: string | null;
}
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget, selectedTeam }) => {
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({
userSpend,
userMaxBudget,
selectedTeam,
budgetDuration = null,
}) => {
const { accessToken, userRole, userId: userID } = useAuthorized();
let [spend, setSpend] = useState(userSpend !== null ? userSpend : 0.0);
const [maxBudget, setMaxBudget] = useState(
@ -109,6 +118,11 @@ const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget,
const displayMaxBudget = maxBudget !== null ? `$${formatNumberWithCommas(Number(maxBudget), 4)} limit` : "No limit";
// Show the reset window (e.g. "over monthly") only for a finite cap that has a
// paired duration; an unlimited budget or a cap with no duration shows nothing.
const durationLabel = maxBudget !== null && budgetDuration ? getBudgetDurationLabel(budgetDuration) : null;
const budgetPeriodSuffix = durationLabel && durationLabel !== "Not set" ? ` over ${durationLabel}` : "";
const roundedSpend = spend !== undefined ? formatNumberWithCommas(spend, 4) : null;
return (
@ -120,7 +134,12 @@ const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget,
</div>
<div>
<p className="text-sm text-muted-foreground">Max Budget</p>
<p className="text-2xl font-semibold text-foreground">{displayMaxBudget}</p>
<p className="text-2xl font-semibold text-foreground">
{displayMaxBudget}
{budgetPeriodSuffix && (
<span className="text-sm font-normal text-muted-foreground">{budgetPeriodSuffix}</span>
)}
</p>
</div>
</div>
{/* <div className="ml-auto">