From 3eb48c1fba501c9c9a5131cac86d308ec75712c0 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 29 Aug 2026 21:16:59 -0700
Subject: [PATCH 1/3] fix(ui): hide admin write-form tabs on the models page
from view-only admins
---
.../models-and-endpoints/page.test.tsx | 44 ++++++++++++++++++-
.../(dashboard)/models-and-endpoints/page.tsx | 21 ++++-----
2 files changed, 51 insertions(+), 14 deletions(-)
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
index 521f89a39f2..a53e8167636 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
@@ -14,6 +14,7 @@ vi.mock("./panels/HealthStatusPanel", () => ({ default: () =>
({ default: () =>
}));
vi.mock("./panels/ModelGroupAliasPanel", () => ({ default: () =>
}));
vi.mock("./panels/PriceDataPanel", () => ({ default: () =>
}));
+vi.mock("./panels/AccessGroupBudgetsPanel", () => ({ default: () =>
}));
const detailState = { modelId: null as string | null, teamId: null as string | null };
vi.mock("./detailNavigation", () => ({
@@ -38,8 +39,18 @@ vi.mock("./useModelDashboardData", () => ({
useModelDashboardData: () => ({ availableModelAccessGroups: [], allModelsOnProxy: [], availableModelGroups: [] }),
}));
-const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false };
-const NON_ADMIN = { accessToken: "at", token: "t", userRole: "Internal User", userId: "u1", premiumUser: false };
+const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false, isViewOnly: false };
+const NON_ADMIN = {
+ accessToken: "at",
+ token: "t",
+ userRole: "Internal User",
+ userId: "u1",
+ premiumUser: false,
+ isViewOnly: false,
+};
+// What useAuthorized returns for a proxy_admin_viewer session: effectiveSessionRole masquerades
+// the role as "Admin" for read parity, and only isViewOnly tells the page it may not write.
+const VIEW_ONLY_ADMIN = { ...ADMIN, isViewOnly: true };
const renderPage = () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
@@ -99,6 +110,35 @@ describe("ModelsAndEndpointsPage", () => {
expect(queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument();
});
+ it("keeps the full admin tab order for a real admin", () => {
+ const { getAllByRole } = renderPage();
+ expect(getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
+ "All Models",
+ "Add Model",
+ "Auto-Routers Beta",
+ "LLM Credentials",
+ "Pass-Through Endpoints",
+ "Health Status",
+ "Model Retry Settings",
+ "Model Group Alias",
+ "Model Access Group Budgets Beta",
+ "Price Data Reload",
+ ]);
+ });
+
+ it("hides the admin write-form tabs from a view-only admin, keeping the read views", () => {
+ mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
+ const { getByRole, queryByRole } = renderPage();
+ expect(getByRole("tab", { name: "All Models" })).toBeInTheDocument();
+ expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
+ expect(queryByRole("tab", { name: "LLM Credentials" })).not.toBeInTheDocument();
+ expect(queryByRole("tab", { name: "Pass-Through Endpoints" })).not.toBeInTheDocument();
+ expect(queryByRole("tab", { name: "Model Retry Settings" })).not.toBeInTheDocument();
+ expect(queryByRole("tab", { name: "Model Group Alias" })).not.toBeInTheDocument();
+ expect(queryByRole("tab", { name: /Model Access Group Budgets/ })).not.toBeInTheDocument();
+ expect(queryByRole("tab", { name: "Price Data Reload" })).not.toBeInTheDocument();
+ });
+
// Auto-routers are excluded from the All Models table, so this tab is their home: the only
// place in the product to list, create, edit or delete one.
describe("Auto-Routers tab", () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
index 9ae7dc12f81..c7455038cb9 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
@@ -80,7 +80,7 @@ const renderPanel = (key: string) => {
};
export default function ModelsAndEndpointsPage() {
- const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
+ const { accessToken, userRole, userId: userID, premiumUser, isViewOnly } = useAuthorized();
const { data: teams } = useTeams();
const { data: uiSettings } = useUISettings();
const queryClient = useQueryClient();
@@ -106,19 +106,16 @@ export default function ModelsAndEndpointsPage() {
"",
...(canCreate ? (["add"] as const) : []),
...(isAdmin || canCreate ? (["auto-routers"] as const) : []),
- ...(isAdmin
- ? ([
- "llm-credentials",
- "pass-through",
- "health",
- "retry-settings",
- "model-group-alias",
- "access-group-budgets",
- "price-data",
- ] as const)
+ // effectiveSessionRole reports proxy_admin_viewer as "Admin", so isAdmin alone would show a
+ // viewer these write-only panels; only the raw-role isViewOnly separates them. Health Status
+ // stays: it is the bucket's one read view, and viewers keep read parity with admins.
+ ...(isAdmin && !isViewOnly ? (["llm-credentials", "pass-through"] as const) : []),
+ ...(isAdmin ? (["health"] as const) : []),
+ ...(isAdmin && !isViewOnly
+ ? (["retry-settings", "model-group-alias", "access-group-budgets", "price-data"] as const)
: []),
],
- [canCreate, isAdmin],
+ [canCreate, isAdmin, isViewOnly],
);
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
From cb5201305a7866f8f5bcb4c40989a038d79389fd Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Tue, 1 Sep 2026 14:20:41 -0700
Subject: [PATCH 2/3] use screen queries in models page tests to satisfy lint
budget
---
.../models-and-endpoints/page.test.tsx | 22 +++++++++----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
index 1871c7cbc84..e5414630c5b 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
@@ -111,8 +111,8 @@ describe("ModelsAndEndpointsPage", () => {
});
it("keeps the full admin tab order for a real admin", () => {
- const { getAllByRole } = renderPage();
- expect(getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
+ renderPage();
+ expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
"All Models",
"Add Model",
"Auto-Routers Beta",
@@ -128,15 +128,15 @@ describe("ModelsAndEndpointsPage", () => {
it("hides the admin write-form tabs from a view-only admin, keeping the read views", () => {
mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
- const { getByRole, queryByRole } = renderPage();
- expect(getByRole("tab", { name: "All Models" })).toBeInTheDocument();
- expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
- expect(queryByRole("tab", { name: "LLM Credentials" })).not.toBeInTheDocument();
- expect(queryByRole("tab", { name: "Pass-Through Endpoints" })).not.toBeInTheDocument();
- expect(queryByRole("tab", { name: "Model Retry Settings" })).not.toBeInTheDocument();
- expect(queryByRole("tab", { name: "Model Group Alias" })).not.toBeInTheDocument();
- expect(queryByRole("tab", { name: /Model Access Group Budgets/ })).not.toBeInTheDocument();
- expect(queryByRole("tab", { name: "Price Data Reload" })).not.toBeInTheDocument();
+ renderPage();
+ expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
+ expect(screen.queryByRole("tab", { name: "LLM Credentials" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("tab", { name: "Pass-Through Endpoints" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("tab", { name: "Model Retry Settings" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("tab", { name: "Model Group Alias" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("tab", { name: /Model Access Group Budgets/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("tab", { name: "Price Data Reload" })).not.toBeInTheDocument();
});
// Auto-routers are excluded from the All Models table, so this tab is their home: the only
From 344b992bed155460539dbbaa7eaf82b3f8673791 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 5 Sep 2026 04:05:30 -0700
Subject: [PATCH 3/3] fix(ui): withhold model row actions and team edit rights
from view-only admins
---
.../components/AllModelsTab.tsx | 3 ++-
.../components/AllModelsTable.test.tsx | 23 +++++++++++++++++++
.../components/AllModelsTable.tsx | 5 +++-
.../components/ModelsTableColumns.tsx | 9 ++++++--
.../models-and-endpoints/page.test.tsx | 17 ++++++++++++--
.../(dashboard)/models-and-endpoints/page.tsx | 2 +-
6 files changed, 52 insertions(+), 7 deletions(-)
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
index be2cf22d71a..ecafdc3bc21 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
@@ -48,7 +48,7 @@ const AllModelsTab = ({
setSelectedTeamId,
}: AllModelsTabProps) => {
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
- const { accessToken, userId, userRole } = useAuthorized();
+ const { accessToken, userId, userRole, isViewOnly } = useAuthorized();
const { data: teams, isLoading: isLoadingTeams } = useTeams();
const queryClient = useQueryClient();
@@ -295,6 +295,7 @@ const AllModelsTab = ({
availableModelAccessGroups={availableModelAccessGroups}
userRole={userRole}
userID={userId}
+ isViewOnly={isViewOnly}
onModelIdClick={setSelectedModelId}
onTeamIdClick={setSelectedTeamId}
onDeleteClick={handleDeleteClick}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx
index 8ba71e82d48..726070c4bb6 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx
@@ -59,6 +59,7 @@ const baseProps = {
availableModelAccessGroups: ["sales-team"],
userRole: "Admin",
userID: "alice",
+ isViewOnly: false,
onModelIdClick: vi.fn(),
onTeamIdClick: vi.fn(),
onDeleteClick: vi.fn(),
@@ -254,6 +255,17 @@ describe("AllModelsTable", () => {
expect(onTogglePauseClick).not.toHaveBeenCalled();
});
+ it("does not let a view-only admin toggle a model", async () => {
+ const user = userEvent.setup();
+ const onTogglePauseClick = vi.fn();
+ render(
);
+
+ const toggle = screen.getByTestId("model-pause-toggle-model-1");
+ expect(toggle).toHaveAttribute("data-disabled");
+ await user.click(toggle);
+ expect(onTogglePauseClick).not.toHaveBeenCalled();
+ });
+
it("does not let anyone toggle a config model", async () => {
const user = userEvent.setup();
const onTogglePauseClick = vi.fn();
@@ -309,6 +321,17 @@ describe("AllModelsTable", () => {
expect(onDeleteClick).not.toHaveBeenCalled();
});
+ it("blocks a view-only admin from deleting a DB model they created", async () => {
+ const user = userEvent.setup();
+ const onDeleteClick = vi.fn();
+ render(
);
+
+ const deleteButton = screen.getByTestId("model-delete-model-1");
+ expect(deleteButton).toBeDisabled();
+ await user.click(deleteButton);
+ expect(onDeleteClick).not.toHaveBeenCalled();
+ });
+
it("blocks deleting a config model", async () => {
const user = userEvent.setup();
const onDeleteClick = vi.fn();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx
index 5c7dbb18428..3dc8230a7c5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx
@@ -73,6 +73,7 @@ interface AllModelsTableProps {
availableModelAccessGroups: string[];
userRole: string;
userID: string;
+ isViewOnly: boolean;
onModelIdClick: (modelId: string) => void;
onTeamIdClick: (teamId: string) => void;
onDeleteClick: (modelId: string) => void;
@@ -120,6 +121,7 @@ export function AllModelsTable({
availableModelAccessGroups,
userRole,
userID,
+ isViewOnly,
onModelIdClick,
onTeamIdClick,
onDeleteClick,
@@ -132,6 +134,7 @@ export function AllModelsTable({
const columnDeps = {
userRole,
userID,
+ isViewOnly,
onModelIdClick,
onTeamIdClick,
onDeleteClick,
@@ -139,7 +142,7 @@ export function AllModelsTable({
pausingModelId,
};
return getModelsTableColumns(columnDeps);
- }, [userRole, userID, onModelIdClick, onTeamIdClick, onDeleteClick, onTogglePauseClick, pausingModelId]);
+ }, [userRole, userID, isViewOnly, onModelIdClick, onTeamIdClick, onDeleteClick, onTogglePauseClick, pausingModelId]);
const modelGroupOptions = useMemo(
() => [
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx
index f3ae687447e..0cc1207e547 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx
@@ -247,6 +247,7 @@ interface ModelRowActionsProps {
model: ModelData;
userRole: string;
userID: string;
+ isViewOnly: boolean;
isPausing: boolean;
onDeleteClick?: (modelId: string) => void;
onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise
;
@@ -256,14 +257,15 @@ function ModelRowActions({
model,
userRole,
userID,
+ isViewOnly,
isPausing,
onDeleteClick,
onTogglePauseClick,
}: ModelRowActionsProps) {
const modelId = model.model_info?.id;
const isConfigModel = !model.model_info?.db_model;
- const isAdmin = userRole === "Admin";
- const canEditModel = isAdmin || model.model_info?.created_by === userID;
+ const isAdmin = userRole === "Admin" && !isViewOnly;
+ const canEditModel = !isViewOnly && (isAdmin || model.model_info?.created_by === userID);
const isBlocked = model.model_info?.blocked === true;
const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick);
@@ -340,6 +342,7 @@ function ModelRowActions({
export interface ModelsTableColumnDeps {
userRole: string;
userID: string;
+ isViewOnly: boolean;
onModelIdClick: (modelId: string) => void;
onTeamIdClick: (teamId: string) => void;
onDeleteClick?: (modelId: string) => void;
@@ -350,6 +353,7 @@ export interface ModelsTableColumnDeps {
export const getModelsTableColumns = ({
userRole,
userID,
+ isViewOnly,
onModelIdClick,
onTeamIdClick,
onDeleteClick,
@@ -479,6 +483,7 @@ export const getModelsTableColumns = ({
model={row.original}
userRole={userRole}
userID={userID}
+ isViewOnly={isViewOnly}
isPausing={pausingModelId === row.original.model_info?.id}
onDeleteClick={onDeleteClick}
onTogglePauseClick={onTogglePauseClick}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
index 1bdad719de4..105f6ff3043 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
@@ -26,7 +26,11 @@ vi.mock("@/components/model_info_view", () => ({
default: ({ modelId }: { modelId: string }) => model:{modelId}
,
}));
vi.mock("@/components/team/TeamInfo", () => ({
- default: ({ teamId }: { teamId: string }) => team:{teamId}
,
+ default: ({ teamId, is_team_admin }: { teamId: string; is_team_admin: boolean }) => (
+
+ team:{teamId}
+
+ ),
}));
const mockUseAuthorized = vi.fn();
@@ -96,10 +100,19 @@ describe("ModelsAndEndpointsPage", () => {
expect(screen.queryByRole("tab", { name: "All Models" })).not.toBeInTheDocument();
});
- it("renders the team detail overlay from the ?team drill-in", () => {
+ it("renders the team detail overlay from the ?team drill-in with admin edit rights", () => {
detailState.teamId = "team-9";
renderPage();
expect(screen.getByTestId("team-info")).toHaveTextContent("team:team-9");
+ expect(screen.getByTestId("team-info")).toHaveAttribute("data-team-admin", "true");
+ });
+
+ it("opens the ?team drill-in without edit rights for a view-only admin", () => {
+ mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
+ detailState.teamId = "team-9";
+ renderPage();
+ expect(screen.getByTestId("team-info")).toHaveTextContent("team:team-9");
+ expect(screen.getByTestId("team-info")).toHaveAttribute("data-team-admin", "false");
});
it("hides admin-only tabs for a non-admin user", () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
index 1afb191bea2..4d6a90fc56e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
@@ -145,7 +145,7 @@ export default function ModelsAndEndpointsPage() {
teamId={teamId}
onClose={close}
accessToken={accessToken}
- is_team_admin={userRole === "Admin"}
+ is_team_admin={userRole === "Admin" && !isViewOnly}
is_proxy_admin={userRole === "Proxy Admin"}
userModels={allModelsOnProxy}
editTeam={false}