mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
feat(ui): manage customer organization, team, and member settings (#51507)
This commit is contained in:
parent
9633be87ea
commit
d126859942
19 changed files with 1740 additions and 40 deletions
BIN
.github/pr-assets/customer-hierarchy-settings/before.png
vendored
Normal file
BIN
.github/pr-assets/customer-hierarchy-settings/before.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
.github/pr-assets/customer-hierarchy-settings/delete-confirmation.png
vendored
Normal file
BIN
.github/pr-assets/customer-hierarchy-settings/delete-confirmation.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
.github/pr-assets/customer-hierarchy-settings/member-settings.png
vendored
Normal file
BIN
.github/pr-assets/customer-hierarchy-settings/member-settings.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
.github/pr-assets/customer-hierarchy-settings/teams.png
vendored
Normal file
BIN
.github/pr-assets/customer-hierarchy-settings/teams.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -0,0 +1,289 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight, Pencil } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
|
||||
import { ErrorState } from "@/components/shared/ErrorState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import CustomerHierarchyNavigation, { customerHierarchyHref } from "./CustomerHierarchyNavigation";
|
||||
import CustomerHierarchySettings from "./CustomerHierarchySettings";
|
||||
import { customerEntityId, customerEntityLabel } from "./customerHierarchyForms";
|
||||
import useCustomerHierarchy, { type CustomerHierarchyContext } from "./useCustomerHierarchy";
|
||||
import type { CustomerHierarchyScope } from "./customerHierarchyApi";
|
||||
|
||||
type Hierarchy = ReturnType<typeof useCustomerHierarchy>;
|
||||
interface ScopeProps {
|
||||
hierarchy: Hierarchy;
|
||||
selected: CustomerHierarchyContext;
|
||||
scope: CustomerHierarchyScope;
|
||||
}
|
||||
|
||||
function HierarchyHeader({ hierarchy, selected }: Pick<ScopeProps, "hierarchy" | "selected">) {
|
||||
const { t } = useTranslation();
|
||||
const entity = selected.entity;
|
||||
const team = selected.team;
|
||||
const label = customerEntityLabel(entity);
|
||||
const kindLabel = {
|
||||
organization: t("customerPortal.hierarchy.organization"),
|
||||
team: t("customerPortal.hierarchy.team"),
|
||||
member: t("customerPortal.hierarchy.member"),
|
||||
}[entity.kind];
|
||||
return (
|
||||
<header>
|
||||
<nav
|
||||
aria-label={t("customerPortal.analytics.breadcrumb")}
|
||||
className="mb-5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<a href={uiHref("customers")} className="hover:text-foreground">
|
||||
{t("customerPortal.analytics.customers")}
|
||||
</a>
|
||||
<ChevronRight aria-hidden="true" className="size-3" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={hierarchy.openOrganization}
|
||||
className="hover:text-foreground"
|
||||
aria-current={!team ? "page" : undefined}
|
||||
>
|
||||
{selected.organization.alias || selected.organization.customer_org_id}
|
||||
</button>
|
||||
{team && (
|
||||
<>
|
||||
<ChevronRight aria-hidden="true" className="size-3" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hierarchy.openTeam(team.customer_team_id)}
|
||||
className="hover:text-foreground"
|
||||
aria-current={!selected.member ? "page" : undefined}
|
||||
>
|
||||
{team.alias || team.customer_team_id}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selected.member && (
|
||||
<>
|
||||
<ChevronRight aria-hidden="true" className="size-3" />
|
||||
<span aria-current="page">{selected.member.alias || selected.member.customer_member_id}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{kindLabel}</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{label}</h1>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{t("customerPortal.hierarchy.subtitle")}</p>
|
||||
<p className="mt-3 font-mono text-xs text-muted-foreground">{customerEntityId(entity)}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) {
|
||||
const list = hierarchy.children.data;
|
||||
if (list?.kind === "teams")
|
||||
return list.data.map((team) => ({
|
||||
id: team.customer_team_id,
|
||||
label: team.alias || team.customer_team_id,
|
||||
spend: team.spend,
|
||||
budget: team.budget?.max_budget,
|
||||
open: () => hierarchy.openTeam(team.customer_team_id),
|
||||
edit: () => hierarchy.openTeam(team.customer_team_id, true),
|
||||
usageScope: { org: scope.org, team: team.customer_team_id, member: null },
|
||||
}));
|
||||
if (list?.kind === "members")
|
||||
return list.data.map((member) => ({
|
||||
id: member.customer_member_id,
|
||||
label: member.alias || member.customer_member_id,
|
||||
spend: member.spend,
|
||||
budget: member.effective_budget.max_budget,
|
||||
open: () => hierarchy.openMember(member.customer_member_id),
|
||||
edit: () => hierarchy.openMember(member.customer_member_id),
|
||||
usageScope: { org: scope.org, team: selected.team?.customer_team_id ?? null, member: member.customer_member_id },
|
||||
}));
|
||||
return [];
|
||||
}
|
||||
|
||||
function HierarchyChildTable(props: ScopeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hierarchy } = props;
|
||||
const { children, childTab, page } = hierarchy;
|
||||
const childLabel = childTab === "teams" ? t("customerPortal.hierarchy.teams") : t("customerPortal.hierarchy.members");
|
||||
if (children.isError)
|
||||
return (
|
||||
<ErrorState
|
||||
title={t(
|
||||
childTab === "teams"
|
||||
? "customerPortal.hierarchy.teamsLoadError"
|
||||
: "customerPortal.hierarchy.membersLoadError",
|
||||
)}
|
||||
message={extractProxyErrorMessage(children.error)}
|
||||
onRetry={() => void children.refetch()}
|
||||
/>
|
||||
);
|
||||
if (children.isPending)
|
||||
return (
|
||||
<p role="status" className="py-10 text-sm text-muted-foreground">
|
||||
{t("customerPortal.hierarchy.loading")}
|
||||
</p>
|
||||
);
|
||||
const rows = hierarchyRows(props);
|
||||
const pages = Math.max(1, children.data.meta.total_pages);
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
<div className="mb-5">
|
||||
<h2 className="text-base font-medium tracking-tight">{childLabel}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(childTab === "teams" ? "customerPortal.hierarchy.teamsHint" : "customerPortal.hierarchy.membersHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Table aria-label={childLabel}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-0">{t("customerPortal.hierarchy.name")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.hierarchy.spend")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.hierarchy.budget")}</TableHead>
|
||||
<TableHead className="pr-0">
|
||||
<span className="sr-only">{t("customerPortal.hierarchy.settings")}</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="py-4 pl-0">
|
||||
<button type="button" onClick={row.open} className="block text-left font-medium hover:text-primary">
|
||||
{row.label}
|
||||
</button>
|
||||
<span className="mt-1 block font-mono text-[11px] text-muted-foreground">{row.id}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatUsd(row.spend)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.budget == null ? t("customerPortal.hierarchy.noBudget") : formatUsd(row.budget)}
|
||||
</TableCell>
|
||||
<TableCell className="pr-0 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<a
|
||||
href={customerHierarchyHref("usage", row.usageScope)}
|
||||
className="inline-flex h-8 items-center px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("customerPortal.hierarchy.viewUsage")}
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={row.edit}
|
||||
aria-label={`${t("customerPortal.hierarchy.edit")} ${row.label}`}
|
||||
>
|
||||
<Pencil aria-hidden="true" className="size-3.5" />
|
||||
{t("customerPortal.hierarchy.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
childTab === "teams"
|
||||
? "customerPortal.hierarchy.emptyTeams"
|
||||
: "customerPortal.hierarchy.emptyMembers",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{pages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between border-t pt-4 text-xs text-muted-foreground">
|
||||
<span>{t("customerPortal.hierarchy.pagination", { page, pages })}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => hierarchy.setPage(page - 1)}>
|
||||
{t("customerPortal.hierarchy.previous")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= pages} onClick={() => hierarchy.setPage(page + 1)}>
|
||||
{t("customerPortal.hierarchy.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerHierarchyManager() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const hierarchy = useCustomerHierarchy();
|
||||
const { scope, context, selected, tab } = hierarchy;
|
||||
|
||||
if (hierarchy.isLoading)
|
||||
return (
|
||||
<p role="status" className="px-8 py-12 text-sm text-muted-foreground">
|
||||
{t("customerPortal.hierarchy.loading")}
|
||||
</p>
|
||||
);
|
||||
if (!hierarchy.canManage) return <ErrorState message={t("customerPortal.hierarchy.adminOnly")} />;
|
||||
if (!scope)
|
||||
return (
|
||||
<div className="space-y-3 px-8 py-12">
|
||||
<h1 className="text-xl font-semibold">{t("customerPortal.hierarchy.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("customerPortal.hierarchy.missingOrganization")}</p>
|
||||
<a href={uiHref("customers")} className="text-sm text-primary hover:underline">
|
||||
{t("customerPortal.hierarchy.backToCustomers")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
if (context.isError)
|
||||
return (
|
||||
<ErrorState
|
||||
title={t("customerPortal.hierarchy.error")}
|
||||
message={extractProxyErrorMessage(context.error)}
|
||||
onRetry={() => void context.refetch()}
|
||||
/>
|
||||
);
|
||||
if (!selected)
|
||||
return (
|
||||
<p role="status" className="px-8 py-12 text-sm text-muted-foreground">
|
||||
{t("customerPortal.hierarchy.loading")}
|
||||
</p>
|
||||
);
|
||||
|
||||
const entity = selected.entity;
|
||||
const currentScope = {
|
||||
org: scope.org,
|
||||
team: selected.team?.customer_team_id ?? null,
|
||||
member: selected.member?.customer_member_id ?? null,
|
||||
};
|
||||
const onDeleted = () => {
|
||||
hierarchy.refresh();
|
||||
if (selected.member && selected.team) hierarchy.openTeam(selected.team.customer_team_id);
|
||||
else if (selected.team) hierarchy.openOrganization();
|
||||
else router.push(uiHref("customers"));
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="mx-auto w-full max-w-[1600px] space-y-7 px-5 py-6 sm:px-8"
|
||||
aria-label={t("customerPortal.hierarchy.title")}
|
||||
>
|
||||
<HierarchyHeader hierarchy={hierarchy} selected={selected} />
|
||||
<CustomerHierarchyNavigation scope={currentScope} active={tab} />
|
||||
<section>
|
||||
{tab === "settings" ? (
|
||||
<CustomerHierarchySettings
|
||||
key={customerEntityId(entity)}
|
||||
organization={selected.organization}
|
||||
entity={entity}
|
||||
onSaved={hierarchy.refresh}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
) : (
|
||||
<HierarchyChildTable hierarchy={hierarchy} selected={selected} scope={scope} />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import type { CustomerUsageScope } from "./customerUsageApi";
|
||||
|
||||
type CustomerHierarchyView = "usage" | "teams" | "members" | "settings";
|
||||
const LABELS = {
|
||||
usage: "customerPortal.hierarchy.usage",
|
||||
teams: "customerPortal.hierarchy.teams",
|
||||
members: "customerPortal.hierarchy.members",
|
||||
settings: "customerPortal.hierarchy.settings",
|
||||
} as const;
|
||||
|
||||
export function customerHierarchyHref(page: "usage" | "manage", scope: CustomerUsageScope): string {
|
||||
const query = new URLSearchParams({ org: scope.org });
|
||||
if (scope.team) query.set("team", scope.team);
|
||||
if (scope.member) query.set("member", scope.member);
|
||||
return `${uiHref(`customers/${page}`)}?${query}`;
|
||||
}
|
||||
|
||||
export default function CustomerHierarchyNavigation({
|
||||
scope,
|
||||
active,
|
||||
}: {
|
||||
scope: CustomerUsageScope;
|
||||
active: CustomerHierarchyView;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pages: readonly CustomerHierarchyView[] = scope.member
|
||||
? ["usage", "settings"]
|
||||
: ["usage", scope.team ? "members" : "teams", "settings"];
|
||||
return (
|
||||
<nav aria-label={t("customerPortal.hierarchy.customerNavigation")} className="flex gap-6 border-b">
|
||||
{pages.map((page) => (
|
||||
<a
|
||||
key={page}
|
||||
href={
|
||||
page === "usage"
|
||||
? customerHierarchyHref("usage", scope)
|
||||
: `${customerHierarchyHref("manage", scope)}${page === "settings" ? "&tab=settings" : ""}`
|
||||
}
|
||||
aria-current={active === page ? "page" : undefined}
|
||||
className={cn(
|
||||
"border-b-2 border-transparent px-0.5 pb-3 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring",
|
||||
active === page && "border-primary text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(LABELS[page])}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerBaseUrlGetter } from "@/lib/http/runtime";
|
||||
import { act, chooseSelectOption, fireEvent, render, screen, waitFor, within } from "../../../../../tests/test-utils";
|
||||
import CustomerHierarchySettings from "./CustomerHierarchySettings";
|
||||
import type { Organization, Member } from "./customerHierarchyForms";
|
||||
|
||||
const audit = {
|
||||
created_at: "2026-11-01T00:00:00Z",
|
||||
updated_at: "2026-11-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_by: "admin",
|
||||
spend: 5,
|
||||
};
|
||||
const organization: Organization = {
|
||||
...audit,
|
||||
customer_org_id: "org-1",
|
||||
alias: "Example organization",
|
||||
default_budget_id: "template-1",
|
||||
default_budgets: [
|
||||
{ budget_id: "template-1", name: "Standard", is_default: true, max_budget: 10 },
|
||||
{ budget_id: "template-2", name: "Pro", is_default: false, max_budget: 50 },
|
||||
],
|
||||
budget: {
|
||||
max_budget: 100,
|
||||
soft_budget: 80,
|
||||
budget_duration: "1mo",
|
||||
tpm_limit: 200,
|
||||
rpm_limit: 10,
|
||||
allowed_models: ["openai/gpt-4.1"],
|
||||
},
|
||||
};
|
||||
const member: Member = {
|
||||
...audit,
|
||||
customer_member_id: "member-1",
|
||||
parent_team_id: "team-1",
|
||||
alias: "Member One",
|
||||
budget_template_id: "template-1",
|
||||
budget_override: { max_budget: 20 },
|
||||
effective_budget: { max_budget: 20 },
|
||||
budget_override_expires_at: "2026-12-01T12:00:00Z",
|
||||
};
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const onSaved = vi.fn();
|
||||
const onDeleted = vi.fn();
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
const lastRequest = (): Request => {
|
||||
const request = fetchMock.mock.calls.at(-1)?.[0];
|
||||
if (!(request instanceof Request)) throw new Error("Expected a request through the shared HTTP client");
|
||||
return request;
|
||||
};
|
||||
|
||||
describe("CustomerHierarchySettings", () => {
|
||||
beforeEach(() => {
|
||||
registerBaseUrlGetter(() => "http://localhost");
|
||||
fetchMock.mockReset();
|
||||
onSaved.mockReset();
|
||||
onDeleted.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("should render the existing organization settings and read-only identifier", () => {
|
||||
render(
|
||||
<CustomerHierarchySettings
|
||||
entity={{ kind: "organization", record: organization }}
|
||||
organization={organization}
|
||||
onSaved={onSaved}
|
||||
onDeleted={onDeleted}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "Display name" })).toHaveValue("Example organization");
|
||||
expect(screen.getByRole("textbox", { name: "Identifier" })).toHaveAttribute("readonly");
|
||||
expect(screen.getByRole("spinbutton", { name: "Spend limit (USD)" })).toHaveValue(100);
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should preserve other limits when saving a zero organization spend limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValue(json(organization));
|
||||
render(
|
||||
<CustomerHierarchySettings
|
||||
entity={{ kind: "organization", record: organization }}
|
||||
organization={organization}
|
||||
onSaved={onSaved}
|
||||
onDeleted={onDeleted}
|
||||
/>,
|
||||
);
|
||||
act(() =>
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Spend limit (USD)" }), { target: { value: "0" } }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledOnce());
|
||||
expect(lastRequest().method).toBe("PATCH");
|
||||
expect(new URL(lastRequest().url).pathname).toBe("/management/v1/customer_organizations/org-1");
|
||||
expect(await lastRequest().clone().json()).toEqual({ budget: { ...organization.budget, max_budget: 0 } });
|
||||
});
|
||||
|
||||
it("should show friendly template names while sending the selected template identifier", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValue(json(member));
|
||||
render(
|
||||
<CustomerHierarchySettings
|
||||
entity={{ kind: "member", record: member }}
|
||||
organization={organization}
|
||||
onSaved={onSaved}
|
||||
onDeleted={onDeleted}
|
||||
/>,
|
||||
);
|
||||
const template = screen.getByRole("combobox", { name: "Budget template" });
|
||||
expect(template).toHaveTextContent("Standard (default)");
|
||||
expect(template).not.toHaveTextContent("template-1");
|
||||
await chooseSelectOption(user, template, "Pro");
|
||||
expect(template).toHaveTextContent("Pro");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledOnce());
|
||||
expect(await lastRequest().clone().json()).toEqual({ budget_template_id: "template-2" });
|
||||
});
|
||||
|
||||
it("should identify an unavailable stored template instead of displaying No template", () => {
|
||||
render(
|
||||
<CustomerHierarchySettings
|
||||
entity={{ kind: "member", record: { ...member, budget_template_id: "retired-template" } }}
|
||||
organization={organization}
|
||||
onSaved={onSaved}
|
||||
onDeleted={onDeleted}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "Budget template" })).toHaveTextContent(
|
||||
"Unavailable template (retired-template)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should clear the member override and its expiry without changing the template", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValue(json(member));
|
||||
render(
|
||||
<CustomerHierarchySettings
|
||||
entity={{ kind: "member", record: member }}
|
||||
organization={organization}
|
||||
onSaved={onSaved}
|
||||
onDeleted={onDeleted}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("checkbox", { name: "Override the template budget" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledOnce());
|
||||
expect(new URL(lastRequest().url).pathname).toBe("/management/v1/customer_members/member-1");
|
||||
expect(await lastRequest().clone().json()).toEqual({ budget_override: null, budget_override_expires_at: null });
|
||||
});
|
||||
|
||||
it("should keep edited values and show the API error when a save fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValue(json({ detail: "Database not connected." }, 503));
|
||||
render(
|
||||
<CustomerHierarchySettings
|
||||
entity={{ kind: "organization", record: organization }}
|
||||
organization={organization}
|
||||
onSaved={onSaved}
|
||||
onDeleted={onDeleted}
|
||||
/>,
|
||||
);
|
||||
const alias = screen.getByRole("textbox", { name: "Display name" });
|
||||
await user.clear(alias);
|
||||
await user.type(alias, "Updated organization");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Database not connected.");
|
||||
expect(alias).toHaveValue("Updated organization");
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should require confirmation and retain the delete dialog when a parent has children", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ detail: "Delete the customer's children before deleting the parent." }, 409),
|
||||
);
|
||||
render(
|
||||
<CustomerHierarchySettings
|
||||
entity={{ kind: "organization", record: organization }}
|
||||
organization={organization}
|
||||
onSaved={onSaved}
|
||||
onDeleted={onDeleted}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Delete Organization" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Delete Example organization?" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Delete Organization" }));
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
"Delete the customer's children before deleting the parent.",
|
||||
);
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(onDeleted).not.toHaveBeenCalled();
|
||||
expect(lastRequest().method).toBe("DELETE");
|
||||
expect(new URL(lastRequest().url).pathname).toBe("/management/v1/customer_organizations/org-1");
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
await user.click(within(dialog).getByRole("button", { name: "Delete Organization" }));
|
||||
await waitFor(() => expect(onDeleted).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
buildCustomerHierarchyPatch,
|
||||
customerEntityId,
|
||||
customerEntityLabel,
|
||||
customerHierarchyFields,
|
||||
type CustomerHierarchyEntity,
|
||||
type CustomerHierarchyFields,
|
||||
type CustomerHierarchyPatch,
|
||||
type Organization,
|
||||
} from "./customerHierarchyForms";
|
||||
|
||||
interface Props {
|
||||
entity: CustomerHierarchyEntity;
|
||||
organization: Organization;
|
||||
onSaved: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const saveEntity = async (id: string, patch: CustomerHierarchyPatch): Promise<void> => {
|
||||
switch (patch.kind) {
|
||||
case "organization":
|
||||
await fetchClient.PATCH("/management/v1/customer_organizations/{customer_org_id}", {
|
||||
params: { path: { customer_org_id: id } },
|
||||
body: patch.body,
|
||||
});
|
||||
return;
|
||||
case "team":
|
||||
await fetchClient.PATCH("/management/v1/customer_teams/{customer_team_id}", {
|
||||
params: { path: { customer_team_id: id } },
|
||||
body: patch.body,
|
||||
});
|
||||
return;
|
||||
case "member":
|
||||
await fetchClient.PATCH("/management/v1/customer_members/{customer_member_id}", {
|
||||
params: { path: { customer_member_id: id } },
|
||||
body: patch.body,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEntity = async (entity: CustomerHierarchyEntity): Promise<void> => {
|
||||
const id = customerEntityId(entity);
|
||||
switch (entity.kind) {
|
||||
case "organization":
|
||||
await fetchClient.DELETE("/management/v1/customer_organizations/{customer_org_id}", {
|
||||
params: { path: { customer_org_id: id } },
|
||||
});
|
||||
return;
|
||||
case "team":
|
||||
await fetchClient.DELETE("/management/v1/customer_teams/{customer_team_id}", {
|
||||
params: { path: { customer_team_id: id } },
|
||||
});
|
||||
return;
|
||||
case "member":
|
||||
await fetchClient.DELETE("/management/v1/customer_members/{customer_member_id}", {
|
||||
params: { path: { customer_member_id: id } },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const KIND_LABELS = {
|
||||
organization: "customerPortal.hierarchy.organization",
|
||||
team: "customerPortal.hierarchy.team",
|
||||
member: "customerPortal.hierarchy.member",
|
||||
} as const;
|
||||
|
||||
const ERROR_LABELS = {
|
||||
invalidNumber: "customerPortal.hierarchy.invalidNumber",
|
||||
invalidExpiry: "customerPortal.hierarchy.invalidExpiry",
|
||||
budgetRequired: "customerPortal.hierarchy.budgetRequired",
|
||||
} as const;
|
||||
|
||||
function SettingsForm({ entity, organization, onSaved, onDeleted }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const initial = customerHierarchyFields(entity);
|
||||
const [fields, setFields] = useState(initial);
|
||||
const [pending, setPending] = useState<"save" | "delete" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const dirty = JSON.stringify(fields) !== JSON.stringify(initial);
|
||||
const id = customerEntityId(entity);
|
||||
const kind = t(KIND_LABELS[entity.kind]);
|
||||
const update = <K extends keyof CustomerHierarchyFields>(key: K, value: CustomerHierarchyFields[K]) =>
|
||||
setFields((current) => ({ ...current, [key]: value }));
|
||||
const templateLabel = (templateId: string): string => {
|
||||
if (!templateId) return t("customerPortal.hierarchy.noTemplate");
|
||||
const template = organization.default_budgets.find((budget) => budget.budget_id === templateId);
|
||||
if (!template) return t("customerPortal.hierarchy.unknownTemplate", { id: templateId });
|
||||
if (templateId === organization.default_budget_id)
|
||||
return t("customerPortal.hierarchy.defaultTemplate", { name: template.name });
|
||||
return template.name;
|
||||
};
|
||||
|
||||
const save = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const result = buildCustomerHierarchyPatch(entity, fields);
|
||||
if (!result.ok) {
|
||||
setError(t(ERROR_LABELS[result.error]));
|
||||
return;
|
||||
}
|
||||
setPending("save");
|
||||
setError(null);
|
||||
try {
|
||||
await saveEntity(id, result.patch);
|
||||
toast.success(t("customerPortal.hierarchy.saved"));
|
||||
onSaved();
|
||||
} catch (cause) {
|
||||
setError(extractProxyErrorMessage(cause));
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setPending("delete");
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteEntity(entity);
|
||||
setDeleteOpen(false);
|
||||
onDeleted();
|
||||
} catch (cause) {
|
||||
setDeleteError(extractProxyErrorMessage(cause));
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const numericFields = [
|
||||
["maxBudget", "customerPortal.hierarchy.maxBudget", "any"],
|
||||
["softBudget", "customerPortal.hierarchy.softBudget", "any"],
|
||||
["tpm", "customerPortal.hierarchy.tpm", "1"],
|
||||
["rpm", "customerPortal.hierarchy.rpm", "1"],
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<form onSubmit={(event) => void save(event)} className="space-y-7">
|
||||
<fieldset disabled={pending !== null} className="space-y-7">
|
||||
<section className="space-y-4" aria-labelledby="hierarchy-general-title">
|
||||
<div>
|
||||
<h2 id="hierarchy-general-title" className="text-base font-medium">
|
||||
{t("customerPortal.hierarchy.profileTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("customerPortal.hierarchy.profileHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hierarchy-alias">{t("customerPortal.hierarchy.alias")}</Label>
|
||||
<Input
|
||||
id="hierarchy-alias"
|
||||
value={fields.alias}
|
||||
onChange={(event) => update("alias", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hierarchy-id">{t("customerPortal.hierarchy.identifier")}</Label>
|
||||
<Input id="hierarchy-id" value={id} readOnly className="bg-muted font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t pt-6" aria-labelledby="hierarchy-budget-title">
|
||||
<div>
|
||||
<h2 id="hierarchy-budget-title" className="text-base font-medium">
|
||||
{t("customerPortal.hierarchy.budgetTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("customerPortal.hierarchy.budgetHint")}</p>
|
||||
</div>
|
||||
{entity.kind === "member" && (
|
||||
<div className="max-w-sm space-y-1.5">
|
||||
<Label htmlFor="hierarchy-template">{t("customerPortal.hierarchy.template")}</Label>
|
||||
<Select
|
||||
value={fields.templateId || "__none__"}
|
||||
onValueChange={(value) => update("templateId", value === "__none__" ? "" : value ?? "")}
|
||||
>
|
||||
<SelectTrigger id="hierarchy-template" className="w-full">
|
||||
<SelectValue>{templateLabel(fields.templateId)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t("customerPortal.hierarchy.noTemplate")}</SelectItem>
|
||||
{organization.default_budgets.map((template) => (
|
||||
<SelectItem key={template.budget_id} value={template.budget_id}>
|
||||
{templateLabel(template.budget_id)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="hierarchy-budget-enabled"
|
||||
checked={fields.budgetEnabled}
|
||||
onCheckedChange={(checked) => update("budgetEnabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="hierarchy-budget-enabled">
|
||||
{t(
|
||||
entity.kind === "member"
|
||||
? "customerPortal.hierarchy.overrideEnabled"
|
||||
: "customerPortal.hierarchy.budgetEnabled",
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
{entity.kind === "member" && (
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.hierarchy.overrideHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
{fields.budgetEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{numericFields.map(([name, label, step]) => (
|
||||
<div className="space-y-1.5" key={name}>
|
||||
<Label htmlFor={`hierarchy-${name}`}>{t(label)}</Label>
|
||||
<Input
|
||||
id={`hierarchy-${name}`}
|
||||
type="number"
|
||||
min="0"
|
||||
step={step}
|
||||
value={fields[name]}
|
||||
onChange={(event) => update(name, event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hierarchy-duration">{t("customerPortal.hierarchy.budgetDuration")}</Label>
|
||||
<Input
|
||||
id="hierarchy-duration"
|
||||
value={fields.budgetDuration}
|
||||
onChange={(event) => update("budgetDuration", event.target.value)}
|
||||
aria-describedby="hierarchy-duration-hint"
|
||||
/>
|
||||
<p id="hierarchy-duration-hint" className="text-xs text-muted-foreground">
|
||||
{t("customerPortal.hierarchy.budgetDurationHint")}
|
||||
</p>
|
||||
</div>
|
||||
{entity.kind === "member" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hierarchy-expiry">{t("customerPortal.hierarchy.overrideExpiry")}</Label>
|
||||
<Input
|
||||
id="hierarchy-expiry"
|
||||
type="datetime-local"
|
||||
value={fields.overrideExpiry}
|
||||
onChange={(event) => update("overrideExpiry", event.target.value)}
|
||||
aria-describedby="hierarchy-expiry-hint"
|
||||
/>
|
||||
<p id="hierarchy-expiry-hint" className="text-xs text-muted-foreground">
|
||||
{t("customerPortal.hierarchy.overrideExpiryHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hierarchy-models">{t("customerPortal.hierarchy.allowedModels")}</Label>
|
||||
<Textarea
|
||||
id="hierarchy-models"
|
||||
value={fields.allowedModels}
|
||||
onChange={(event) => update("allowedModels", event.target.value)}
|
||||
aria-describedby="hierarchy-models-hint"
|
||||
className="min-h-20 font-mono text-xs"
|
||||
/>
|
||||
<p id="hierarchy-models-hint" className="text-xs text-muted-foreground">
|
||||
{t("customerPortal.hierarchy.allowedModelsHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending !== null || !dirty}
|
||||
onClick={() => {
|
||||
setFields(initial);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t("customerPortal.hierarchy.reset")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending !== null || !dirty}>
|
||||
{t(pending === "save" ? "customerPortal.hierarchy.saving" : "customerPortal.hierarchy.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section
|
||||
className="flex flex-wrap items-start justify-between gap-4 border-t pt-6"
|
||||
aria-labelledby="hierarchy-delete-title"
|
||||
>
|
||||
<div className="max-w-xl space-y-1">
|
||||
<h2 id="hierarchy-delete-title" className="text-sm font-medium">
|
||||
{t("customerPortal.hierarchy.deleteTitle", { kind })}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.hierarchy.deleteHint", { kind })}</p>
|
||||
{entity.kind !== "member" && (
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.hierarchy.deleteParentHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
disabled={pending !== null}
|
||||
onClick={() => {
|
||||
setDeleteError(null);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("customerPortal.hierarchy.deleteTitle", { kind })}
|
||||
</Button>
|
||||
</section>
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (pending !== "delete") setDeleteOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("customerPortal.hierarchy.deleteConfirmTitle", { name: customerEntityLabel(entity) })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("customerPortal.hierarchy.deleteConfirmHint", { kind })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="font-mono text-xs text-muted-foreground">{id}</p>
|
||||
{entity.kind !== "member" && (
|
||||
<p className="text-sm text-muted-foreground">{t("customerPortal.hierarchy.deleteParentHint")}</p>
|
||||
)}
|
||||
{deleteError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending === "delete"}
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
>
|
||||
{t("customerPortal.hierarchy.cancel")}
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" disabled={pending === "delete"} onClick={() => void remove()}>
|
||||
{t(
|
||||
pending === "delete" ? "customerPortal.hierarchy.deleting" : "customerPortal.hierarchy.deleteConfirm",
|
||||
{ kind },
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerHierarchySettings(props: Props) {
|
||||
return (
|
||||
<SettingsForm
|
||||
key={`${props.entity.kind}:${customerEntityId(props.entity)}:${props.entity.record.updated_at}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { apiClient } from "@/components/networking";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
type Organization = components["schemas"]["Organization"];
|
||||
type Team = components["schemas"]["Team"];
|
||||
type Member = components["schemas"]["CustomerMember"];
|
||||
type Collection = "customer_organizations" | "customer_teams" | "customer_members";
|
||||
|
||||
export interface CustomerHierarchyScope {
|
||||
org: string;
|
||||
team: string | null;
|
||||
member: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedCustomerHierarchy {
|
||||
organization: Organization;
|
||||
team: Team | null;
|
||||
member: Member | null;
|
||||
}
|
||||
|
||||
interface HierarchyRequest {
|
||||
scope: CustomerHierarchyScope;
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const assertScope = (valid: boolean) => {
|
||||
if (!valid) throw new Error(i18n.t("customerPortal.analytics.scopeMismatch"));
|
||||
};
|
||||
|
||||
const info = <T>(resource: Collection, id: string, request: HierarchyRequest) =>
|
||||
apiClient.get<T>(`/management/v1/${resource}/${encodeURIComponent(id)}`, {
|
||||
accessToken: request.accessToken,
|
||||
signal: request.signal,
|
||||
});
|
||||
|
||||
export const resolveCustomerHierarchy = async (request: HierarchyRequest): Promise<ResolvedCustomerHierarchy> => {
|
||||
const { scope } = request;
|
||||
const [organization, requestedTeam, member] = await Promise.all([
|
||||
info<Organization>("customer_organizations", scope.org, request),
|
||||
scope.team ? info<Team>("customer_teams", scope.team, request) : null,
|
||||
scope.member ? info<Member>("customer_members", scope.member, request) : null,
|
||||
]);
|
||||
const team = requestedTeam ?? (member ? await info<Team>("customer_teams", member.parent_team_id, request) : null);
|
||||
assertScope(organization.customer_org_id === scope.org);
|
||||
assertScope(!team || team.parent_org_id === scope.org);
|
||||
assertScope(!scope.team || team?.customer_team_id === scope.team);
|
||||
assertScope(!member || member.customer_member_id === scope.member);
|
||||
assertScope(!member || member.parent_team_id === team?.customer_team_id);
|
||||
return { organization, team, member };
|
||||
};
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCustomerHierarchyPatch,
|
||||
customerHierarchyFields,
|
||||
type CustomerHierarchyEntity,
|
||||
type Organization,
|
||||
type Member,
|
||||
} from "./customerHierarchyForms";
|
||||
|
||||
const audit = {
|
||||
created_at: "2026-11-01T00:00:00Z",
|
||||
updated_at: "2026-11-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_by: "admin",
|
||||
spend: 5,
|
||||
};
|
||||
const organization: Organization = {
|
||||
...audit,
|
||||
customer_org_id: "org-1",
|
||||
alias: "Example",
|
||||
default_budgets: [],
|
||||
budget: {
|
||||
max_budget: 100,
|
||||
soft_budget: 80,
|
||||
budget_duration: "1mo",
|
||||
tpm_limit: 200,
|
||||
rpm_limit: 10,
|
||||
allowed_models: ["openai/gpt-4.1", "anthropic/claude-sonnet"],
|
||||
},
|
||||
};
|
||||
const member: Member = {
|
||||
...audit,
|
||||
customer_member_id: "member-1",
|
||||
parent_team_id: "team-1",
|
||||
alias: "Member One",
|
||||
budget_template_id: "template-1",
|
||||
budget_override: { max_budget: 20, rpm_limit: 4 },
|
||||
effective_budget: { max_budget: 20, rpm_limit: 4 },
|
||||
budget_override_expires_at: "2026-12-01T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("customer hierarchy settings payloads", () => {
|
||||
it("should leave untouched settings out of the patch", () => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "organization", record: organization };
|
||||
expect(buildCustomerHierarchyPatch(entity, customerHierarchyFields(entity))).toEqual({
|
||||
ok: true,
|
||||
patch: { kind: "organization", body: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update only the alias without overwriting the existing budget", () => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "organization", record: organization };
|
||||
expect(buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), alias: " Updated " })).toEqual({
|
||||
ok: true,
|
||||
patch: { kind: "organization", body: { alias: "Updated" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve the other limits when changing a spend limit to zero", () => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "organization", record: organization };
|
||||
expect(buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), maxBudget: "0" })).toEqual({
|
||||
ok: true,
|
||||
patch: { kind: "organization", body: { budget: { ...organization.budget, max_budget: 0 } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear an explicit budget when disabled", () => {
|
||||
const entity: CustomerHierarchyEntity = {
|
||||
kind: "team",
|
||||
record: { ...audit, customer_team_id: "team-1", parent_org_id: "org-1", budget: { max_budget: 100 } },
|
||||
};
|
||||
expect(buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), budgetEnabled: false })).toEqual({
|
||||
ok: true,
|
||||
patch: { kind: "team", body: { budget: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear a member override and expiry while keeping its template", () => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "member", record: member };
|
||||
expect(buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), budgetEnabled: false })).toEqual({
|
||||
ok: true,
|
||||
patch: { kind: "member", body: { budget_override: null, budget_override_expires_at: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should require a template or an override for a member", () => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "member", record: member };
|
||||
expect(
|
||||
buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), budgetEnabled: false, templateId: "" }),
|
||||
).toEqual({ ok: false, error: "budgetRequired" });
|
||||
});
|
||||
|
||||
it.each(["-1", "NaN", "Infinity", "1e999", "not a number"])(
|
||||
"should reject an invalid spend limit %s",
|
||||
(maxBudget) => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "organization", record: organization };
|
||||
expect(buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), maxBudget })).toEqual({
|
||||
ok: false,
|
||||
error: "invalidNumber",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["1.5", "9007199254740992"])("should reject a non-safe-integer rate limit %s", (rpm) => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "organization", record: organization };
|
||||
expect(buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), rpm })).toEqual({
|
||||
ok: false,
|
||||
error: "invalidNumber",
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear a blank limit and allow a sub-cent spend limit", () => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "organization", record: organization };
|
||||
expect(
|
||||
buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), maxBudget: "0.00001", softBudget: "" }),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
patch: {
|
||||
kind: "organization",
|
||||
body: { budget: { ...organization.budget, max_budget: 0.00001, soft_budget: null } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should retain an unchanged expiry exactly when editing the member name", () => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "member", record: member };
|
||||
expect(buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), alias: "New name" })).toEqual({
|
||||
ok: true,
|
||||
patch: { kind: "member", body: { alias: "New name" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject an invalid override expiry", () => {
|
||||
const entity: CustomerHierarchyEntity = { kind: "member", record: member };
|
||||
expect(
|
||||
buildCustomerHierarchyPatch(entity, { ...customerHierarchyFields(entity), overrideExpiry: "invalid" }),
|
||||
).toEqual({ ok: false, error: "invalidExpiry" });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type Organization = components["schemas"]["Organization"];
|
||||
export type Team = components["schemas"]["Team"];
|
||||
export type Member = components["schemas"]["CustomerMember"];
|
||||
type Budget = components["schemas"]["Budget"];
|
||||
|
||||
export type CustomerHierarchyEntity =
|
||||
| { kind: "organization"; record: Organization }
|
||||
| { kind: "team"; record: Team }
|
||||
| { kind: "member"; record: Member };
|
||||
|
||||
export type CustomerHierarchyPatch =
|
||||
| { kind: "organization"; body: components["schemas"]["OrganizationPatch"] }
|
||||
| { kind: "team"; body: components["schemas"]["TeamPatch"] }
|
||||
| { kind: "member"; body: components["schemas"]["MemberPatch"] };
|
||||
|
||||
export interface CustomerHierarchyFields {
|
||||
alias: string;
|
||||
budgetEnabled: boolean;
|
||||
maxBudget: string;
|
||||
softBudget: string;
|
||||
budgetDuration: string;
|
||||
tpm: string;
|
||||
rpm: string;
|
||||
allowedModels: string;
|
||||
templateId: string;
|
||||
overrideExpiry: string;
|
||||
}
|
||||
|
||||
type FormError = "invalidNumber" | "invalidExpiry" | "budgetRequired";
|
||||
type PatchResult = { ok: true; patch: CustomerHierarchyPatch } | { ok: false; error: FormError };
|
||||
|
||||
export const customerEntityId = (entity: CustomerHierarchyEntity): string => {
|
||||
switch (entity.kind) {
|
||||
case "organization":
|
||||
return entity.record.customer_org_id;
|
||||
case "team":
|
||||
return entity.record.customer_team_id;
|
||||
case "member":
|
||||
return entity.record.customer_member_id;
|
||||
}
|
||||
};
|
||||
|
||||
export const customerEntityLabel = (entity: CustomerHierarchyEntity): string =>
|
||||
entity.record.alias || customerEntityId(entity);
|
||||
|
||||
const asText = (value: number | null | undefined): string => (value == null ? "" : String(value));
|
||||
|
||||
const localDateTime = (value: string | null | undefined): string => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
export const customerHierarchyFields = (entity: CustomerHierarchyEntity): CustomerHierarchyFields => {
|
||||
const storedBudget = entity.kind === "member" ? entity.record.budget_override : entity.record.budget;
|
||||
const budget = storedBudget ?? (entity.kind === "member" ? entity.record.effective_budget : null);
|
||||
return {
|
||||
alias: entity.record.alias ?? "",
|
||||
budgetEnabled: storedBudget != null,
|
||||
maxBudget: asText(budget?.max_budget),
|
||||
softBudget: asText(budget?.soft_budget),
|
||||
budgetDuration: budget?.budget_duration ?? "",
|
||||
tpm: asText(budget?.tpm_limit),
|
||||
rpm: asText(budget?.rpm_limit),
|
||||
allowedModels: budget?.allowed_models?.join("\n") ?? "",
|
||||
templateId: entity.kind === "member" ? entity.record.budget_template_id ?? "" : "",
|
||||
overrideExpiry: entity.kind === "member" ? localDateTime(entity.record.budget_override_expires_at) : "",
|
||||
};
|
||||
};
|
||||
|
||||
const numericLimit = (raw: string, integer = false): number | null | undefined => {
|
||||
const value = raw.trim();
|
||||
if (!value) return null;
|
||||
if (!(integer ? /^\d+$/ : /^(?:\d+(?:\.\d*)?|\.\d+)$/).test(value)) return undefined;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || (integer && !Number.isSafeInteger(number))) return undefined;
|
||||
return number;
|
||||
};
|
||||
|
||||
const parseBudget = (fields: CustomerHierarchyFields): Budget | null => {
|
||||
const max = numericLimit(fields.maxBudget);
|
||||
const soft = numericLimit(fields.softBudget);
|
||||
const tpm = numericLimit(fields.tpm, true);
|
||||
const rpm = numericLimit(fields.rpm, true);
|
||||
if ([max, soft, tpm, rpm].includes(undefined)) return null;
|
||||
const models = fields.allowedModels
|
||||
.split(/\r?\n/)
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
max_budget: max,
|
||||
soft_budget: soft,
|
||||
tpm_limit: tpm,
|
||||
rpm_limit: rpm,
|
||||
budget_duration: fields.budgetDuration.trim() || null,
|
||||
allowed_models: models.length ? [...new Set(models)] : null,
|
||||
};
|
||||
};
|
||||
|
||||
const BUDGET_FIELDS = [
|
||||
"budgetEnabled",
|
||||
"maxBudget",
|
||||
"softBudget",
|
||||
"budgetDuration",
|
||||
"tpm",
|
||||
"rpm",
|
||||
"allowedModels",
|
||||
] as const;
|
||||
|
||||
const budgetChanged = (fields: CustomerHierarchyFields, initial: CustomerHierarchyFields): boolean =>
|
||||
BUDGET_FIELDS.some((key) => fields[key] !== initial[key]);
|
||||
|
||||
const aliasChanges = (initial: string | null | undefined, value: string): { alias?: string | null } =>
|
||||
value === (initial ?? "") ? {} : { alias: value.trim() || null };
|
||||
|
||||
const memberPatch = (
|
||||
member: Member,
|
||||
fields: CustomerHierarchyFields,
|
||||
budget: Budget | null,
|
||||
changed: boolean,
|
||||
): PatchResult => {
|
||||
const initial = customerHierarchyFields({ kind: "member", record: member });
|
||||
if (!fields.templateId && !fields.budgetEnabled) return { ok: false, error: "budgetRequired" };
|
||||
const body: components["schemas"]["MemberPatch"] = aliasChanges(member.alias, fields.alias);
|
||||
if (fields.templateId !== initial.templateId) body.budget_template_id = fields.templateId || null;
|
||||
if (changed) body.budget_override = budget;
|
||||
if (!fields.budgetEnabled && initial.budgetEnabled) body.budget_override_expires_at = null;
|
||||
if (fields.budgetEnabled && fields.overrideExpiry !== initial.overrideExpiry) {
|
||||
const expiry = fields.overrideExpiry ? new Date(fields.overrideExpiry) : null;
|
||||
if (expiry && Number.isNaN(expiry.getTime())) return { ok: false, error: "invalidExpiry" };
|
||||
body.budget_override_expires_at = expiry?.toISOString() ?? null;
|
||||
}
|
||||
return { ok: true, patch: { kind: "member", body } };
|
||||
};
|
||||
|
||||
export const buildCustomerHierarchyPatch = (
|
||||
entity: CustomerHierarchyEntity,
|
||||
fields: CustomerHierarchyFields,
|
||||
): PatchResult => {
|
||||
const initial = customerHierarchyFields(entity);
|
||||
const aliasPatch = aliasChanges(entity.record.alias, fields.alias);
|
||||
const changed = budgetChanged(fields, initial);
|
||||
const budget = fields.budgetEnabled && changed ? parseBudget(fields) : null;
|
||||
if (fields.budgetEnabled && changed && budget === null) return { ok: false, error: "invalidNumber" };
|
||||
if (entity.kind === "organization" || entity.kind === "team") {
|
||||
return {
|
||||
ok: true,
|
||||
patch: { kind: entity.kind, body: { ...aliasPatch, ...(changed ? { budget } : {}) } },
|
||||
};
|
||||
}
|
||||
return memberPatch(entity.record, fields, budget, changed);
|
||||
};
|
||||
|
|
@ -2,6 +2,12 @@ import { apiClient } from "@/components/networking";
|
|||
import { type DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import {
|
||||
resolveCustomerHierarchy,
|
||||
type CustomerHierarchyScope as CustomerUsageScope,
|
||||
type ResolvedCustomerHierarchy as ResolvedScope,
|
||||
} from "./customerHierarchyApi";
|
||||
export type { CustomerHierarchyScope as CustomerUsageScope } from "./customerHierarchyApi";
|
||||
import {
|
||||
mergeCustomerActivityPages,
|
||||
customerEntityRankings,
|
||||
|
|
@ -36,12 +42,6 @@ export interface CustomerHierarchyUsage extends ReturnType<typeof mergeCustomerA
|
|||
memberBudgets: CustomerUsageEntity[];
|
||||
}
|
||||
|
||||
export interface CustomerUsageScope {
|
||||
org: string;
|
||||
team: string | null;
|
||||
member: string | null;
|
||||
}
|
||||
|
||||
interface CustomerUsageRequest {
|
||||
scope: CustomerUsageScope;
|
||||
accessToken: string;
|
||||
|
|
@ -50,12 +50,6 @@ interface CustomerUsageRequest {
|
|||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface ResolvedScope {
|
||||
organization: Organization;
|
||||
team: Team | null;
|
||||
member: Member | null;
|
||||
}
|
||||
|
||||
const PREFIX = "/management/v1";
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 1_000;
|
||||
|
|
@ -70,10 +64,6 @@ const assertIdentifiers = (ids: readonly string[]) => {
|
|||
if (ids.some((id) => !id || id.includes(","))) throw new Error(i18n.t("customerPortal.analytics.invalidIdentifier"));
|
||||
};
|
||||
|
||||
const assertScope = (valid: boolean) => {
|
||||
if (!valid) throw new Error(i18n.t("customerPortal.analytics.scopeMismatch"));
|
||||
};
|
||||
|
||||
const batches = async <T, R>(items: readonly T[], task: (item: T) => Promise<R>): Promise<R[]> => {
|
||||
const results: R[] = [];
|
||||
for (let start = 0; start < items.length; start += MAX_CONCURRENT) {
|
||||
|
|
@ -82,29 +72,6 @@ const batches = async <T, R>(items: readonly T[], task: (item: T) => Promise<R>)
|
|||
return results;
|
||||
};
|
||||
|
||||
const info = <T>(resource: Collection, id: string, request: CustomerUsageRequest) =>
|
||||
apiClient.get<T>(`${PREFIX}/${resource}/${encodeURIComponent(id)}`, {
|
||||
accessToken: request.accessToken,
|
||||
signal: request.signal,
|
||||
});
|
||||
|
||||
const resolveScope = async (request: CustomerUsageRequest): Promise<ResolvedScope> => {
|
||||
const { scope } = request;
|
||||
assertIdentifiers([scope.org, ...[scope.team, scope.member].filter((id): id is string => id !== null)]);
|
||||
const [organization, requestedTeam, member] = await Promise.all([
|
||||
info<Organization>("customer_organizations", scope.org, request),
|
||||
scope.team ? info<Team>("customer_teams", scope.team, request) : null,
|
||||
scope.member ? info<Member>("customer_members", scope.member, request) : null,
|
||||
]);
|
||||
const team = requestedTeam ?? (member ? await info<Team>("customer_teams", member.parent_team_id, request) : null);
|
||||
assertScope(organization.customer_org_id === scope.org);
|
||||
assertScope(!team || team.parent_org_id === scope.org);
|
||||
assertScope(!scope.team || team?.customer_team_id === scope.team);
|
||||
assertScope(!member || member.customer_member_id === scope.member);
|
||||
assertScope(!member || member.parent_team_id === team?.customer_team_id);
|
||||
return { organization, team, member };
|
||||
};
|
||||
|
||||
const listAll = async <T>(
|
||||
resource: Collection,
|
||||
parent: Record<string, string>,
|
||||
|
|
@ -196,7 +163,11 @@ const selectedResource = (scope: ResolvedScope): Collection => {
|
|||
};
|
||||
|
||||
export const customerHierarchyUsageCall = async (request: CustomerUsageRequest): Promise<CustomerHierarchyUsage> => {
|
||||
const selected = await resolveScope(request);
|
||||
assertIdentifiers([
|
||||
request.scope.org,
|
||||
...[request.scope.team, request.scope.member].filter((id): id is string => id !== null),
|
||||
]);
|
||||
const selected = await resolveCustomerHierarchy(request);
|
||||
const children = await descendants(selected, request);
|
||||
const resource = selectedResource(selected);
|
||||
const selectedId = selected.member?.customer_member_id ?? selected.team?.customer_team_id ?? request.scope.org;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { registerBaseUrlGetter } from "@/lib/http/runtime";
|
||||
import { customerHierarchyContext } from "./useCustomerHierarchy";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("should encode hierarchy IDs in paths and preserve their identity", async () => {
|
||||
registerBaseUrlGetter(() => "http://localhost");
|
||||
const requested: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async (input) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
|
||||
requested.push(url.pathname);
|
||||
let record: object = { customer_team_id: "team/one", parent_org_id: "org/one" };
|
||||
if (url.pathname.includes("customer_organizations")) record = { customer_org_id: "org/one", default_budgets: [] };
|
||||
if (url.pathname.includes("customer_members"))
|
||||
record = { customer_member_id: "member/one", parent_team_id: "team/one" };
|
||||
return new Response(JSON.stringify(record), { headers: { "Content-Type": "application/json" } });
|
||||
}),
|
||||
);
|
||||
const result = await customerHierarchyContext({ org: "org/one", team: null, member: "member/one" }, "test-session");
|
||||
expect(requested).toEqual([
|
||||
"/management/v1/customer_organizations/org%2Fone",
|
||||
"/management/v1/customer_members/member%2Fone",
|
||||
"/management/v1/customer_teams/team%2Fone",
|
||||
]);
|
||||
expect(result.entity).toEqual({
|
||||
kind: "member",
|
||||
record: { customer_member_id: "member/one", parent_team_id: "team/one" },
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { useEffect } from "react";
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { parseAsInteger, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { resolveCustomerHierarchy, type CustomerHierarchyScope } from "./customerHierarchyApi";
|
||||
import type { CustomerHierarchyEntity, Member, Organization, Team } from "./customerHierarchyForms";
|
||||
|
||||
const MANAGE_PARSERS = {
|
||||
org: parseAsString,
|
||||
team: parseAsString,
|
||||
member: parseAsString,
|
||||
tab: parseAsStringLiteral(["teams", "members", "settings"] as const),
|
||||
hierarchyPage: parseAsInteger.withDefault(1),
|
||||
};
|
||||
|
||||
export interface CustomerHierarchyContext {
|
||||
organization: Organization;
|
||||
team: Team | null;
|
||||
member: Member | null;
|
||||
entity: CustomerHierarchyEntity;
|
||||
}
|
||||
|
||||
type ChildList =
|
||||
| { kind: "teams"; data: Team[]; meta: components["schemas"]["ListMeta"] }
|
||||
| { kind: "members"; data: Member[]; meta: components["schemas"]["ListMeta"] };
|
||||
|
||||
const scopeFailure = () => new Error(i18n.t("customerPortal.hierarchy.scopeMismatch"));
|
||||
|
||||
function selectedEntity(organization: Organization, team: Team | null, member: Member | null): CustomerHierarchyEntity {
|
||||
if (member) return { kind: "member", record: member };
|
||||
if (team) return { kind: "team", record: team };
|
||||
return { kind: "organization", record: organization };
|
||||
}
|
||||
|
||||
export async function customerHierarchyContext(
|
||||
scope: CustomerHierarchyScope,
|
||||
accessToken: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CustomerHierarchyContext> {
|
||||
const selected = await resolveCustomerHierarchy({ scope, accessToken, signal });
|
||||
return { ...selected, entity: selectedEntity(selected.organization, selected.team, selected.member) };
|
||||
}
|
||||
|
||||
async function customerHierarchyChildren(
|
||||
context: CustomerHierarchyContext,
|
||||
page: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChildList> {
|
||||
if (context.team) {
|
||||
const response = await fetchClient.GET("/management/v1/customer_members", {
|
||||
params: { query: { parent_team_id: context.team.customer_team_id, page, page_size: 20 } },
|
||||
signal,
|
||||
});
|
||||
if (
|
||||
!response.data ||
|
||||
response.data.data.some((member) => member.parent_team_id !== context.team?.customer_team_id)
|
||||
) {
|
||||
throw scopeFailure();
|
||||
}
|
||||
return { kind: "members", ...response.data };
|
||||
}
|
||||
const response = await fetchClient.GET("/management/v1/customer_teams", {
|
||||
params: { query: { parent_org_id: context.organization.customer_org_id, page, page_size: 20 } },
|
||||
signal,
|
||||
});
|
||||
if (
|
||||
!response.data ||
|
||||
response.data.data.some((team) => team.parent_org_id !== context.organization.customer_org_id)
|
||||
) {
|
||||
throw scopeFailure();
|
||||
}
|
||||
return { kind: "teams", ...response.data };
|
||||
}
|
||||
|
||||
export default function useCustomerHierarchy() {
|
||||
const { accessToken, userRole, isLoading } = useAuthorized();
|
||||
const canManage = isProxyAdminRole(userRole);
|
||||
const [params, setParams] = useQueryStates(MANAGE_PARSERS);
|
||||
const queryClient = useQueryClient();
|
||||
const scope = params.org ? { org: params.org, team: params.team, member: params.member } : null;
|
||||
const authenticatedAdmin = Boolean(accessToken && canManage && !isLoading);
|
||||
const enabled = authenticatedAdmin && scope !== null;
|
||||
const context = useQuery({
|
||||
queryKey: ["customer-hierarchy", "manage", params.org, params.team, params.member],
|
||||
queryFn:
|
||||
enabled && scope && accessToken
|
||||
? ({ signal }) => customerHierarchyContext(scope, accessToken, signal)
|
||||
: skipToken,
|
||||
});
|
||||
const selected = context.data;
|
||||
const childTab: "members" | "teams" = selected?.team ? "members" : "teams";
|
||||
const showSettings = params.member !== null || params.tab === "settings";
|
||||
const tab: "teams" | "members" | "settings" = showSettings ? "settings" : childTab;
|
||||
const childContext = selected && !selected.member ? selected : null;
|
||||
const page = Math.max(1, params.hierarchyPage);
|
||||
const children = useQuery({
|
||||
queryKey: [
|
||||
"customer-hierarchy",
|
||||
"children",
|
||||
selected?.organization.customer_org_id,
|
||||
selected?.team?.customer_team_id,
|
||||
page,
|
||||
],
|
||||
queryFn:
|
||||
enabled && childContext && tab !== "settings"
|
||||
? ({ signal }) => customerHierarchyChildren(childContext, page, signal)
|
||||
: skipToken,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!children.isSuccess) return;
|
||||
if (page <= Math.max(1, children.data.meta.total_pages)) return;
|
||||
void setParams({ hierarchyPage: null });
|
||||
}, [children.isSuccess, children.data, page, setParams]);
|
||||
const openOrganization = () => {
|
||||
const parent = { team: null, member: null, tab: null, hierarchyPage: null };
|
||||
void setParams(parent, { history: "push" });
|
||||
};
|
||||
const openTeam = (id: string, settings = false) => {
|
||||
const next = { team: id, member: null, tab: settings ? ("settings" as const) : null, hierarchyPage: null };
|
||||
void setParams(next, { history: "push" });
|
||||
};
|
||||
const openMember = (id: string) =>
|
||||
void setParams({ member: id, tab: null, hierarchyPage: null }, { history: "push" });
|
||||
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["customer-hierarchy"] });
|
||||
return {
|
||||
scope,
|
||||
context,
|
||||
children,
|
||||
selected,
|
||||
tab,
|
||||
childTab,
|
||||
page,
|
||||
canManage,
|
||||
isLoading,
|
||||
openOrganization,
|
||||
openTeam,
|
||||
openMember,
|
||||
refresh,
|
||||
setPage: (hierarchyPage: number) => void setParams({ hierarchyPage }),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerBaseUrlGetter } from "@/lib/http/runtime";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor, within } from "../../../../../tests/test-utils";
|
||||
import CustomerHierarchyManager from "./page";
|
||||
|
||||
const auth = vi.hoisted(() => ({ userRole: "Admin", accessToken: "test-session", isLoading: false }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => auth }));
|
||||
vi.mock("next/navigation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/navigation")>()),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const audit = {
|
||||
spend: 12,
|
||||
created_at: "2026-11-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_at: "2026-11-01T00:00:00Z",
|
||||
updated_by: "admin",
|
||||
};
|
||||
const organization = {
|
||||
...audit,
|
||||
customer_org_id: "org-1",
|
||||
alias: "Example customer",
|
||||
budget: { max_budget: 100 },
|
||||
default_budgets: [],
|
||||
};
|
||||
const product = {
|
||||
...audit,
|
||||
customer_team_id: "team-a",
|
||||
parent_org_id: "org-1",
|
||||
alias: "Product",
|
||||
budget: { max_budget: 50 },
|
||||
};
|
||||
const operations = { ...audit, customer_team_id: "team-b", parent_org_id: "org-1", alias: "Operations", budget: null };
|
||||
const member = {
|
||||
...audit,
|
||||
customer_member_id: "member-a",
|
||||
parent_team_id: "team-a",
|
||||
alias: "Member One",
|
||||
effective_budget: { max_budget: 10 },
|
||||
};
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const json = (body: unknown) => new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } });
|
||||
const requestUrl = (input: Parameters<typeof fetch>[0]) =>
|
||||
new URL(input instanceof Request ? input.url : String(input), "http://localhost");
|
||||
const requestedUrls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input));
|
||||
|
||||
function serve(input: Parameters<typeof fetch>[0]): Response {
|
||||
const url = requestUrl(input);
|
||||
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
|
||||
if (url.pathname === "/management/v1/customer_teams/team-a") return json(product);
|
||||
if (url.pathname === "/management/v1/customer_teams/team-b")
|
||||
return json({ ...operations, parent_org_id: "another-org" });
|
||||
if (url.pathname === "/management/v1/customer_members/member-a") return json(member);
|
||||
if (url.pathname === "/management/v1/customer_teams" && url.searchParams.get("parent_org_id") === "org-1") {
|
||||
const page = Number(url.searchParams.get("page") ?? 1);
|
||||
return json({
|
||||
data: page === 1 ? [product] : [operations],
|
||||
meta: { total_count: 21, page, page_size: 20, total_pages: 2 },
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/management/v1/customer_members" && url.searchParams.get("parent_team_id") === "team-a") {
|
||||
return json({ data: [member], meta: { total_count: 1, page: 1, page_size: 20, total_pages: 1 } });
|
||||
}
|
||||
throw new Error(`Unexpected customer request ${url.pathname}`);
|
||||
}
|
||||
|
||||
describe("customer hierarchy management", () => {
|
||||
beforeEach(() => {
|
||||
auth.userRole = "Admin";
|
||||
testQueryClient.clear();
|
||||
registerBaseUrlGetter(() => "http://localhost");
|
||||
fetchMock.mockImplementation(async (input) => serve(input));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchMock.mockReset();
|
||||
testQueryClient.clear();
|
||||
});
|
||||
|
||||
it("should render the selected organization and request its paginated teams", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CustomerHierarchyManager />, { searchParams: "?org=org-1" });
|
||||
const table = await screen.findByRole("table", { name: "Teams" });
|
||||
expect(within(table).getByRole("button", { name: "Product" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Example customer" })).toBeInTheDocument();
|
||||
expect(
|
||||
requestedUrls()
|
||||
.find((url) => url.pathname === "/management/v1/customer_teams")
|
||||
?.searchParams.get("page_size"),
|
||||
).toBe("20");
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(await screen.findByRole("button", { name: "Operations" })).toBeInTheDocument();
|
||||
expect(
|
||||
requestedUrls()
|
||||
.filter((url) => url.pathname === "/management/v1/customer_teams")
|
||||
.map((url) => [url.searchParams.get("parent_org_id"), url.searchParams.get("page")]),
|
||||
).toEqual([
|
||||
["org-1", "1"],
|
||||
["org-1", "2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([2, 0])(
|
||||
"should reset an out-of-range hierarchy deep link when the collection has %i pages",
|
||||
async (totalPages) => {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.pathname !== "/management/v1/customer_teams") return serve(input);
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
return json({
|
||||
data: page === 1 && totalPages > 0 ? [product] : [],
|
||||
meta: { total_count: totalPages * 20, page, page_size: 20, total_pages: totalPages },
|
||||
});
|
||||
});
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<CustomerHierarchyManager />, {
|
||||
searchParams: "?org=org-1&hierarchyPage=999&source=portal",
|
||||
onUrlUpdate,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ queryString: "?org=org-1&source=portal" }),
|
||||
),
|
||||
);
|
||||
if (totalPages > 0) expect(await screen.findByRole("button", { name: "Product" })).toBeInTheDocument();
|
||||
else expect(await screen.findByText("No teams in this organization.")).toBeInTheDocument();
|
||||
expect(
|
||||
requestedUrls()
|
||||
.filter((url) => url.pathname === "/management/v1/customer_teams")
|
||||
.map((url) => url.searchParams.get("page")),
|
||||
).toEqual(["999", "1"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("should navigate from a team to member settings and keep scoped usage links", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<CustomerHierarchyManager />, { searchParams: "?org=org-1&source=portal", onUrlUpdate });
|
||||
await user.click(await screen.findByRole("button", { name: "Product" }));
|
||||
const members = await screen.findByRole("table", { name: "Members" });
|
||||
expect(
|
||||
requestedUrls()
|
||||
.find((url) => url.pathname === "/management/v1/customer_members")
|
||||
?.searchParams.get("parent_team_id"),
|
||||
).toBe("team-a");
|
||||
await user.click(within(members).getByRole("button", { name: "Edit Member One" }));
|
||||
expect(await screen.findByRole("heading", { name: "Member One" })).toBeInTheDocument();
|
||||
const usage = screen.getByRole("link", { name: "Usage" });
|
||||
expect(usage).toHaveAttribute("href", `${uiHref("customers/usage")}?org=org-1&team=team-a&member=member-a`);
|
||||
expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute("aria-current", "page");
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ queryString: "?org=org-1&source=portal&team=team-a&member=member-a" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve the member parent for a direct member deep link", async () => {
|
||||
renderWithProviders(<CustomerHierarchyManager />, { searchParams: "?org=org-1&member=member-a" });
|
||||
expect(await screen.findByRole("heading", { name: "Member One" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Customer hierarchy" })).toHaveTextContent("Product");
|
||||
expect(requestedUrls().some((url) => url.pathname === "/management/v1/customer_teams/team-a")).toBe(true);
|
||||
expect(requestedUrls().some((url) => url.pathname === "/management/v1/customer_members")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject a team outside the selected organization before exposing settings", async () => {
|
||||
renderWithProviders(<CustomerHierarchyManager />, { searchParams: "?org=org-1&team=team-b&tab=settings" });
|
||||
expect(
|
||||
await screen.findByText("This team or member does not belong to the selected organization."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Settings" })).not.toBeInTheDocument();
|
||||
expect(requestedUrls().some((url) => url.pathname === "/management/v1/customer_members")).toBe(false);
|
||||
});
|
||||
|
||||
it("should keep the management surface unavailable to a view-only admin", async () => {
|
||||
auth.userRole = "Admin Viewer";
|
||||
renderWithProviders(<CustomerHierarchyManager />, { searchParams: "?org=org-1" });
|
||||
expect(
|
||||
screen.getByText("Only proxy admins can manage customer organizations, teams, and members."),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"use client";
|
||||
|
||||
export { default } from "../_components/CustomerHierarchyManager";
|
||||
|
|
@ -166,6 +166,10 @@ describe("CustomerHierarchyUsagePage", () => {
|
|||
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl });
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Customer organization" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Teams", exact: true })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/customers/manage?org=org-1",
|
||||
);
|
||||
expect(screen.getByText("$15.00")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("700 tokens")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Usage over time" })).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { extractProxyErrorMessage } from "@/lib/http/client";
|
|||
import { uiHref } from "@/utils/uiHref";
|
||||
import CustomerMemberSpend from "../_components/CustomerMemberSpend";
|
||||
import CustomerUsageHeader from "../_components/CustomerUsageHeader";
|
||||
import CustomerHierarchyNavigation from "../_components/CustomerHierarchyNavigation";
|
||||
import CustomerUsageRankings from "../_components/CustomerUsageRankings";
|
||||
import CustomerUsageTrend from "../_components/CustomerUsageTrend";
|
||||
import useCustomerHierarchyUsage from "../_components/useCustomerHierarchyUsage";
|
||||
|
|
@ -73,6 +74,7 @@ export default function CustomerHierarchyUsagePage() {
|
|||
onTeamClick={selectTeam}
|
||||
onDateRangeChange={setDateRange}
|
||||
/>
|
||||
<CustomerHierarchyNavigation scope={scope} active="usage" />
|
||||
{usage.isError ? (
|
||||
<ErrorState
|
||||
title={t("customerPortal.analytics.error")}
|
||||
|
|
|
|||
|
|
@ -20599,6 +20599,80 @@
|
|||
"loadFailed": "Could not load this model group's deployments. Saved weights are kept."
|
||||
},
|
||||
"customerPortal": {
|
||||
"hierarchy": {
|
||||
"customerNavigation": "Customer navigation",
|
||||
"usage": "Usage",
|
||||
"manage": "Manage",
|
||||
"title": "Customer management",
|
||||
"subtitle": "Manage teams, members, and settings for this customer.",
|
||||
"organization": "Organization",
|
||||
"team": "Team",
|
||||
"member": "Member",
|
||||
"teams": "Teams",
|
||||
"members": "Members",
|
||||
"settings": "Settings",
|
||||
"viewUsage": "View usage",
|
||||
"edit": "Edit",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"spend": "Recorded spend",
|
||||
"budget": "Budget",
|
||||
"noBudget": "No budget",
|
||||
"teamsHint": "Choose a team to manage its members and settings.",
|
||||
"membersHint": "Choose a member to manage their settings and budget.",
|
||||
"emptyTeams": "No teams in this organization.",
|
||||
"emptyMembers": "No members in this team.",
|
||||
"error": "Unable to load customer",
|
||||
"scopeMismatch": "This team or member does not belong to the selected customer.",
|
||||
"missingOrganization": "Choose a customer organization to manage.",
|
||||
"backToCustomers": "Back to customers",
|
||||
"adminOnly": "Only proxy admins can manage customer organizations, teams, and members.",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pagination": "Page {{page}} of {{pages}}",
|
||||
"loading": "Loading customer…",
|
||||
"teamsLoadError": "Unable to load teams",
|
||||
"membersLoadError": "Unable to load members",
|
||||
"settingsTitle": "Settings",
|
||||
"profileTitle": "General",
|
||||
"profileHint": "Update the display name without changing its identifier or parent.",
|
||||
"alias": "Display name",
|
||||
"identifier": "Identifier",
|
||||
"budgetTitle": "Budget and limits",
|
||||
"budgetHint": "Leave a limit blank for no limit. Zero is a valid limit.",
|
||||
"budgetEnabled": "Set a budget and limits",
|
||||
"maxBudget": "Spend limit (USD)",
|
||||
"softBudget": "Soft budget (USD)",
|
||||
"budgetDuration": "Reset period",
|
||||
"budgetDurationHint": "For example, 1h, 7d, or 1mo. Leave blank for no reset.",
|
||||
"tpm": "Tokens per minute",
|
||||
"rpm": "Requests per minute",
|
||||
"allowedModels": "Allowed models",
|
||||
"allowedModelsHint": "One model per line. Leave blank to allow all models.",
|
||||
"template": "Budget template",
|
||||
"noTemplate": "No template",
|
||||
"unknownTemplate": "Unavailable template ({{id}})",
|
||||
"defaultTemplate": "{{name}} (default)",
|
||||
"overrideEnabled": "Override the template budget",
|
||||
"overrideHint": "An override replaces the selected template budget while it is active.",
|
||||
"overrideExpiry": "Override expires",
|
||||
"overrideExpiryHint": "Uses your local time. Leave blank for no expiry.",
|
||||
"save": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"saved": "Changes saved",
|
||||
"reset": "Reset changes",
|
||||
"deleteTitle": "Delete {{kind}}",
|
||||
"deleteHint": "Delete this record and its owned budget.",
|
||||
"deleteParentHint": "Remove child teams or members first. Deleting a parent does not delete its children.",
|
||||
"deleteConfirmTitle": "Delete {{name}}?",
|
||||
"deleteConfirmHint": "This permanently deletes this {{kind}}. This action cannot be undone.",
|
||||
"deleteConfirm": "Delete {{kind}}",
|
||||
"deleting": "Deleting…",
|
||||
"cancel": "Cancel",
|
||||
"invalidNumber": "Enter finite, non-negative limits. Rate limits must be whole numbers.",
|
||||
"invalidExpiry": "Enter a valid expiry date and time.",
|
||||
"budgetRequired": "Select a budget template or enable an override for this member."
|
||||
},
|
||||
"switcher": {
|
||||
"label": "Customer Portal",
|
||||
"description": "Customer organizations and usage"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue