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}