feat(ui): manage customer organization, team, and member settings (#51507)

This commit is contained in:
ishaan-j-berri 2026-11-13 09:29:07 +08:00 • committed by GitHub
parent 9633be87ea
commit d126859942
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1740 additions and 40 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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());
});
});

View file

@ -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}
/>
);
}

View file

@ -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 };
};

View file

@ -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" });
});
});

View file

@ -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);
};

View file

@ -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;

View file

@ -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" },
});
});

View file

@ -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 }),
};
}

View file

@ -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();
});
});

View file

@ -0,0 +1,3 @@
"use client";
export { default } from "../_components/CustomerHierarchyManager";

View file

@ -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();

View file

@ -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")}

View file

@ -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"