diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logging-and-alerts/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logging-and-alerts/page.tsx index 8232e391259..ea6f2dcdfd1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/logging-and-alerts/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/logging-and-alerts/page.tsx @@ -4,6 +4,14 @@ import Settings from "@/components/settings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function LoggingAndAlerts() { - const { accessToken, userRole, userId, premiumUser } = useAuthorized(); - return ; + const { accessToken, userRole, userId, premiumUser, isViewOnly } = useAuthorized(); + return ( + + ); } diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index c8b7418d45d..e4a20bedd66 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -387,3 +387,45 @@ describe("Add Callback dropdown", () => { expect(screen.getByText("Arize (scoped destination)")).toBeInTheDocument(); }); }); + +describe("Settings read-only admin", () => { + beforeEach(() => { + vi.clearAllMocks(); + credentialsFixture = { credentials: [] }; + vi.mocked(getCallbacksCall).mockResolvedValue({ callbacks: [], available_callbacks: [], alerts: [] }); + vi.mocked(getCallbackConfigsCall).mockResolvedValue([]); + vi.mocked(alertingSettingsCall).mockResolvedValue([]); + }); + + it("gives a view-only session no write affordances even though its role reads as Admin", async () => { + // Regression: proxy_admin_viewer is mapped to the effective role "Admin" so it gets + // read parity, and the write restriction travels separately on isViewOnly. Deriving + // write access from the role alone handed the viewer Add, Edit scope and Delete. + const { queryByText } = renderSettings({ + accessToken: "token", + userRole: "Admin", + userID: "viewer-1", + premiumUser: false, + isViewOnly: true, + } as never); + + await waitFor(() => { + expect(queryByText("Active Logging Callbacks")).toBeInTheDocument(); + }); + expect(queryByText("Add Callback")).not.toBeInTheDocument(); + }); + + it("keeps the write affordances for a real proxy admin", async () => { + const { queryByText } = renderSettings({ + accessToken: "token", + userRole: "Admin", + userID: "admin-1", + premiumUser: false, + }); + + await waitFor(() => { + expect(queryByText("Active Logging Callbacks")).toBeInTheDocument(); + }); + expect(queryByText("Add Callback")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 9def17ede04..dacba0524bc 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -57,6 +57,7 @@ interface SettingsPageProps { userRole: string | null; userID: string | null; premiumUser: boolean; + isViewOnly?: boolean; } const assetsLogoFolder = "/ui/assets/logos/"; @@ -215,7 +216,7 @@ const buildCallbackPayload = (formValues: Record, callbackName: str }; }; -const Settings: React.FC = ({ accessToken, userRole, userID, premiumUser }) => { +const Settings: React.FC = ({ accessToken, userRole, userID, premiumUser, isViewOnly = false }) => { const [callbacks, setCallbacks] = useState([]); const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true); const [alerts, setAlerts] = useState([]); @@ -253,7 +254,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, // credential_type=logging; they share the one Active Logging Callbacks table as // rows alongside config callbacks. Only a proxy admin (or admin-viewer, read-only) // may read them, so non-admins skip the fetch entirely. - const isProxyAdmin = userRole != null && isProxyAdminRole(userRole); + const isProxyAdmin = !isViewOnly && userRole != null && isProxyAdminRole(userRole); const { data: credentialData, refetch: refetchCredentials } = useCredentials(canReadCredentialsRole(userRole)); const { data: teamsData } = useTeams(); const { data: orgsData } = useOrganizations();