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 b4e300d9fc3..ccb9f90f9a3 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(); @@ -281,6 +281,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 8482d0832c3..f46130d2386 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 84a05113177..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 @@ -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", () => ({ @@ -25,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(); @@ -95,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", () => { @@ -108,6 +122,35 @@ describe("ModelsAndEndpointsPage", () => { expect(screen.queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument(); }); + it("keeps the full admin tab order for a real admin", () => { + renderPage(); + expect(screen.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); + 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(); + }); + // POST /model/new 403s a proxy_admin_viewer, so the form's tab must not render for one. it("hides the Add Model tab for a view-only admin session", () => { mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); 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 34c9d87004e..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 @@ -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"; @@ -148,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}