mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(ui): restore the Logs Deleted Teams tab for organization admins
Hiding the tab behind all_admin_roles took it away from org admins, who are entitled to it: /v2/team/list?status=deleted returns 200 for them, scoped to their own organizations. An org admin is an organization membership rather than a global role, so their session carries user_role "internal_user" and no role-based gate can ever see them. Lift the membership lookup the left nav already did into a shared useIsOrgAdmin hook, and let a capability opt into allowing org admins. viewDeletedTeams is the only one that opts in; the backend still refuses org admins on /v1/tool/list, /policies/list, /prompts/list and /audit, so those gates stay as they are. The hook also accepts a session role of org_admin, in case a deployment maps one through SSO.
This commit is contained in:
parent
f1ed4690bb
commit
f306927853
12 changed files with 293 additions and 30 deletions
|
|
@ -3,10 +3,12 @@
|
||||||
import { hasCapability, type Capability } from "@/utils/capabilities";
|
import { hasCapability, type Capability } from "@/utils/capabilities";
|
||||||
|
|
||||||
import useAuthorized from "./useAuthorized";
|
import useAuthorized from "./useAuthorized";
|
||||||
|
import useIsOrgAdmin from "./useIsOrgAdmin";
|
||||||
|
|
||||||
const useCan = (capability: Capability): boolean => {
|
const useCan = (capability: Capability): boolean => {
|
||||||
const { userRole } = useAuthorized();
|
const { userRole } = useAuthorized();
|
||||||
return hasCapability(userRole, capability);
|
const isOrgAdmin = useIsOrgAdmin();
|
||||||
|
return hasCapability(userRole, capability, isOrgAdmin);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useCan;
|
export default useCan;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/* @vitest-environment jsdom */
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Organization } from "@/components/networking";
|
||||||
|
import useIsOrgAdmin from "./useIsOrgAdmin";
|
||||||
|
|
||||||
|
const { useAuthorizedMock, useOrganizationsMock } = vi.hoisted(() => ({
|
||||||
|
useAuthorizedMock: vi.fn(),
|
||||||
|
useOrganizationsMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: useAuthorizedMock }));
|
||||||
|
vi.mock("./organizations/useOrganizations", () => ({ useOrganizations: useOrganizationsMock }));
|
||||||
|
|
||||||
|
const orgWithMembers = (members: { user_id: string; user_role: string }[]): Organization =>
|
||||||
|
({ organization_id: "org-1", members }) as unknown as Organization;
|
||||||
|
|
||||||
|
const renderAs = (userRole: string, organizations: Organization[] | undefined) => {
|
||||||
|
useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole });
|
||||||
|
useOrganizationsMock.mockReturnValue({ data: organizations });
|
||||||
|
return renderHook(() => useIsOrgAdmin()).result;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useIsOrgAdmin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for the session a real org admin carries: internal_user plus an org_admin membership", () => {
|
||||||
|
const result = renderAs("Internal User", [orgWithMembers([{ user_id: "user-1", user_role: "org_admin" }])]);
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for an internal user with no org_admin membership", () => {
|
||||||
|
const result = renderAs("Internal User", [orgWithMembers([{ user_id: "user-1", user_role: "internal_user" }])]);
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false while the organization list is still loading", () => {
|
||||||
|
const result = renderAs("Internal User", undefined);
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for a session role of org_admin even with no membership rows", () => {
|
||||||
|
expect(renderAs("org_admin", []).current).toBe(true);
|
||||||
|
expect(renderAs("Org Admin", []).current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a proxy admin, who is covered by role-based gates instead", () => {
|
||||||
|
const result = renderAs("Admin", []);
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { isOrgAdminForAnyOrg, isOrgAdminSessionRole } from "@/utils/roles";
|
||||||
|
|
||||||
|
import { useOrganizations } from "./organizations/useOrganizations";
|
||||||
|
import useAuthorized from "./useAuthorized";
|
||||||
|
|
||||||
|
const useIsOrgAdmin = (): boolean => {
|
||||||
|
const { userId, userRole } = useAuthorized();
|
||||||
|
const { data: organizations } = useOrganizations();
|
||||||
|
return isOrgAdminSessionRole(userRole) || isOrgAdminForAnyOrg(organizations, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useIsOrgAdmin;
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { renderWithProviders as render } from "@/../tests/test-utils";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import ChatUI from "./ChatUI";
|
import ChatUI from "./ChatUI";
|
||||||
import * as fetchModelsModule from "@/components/llm_calls/fetch_models";
|
import * as fetchModelsModule from "@/components/llm_calls/fetch_models";
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { renderWithProviders } from "../../tests/test-utils";
|
import { renderWithProviders } from "../../tests/test-utils";
|
||||||
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
|
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
|
||||||
|
|
||||||
vi.mock("../utils/roles", () => {
|
vi.mock("../utils/roles", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../utils/roles")>();
|
||||||
return {
|
return {
|
||||||
|
...actual,
|
||||||
all_admin_roles: ["admin", "admin_viewer"],
|
all_admin_roles: ["admin", "admin_viewer"],
|
||||||
internalUserRoles: ["internal"],
|
internalUserRoles: ["internal"],
|
||||||
rolesWithWriteAccess: ["admin", "internal"],
|
rolesWithWriteAccess: ["admin", "internal"],
|
||||||
|
|
@ -91,6 +93,11 @@ describe("Sidebar (leftnav)", () => {
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockUseAuthorized.mockReset();
|
||||||
|
mockUseOrganizations.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
it("should link the logo to the UI home route rather than the proxy origin", () => {
|
it("should link the logo to the UI home route rather than the proxy origin", () => {
|
||||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
|
|
||||||
|
|
@ -174,19 +181,19 @@ describe("Sidebar (leftnav)", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
it("hides Playground from Admin Viewer (cost-incurring action)", () => {
|
it("hides Playground from Admin Viewer (cost-incurring action)", () => {
|
||||||
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
|
mockUseAuthorized.mockReturnValue(adminViewerAuth);
|
||||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
expect(screen.queryByText("Playground")).not.toBeInTheDocument();
|
expect(screen.queryByText("Playground")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Models + Endpoints to Admin Viewer (read-only)", () => {
|
it("shows Models + Endpoints to Admin Viewer (read-only)", () => {
|
||||||
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
|
mockUseAuthorized.mockReturnValue(adminViewerAuth);
|
||||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
expect(screen.getByText("Models + Endpoints")).toBeInTheDocument();
|
expect(screen.getByText("Models + Endpoints")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Agents (under Agentic) to Admin Viewer (read-only)", async () => {
|
it("shows Agents (under Agentic) to Admin Viewer (read-only)", async () => {
|
||||||
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
|
mockUseAuthorized.mockReturnValue(adminViewerAuth);
|
||||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
// Agents is now nested under the "Agentic" submenu — expand parent
|
// Agents is now nested under the "Agentic" submenu — expand parent
|
||||||
// first to render the children, then assert Agents is visible.
|
// first to render the children, then assert Agents is visible.
|
||||||
|
|
@ -199,7 +206,7 @@ describe("Sidebar (leftnav)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Logs to Admin Viewer", () => {
|
it("shows Logs to Admin Viewer", () => {
|
||||||
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
|
mockUseAuthorized.mockReturnValue(adminViewerAuth);
|
||||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -269,10 +276,11 @@ describe("Sidebar (leftnav)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show Organizations tab for organization admins", () => {
|
it("should show Organizations tab for organization admins", () => {
|
||||||
mockUseAuthorized.mockReturnValueOnce({
|
mockUseAuthorized.mockReturnValue({
|
||||||
userId: "org-admin-user-id",
|
userId: "org-admin-user-id",
|
||||||
accessToken: "test-access-token",
|
accessToken: "test-access-token",
|
||||||
userRole: "viewer",
|
userRole: "viewer",
|
||||||
|
isViewOnly: false,
|
||||||
token: "test-token",
|
token: "test-token",
|
||||||
userEmail: "orgadmin@example.com",
|
userEmail: "orgadmin@example.com",
|
||||||
premiumUser: false,
|
premiumUser: false,
|
||||||
|
|
@ -280,7 +288,7 @@ describe("Sidebar (leftnav)", () => {
|
||||||
showSSOBanner: false,
|
showSSOBanner: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
mockUseOrganizations.mockReturnValueOnce({
|
mockUseOrganizations.mockReturnValue({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
organization_id: "org-1",
|
organization_id: "org-1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
|
||||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||||
|
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||||
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
|
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
|
||||||
import { getProxyBaseUrl } from "@/components/networking";
|
import { getProxyBaseUrl } from "@/components/networking";
|
||||||
|
|
@ -75,7 +75,6 @@ import {
|
||||||
} from "../utils/roles";
|
} from "../utils/roles";
|
||||||
import BetaBadge from "./BetaBadge";
|
import BetaBadge from "./BetaBadge";
|
||||||
import NewBadge from "./common_components/NewBadge";
|
import NewBadge from "./common_components/NewBadge";
|
||||||
import type { Organization } from "./networking";
|
|
||||||
import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu";
|
import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu";
|
||||||
import SidebarUsageCard from "./SidebarUsageCard";
|
import SidebarUsageCard from "./SidebarUsageCard";
|
||||||
import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages";
|
import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages";
|
||||||
|
|
@ -414,7 +413,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
||||||
allowVectorStoresForTeamAdmins,
|
allowVectorStoresForTeamAdmins,
|
||||||
}) => {
|
}) => {
|
||||||
const { userId, accessToken, userRole, isViewOnly } = useAuthorized();
|
const { userId, accessToken, userRole, isViewOnly } = useAuthorized();
|
||||||
const { data: organizations } = useOrganizations();
|
const isOrgAdmin = useIsOrgAdmin();
|
||||||
const { data: teams } = useTeams();
|
const { data: teams } = useTeams();
|
||||||
const { logoUrl } = useTheme();
|
const { logoUrl } = useTheme();
|
||||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||||
|
|
@ -441,13 +440,6 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOrgAdmin = useMemo(() => {
|
|
||||||
if (!userId || !organizations) return false;
|
|
||||||
return organizations.some((org: Organization) =>
|
|
||||||
org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin"),
|
|
||||||
);
|
|
||||||
}, [userId, organizations]);
|
|
||||||
|
|
||||||
const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]);
|
const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]);
|
||||||
|
|
||||||
const filterItemsByRole = (items: MenuItem[]): MenuItem[] => {
|
const filterItemsByRole = (items: MenuItem[]): MenuItem[] => {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import SpendLogsTable from "./index";
|
import SpendLogsTable from "./index";
|
||||||
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
|
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
|
||||||
|
|
||||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
const { useAuthorizedMock, useOrganizationsMock } = vi.hoisted(() => ({
|
||||||
|
useAuthorizedMock: vi.fn(),
|
||||||
|
useOrganizationsMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||||
default: useAuthorizedMock,
|
default: useAuthorizedMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||||
|
useOrganizations: useOrganizationsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./RequestLogsPanel", () => ({
|
vi.mock("./RequestLogsPanel", () => ({
|
||||||
default: function RequestLogsPanelMock() {
|
default: function RequestLogsPanelMock() {
|
||||||
return <div data-testid="request-logs-panel" />;
|
return <div data-testid="request-logs-panel" />;
|
||||||
|
|
@ -37,8 +44,16 @@ const defaultProps = {
|
||||||
premiumUser: true,
|
premiumUser: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAs = (sessionRole: string) => {
|
const ORG_ADMIN_MEMBERSHIPS = [{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "org_admin" }] }];
|
||||||
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userRole: sessionRole, premiumUser: true });
|
|
||||||
|
const renderAs = (sessionRole: string, organizations: unknown[] = []) => {
|
||||||
|
useAuthorizedMock.mockReturnValue({
|
||||||
|
accessToken: "sk-test",
|
||||||
|
userId: "user-1",
|
||||||
|
userRole: sessionRole,
|
||||||
|
premiumUser: true,
|
||||||
|
});
|
||||||
|
useOrganizationsMock.mockReturnValue({ data: organizations });
|
||||||
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
|
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -46,6 +61,7 @@ describe("SpendLogsTable network access by role", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
testQueryClient.clear();
|
testQueryClient.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
useOrganizationsMock.mockReturnValue({ data: [] });
|
||||||
fetchMock.mockImplementation(async (url: string) => {
|
fetchMock.mockImplementation(async (url: string) => {
|
||||||
if (String(url).includes("/audit")) {
|
if (String(url).includes("/audit")) {
|
||||||
return jsonResponse(emptyAuditLogs);
|
return jsonResponse(emptyAuditLogs);
|
||||||
|
|
@ -73,6 +89,16 @@ describe("SpendLogsTable network access by role", () => {
|
||||||
expect(requestedUrls().filter((url) => url.includes("/v2/team/list"))).toEqual([]);
|
expect(requestedUrls().filter((url) => url.includes("/v2/team/list"))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches the deleted teams an org admin is entitled to, and still no audit logs", async () => {
|
||||||
|
renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(requestedUrls().some((url) => url.includes("/v2/team/list") && url.includes("status=deleted"))).toBe(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("fetches deleted teams and audit logs for an admin", async () => {
|
it("fetches deleted teams and audit logs for an admin", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderAs("Admin");
|
renderAs("Admin");
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import SpendLogsTable from "./index";
|
import SpendLogsTable from "./index";
|
||||||
import { renderWithProviders } from "../../../tests/test-utils";
|
import { renderWithProviders } from "../../../tests/test-utils";
|
||||||
|
|
||||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
const { useAuthorizedMock, useOrganizationsMock } = vi.hoisted(() => ({
|
||||||
|
useAuthorizedMock: vi.fn(),
|
||||||
|
useOrganizationsMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||||
default: useAuthorizedMock,
|
default: useAuthorizedMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||||
|
useOrganizations: useOrganizationsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./RequestLogsPanel", () => ({
|
vi.mock("./RequestLogsPanel", () => ({
|
||||||
default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) {
|
default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) {
|
||||||
return <div data-testid="request-logs-panel">{isActive ? "active" : "inactive"}</div>;
|
return <div data-testid="request-logs-panel">{isActive ? "active" : "inactive"}</div>;
|
||||||
|
|
@ -42,14 +49,20 @@ const defaultProps = {
|
||||||
premiumUser: false,
|
premiumUser: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAs = (sessionRole: string) => {
|
const ORG_ADMIN_MEMBERSHIPS = [{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "org_admin" }] }];
|
||||||
useAuthorizedMock.mockReturnValue({ userRole: sessionRole });
|
|
||||||
|
const renderAs = (sessionRole: string, organizations: unknown[] = []) => {
|
||||||
|
useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole: sessionRole });
|
||||||
|
useOrganizationsMock.mockReturnValue({ data: organizations });
|
||||||
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
|
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tabNames = () => screen.getAllByRole("tab").map((tab) => tab.textContent);
|
||||||
|
|
||||||
describe("SpendLogsTable", () => {
|
describe("SpendLogsTable", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useAuthorizedMock.mockReturnValue({ userRole: "Admin" });
|
useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole: "Admin" });
|
||||||
|
useOrganizationsMock.mockReturnValue({ data: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the four log tabs", () => {
|
it("renders the four log tabs", () => {
|
||||||
|
|
@ -91,6 +104,44 @@ describe("SpendLogsTable", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("organization admins", () => {
|
||||||
|
it("shows Deleted Teams to an org admin, whose session role reads as a plain internal user", () => {
|
||||||
|
renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS);
|
||||||
|
|
||||||
|
expect(screen.getByRole("tab", { name: "Deleted Teams" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("deleted-teams-page")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hand an org admin the Audit Logs tab, which the backend still refuses them", () => {
|
||||||
|
renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS);
|
||||||
|
|
||||||
|
expect(tabNames()).toEqual(["Request Logs", "Deleted Keys", "Deleted Teams"]);
|
||||||
|
expect(screen.queryByTestId("audit-logs-panel")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an internal user in the same org without an org_admin membership at two tabs", () => {
|
||||||
|
renderAs("Internal User", [
|
||||||
|
{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "internal_user" }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tabNames()).toEqual(["Request Logs", "Deleted Keys"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates the org admin's selected tab rather than the one at the four-tab index", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Deleted Teams" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("tab", { name: "Deleted Teams" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Request Logs" }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("tab index mapping", () => {
|
describe("tab index mapping", () => {
|
||||||
it("activates the panel the admin selected, not the one at the old hardcoded index", async () => {
|
it("activates the panel the admin selected, not the one at the old hardcoded index", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,38 @@ describe("hasCapability", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An org admin is a membership, not a session role, so their JWT reads "Internal User".
|
||||||
|
// Each row was measured on a live proxy with a membership-granted org admin's key.
|
||||||
|
const ORG_ADMIN_BACKEND_ACCESS: ReadonlyArray<readonly [Capability, string, boolean]> = [
|
||||||
|
["viewDeletedTeams", "GET /v2/team/list?status=deleted -> 200 (scoped to their orgs)", true],
|
||||||
|
["viewToolPolicies", "GET /v1/tool/list -> 401", false],
|
||||||
|
["viewPolicies", "GET /policies/list -> 401", false],
|
||||||
|
["viewPrompts", "GET /prompts/list -> 401", false],
|
||||||
|
["viewAuditLogs", "GET /audit -> 401", false],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("hasCapability for organization admins", () => {
|
||||||
|
it.each(ORG_ADMIN_BACKEND_ACCESS)("%s matches the backend: %s", (capability, _endpoint, isEntitled) => {
|
||||||
|
expect(hasCapability("Internal User", capability, true)).toBe(isEntitled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(NON_ADMIN_ROLES)("grants viewDeletedTeams to an org admin whose session role is %s", (role) => {
|
||||||
|
expect(hasCapability(role, "viewDeletedTeams", true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(ADMIN_ONLY_CAPABILITIES)("leaves %s denied when the caller is not an org admin", (capability) => {
|
||||||
|
expect(hasCapability("Internal User", capability, false)).toBe(false);
|
||||||
|
expect(hasCapability("Internal User", capability)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the org-admin allowance opt-in per capability", () => {
|
||||||
|
const orgAdminCapabilities = ADMIN_ONLY_CAPABILITIES.filter((capability) =>
|
||||||
|
hasCapability("Internal User", capability, true),
|
||||||
|
);
|
||||||
|
expect(orgAdminCapabilities).toEqual(["viewDeletedTeams"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("rolesWithCapability", () => {
|
describe("rolesWithCapability", () => {
|
||||||
it("should return a copy so callers cannot mutate the capability map", () => {
|
it("should return a copy so callers cannot mutate the capability map", () => {
|
||||||
const roles = rolesWithCapability("viewToolPolicies");
|
const roles = rolesWithCapability("viewToolPolicies");
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,14 @@ const CAPABILITY_ROLES = {
|
||||||
|
|
||||||
export type Capability = keyof typeof CAPABILITY_ROLES;
|
export type Capability = keyof typeof CAPABILITY_ROLES;
|
||||||
|
|
||||||
export const hasCapability = (userRole: string | null | undefined, capability: Capability): boolean =>
|
const ORG_ADMIN_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>(["viewDeletedTeams"]);
|
||||||
userRole != null && CAPABILITY_ROLES[capability].includes(userRole);
|
|
||||||
|
export const hasCapability = (
|
||||||
|
userRole: string | null | undefined,
|
||||||
|
capability: Capability,
|
||||||
|
isOrgAdmin: boolean = false,
|
||||||
|
): boolean =>
|
||||||
|
(isOrgAdmin && ORG_ADMIN_CAPABILITIES.has(capability)) ||
|
||||||
|
(userRole != null && CAPABILITY_ROLES[capability].includes(userRole));
|
||||||
|
|
||||||
export const rolesWithCapability = (capability: Capability): string[] => [...CAPABILITY_ROLES[capability]];
|
export const rolesWithCapability = (capability: Capability): string[] => [...CAPABILITY_ROLES[capability]];
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
effectiveSessionRole,
|
effectiveSessionRole,
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
|
isOrgAdminForAnyOrg,
|
||||||
|
isOrgAdminSessionRole,
|
||||||
isProxyAdminRole,
|
isProxyAdminRole,
|
||||||
isUserTeamAdminForAnyTeam,
|
isUserTeamAdminForAnyTeam,
|
||||||
isUserTeamAdminForSingleTeam,
|
isUserTeamAdminForSingleTeam,
|
||||||
|
|
@ -9,7 +11,10 @@ import {
|
||||||
rolesAllowedToViewWriteScopedPages,
|
rolesAllowedToViewWriteScopedPages,
|
||||||
rolesWithWriteAccess,
|
rolesWithWriteAccess,
|
||||||
} from "./roles";
|
} from "./roles";
|
||||||
import { Team } from "@/components/networking";
|
import { Organization, Team } from "@/components/networking";
|
||||||
|
|
||||||
|
const orgWithMembers = (members: { user_id: string; user_role: string }[]): Organization =>
|
||||||
|
({ organization_id: "org-1", members }) as unknown as Organization;
|
||||||
|
|
||||||
describe("roles", () => {
|
describe("roles", () => {
|
||||||
describe("isAdminRole", () => {
|
describe("isAdminRole", () => {
|
||||||
|
|
@ -154,6 +159,55 @@ describe("roles", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isOrgAdminForAnyOrg", () => {
|
||||||
|
it("returns true when the user holds an org_admin membership in any organization", () => {
|
||||||
|
const organizations = [
|
||||||
|
orgWithMembers([{ user_id: "user-1", user_role: "internal_user" }]),
|
||||||
|
orgWithMembers([{ user_id: "user-1", user_role: "org_admin" }]),
|
||||||
|
];
|
||||||
|
expect(isOrgAdminForAnyOrg(organizations, "user-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the user is only a plain member", () => {
|
||||||
|
const organizations = [orgWithMembers([{ user_id: "user-1", user_role: "internal_user" }])];
|
||||||
|
expect(isOrgAdminForAnyOrg(organizations, "user-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not credit one user with another user's org_admin membership", () => {
|
||||||
|
const organizations = [orgWithMembers([{ user_id: "user-2", user_role: "org_admin" }])];
|
||||||
|
expect(isOrgAdminForAnyOrg(organizations, "user-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for missing organizations, missing members, or a missing user id", () => {
|
||||||
|
expect(isOrgAdminForAnyOrg(null, "user-1")).toBe(false);
|
||||||
|
expect(isOrgAdminForAnyOrg(undefined, "user-1")).toBe(false);
|
||||||
|
expect(isOrgAdminForAnyOrg([], "user-1")).toBe(false);
|
||||||
|
expect(isOrgAdminForAnyOrg([{ organization_id: "org-1" } as unknown as Organization], "user-1")).toBe(false);
|
||||||
|
expect(isOrgAdminForAnyOrg([orgWithMembers([{ user_id: "user-1", user_role: "org_admin" }])], null)).toBe(false);
|
||||||
|
expect(isOrgAdminForAnyOrg([orgWithMembers([{ user_id: "user-1", user_role: "org_admin" }])], "")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isOrgAdminSessionRole", () => {
|
||||||
|
it("accepts both the raw and the formatted org admin role", () => {
|
||||||
|
expect(isOrgAdminSessionRole("org_admin")).toBe(true);
|
||||||
|
expect(isOrgAdminSessionRole(effectiveSessionRole("org_admin"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for the role a membership-granted org admin actually carries", () => {
|
||||||
|
expect(isOrgAdminSessionRole("Internal User")).toBe(false);
|
||||||
|
expect(isOrgAdminSessionRole("internal_user")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for admin and missing roles", () => {
|
||||||
|
expect(isOrgAdminSessionRole("Admin")).toBe(false);
|
||||||
|
expect(isOrgAdminSessionRole("proxy_admin")).toBe(false);
|
||||||
|
expect(isOrgAdminSessionRole(null)).toBe(false);
|
||||||
|
expect(isOrgAdminSessionRole(undefined)).toBe(false);
|
||||||
|
expect(isOrgAdminSessionRole("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("rolesAllowedToViewWriteScopedPages", () => {
|
describe("rolesAllowedToViewWriteScopedPages", () => {
|
||||||
it("includes Admin Viewer (both display and stored forms)", () => {
|
it("includes Admin Viewer (both display and stored forms)", () => {
|
||||||
// Admin Viewer follows the read-parity rule — they must be able to
|
// Admin Viewer follows the read-parity rule — they must be able to
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import { Member, Team } from "@/components/networking";
|
import { Member, Organization, Team } from "@/components/networking";
|
||||||
|
|
||||||
|
const ORG_ADMIN_MEMBERSHIP_ROLE = "org_admin";
|
||||||
|
|
||||||
|
interface OrganizationMembership {
|
||||||
|
user_id?: string | null;
|
||||||
|
user_role?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Define admin roles and permissions
|
// Define admin roles and permissions
|
||||||
export const old_admin_roles = ["Admin", "Admin Viewer"];
|
export const old_admin_roles = ["Admin", "Admin Viewer"];
|
||||||
|
|
@ -39,6 +46,19 @@ export const isUserTeamAdminForSingleTeam = (teamMemberWithRoles: Member[] | nul
|
||||||
return teamMemberWithRoles.some((member) => member.user_id === userID && member.role === "admin");
|
return teamMemberWithRoles.some((member) => member.user_id === userID && member.role === "admin");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isOrgAdminForAnyOrg = (
|
||||||
|
organizations: Organization[] | null | undefined,
|
||||||
|
userID: string | null | undefined,
|
||||||
|
): boolean => {
|
||||||
|
if (organizations == null || !userID) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return organizations.some((org) => {
|
||||||
|
const members: OrganizationMembership[] = org.members ?? [];
|
||||||
|
return members.some((member) => member.user_id === userID && member.user_role === ORG_ADMIN_MEMBERSHIP_ROLE);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const formatUserRole = (userRole: string): string => {
|
export const formatUserRole = (userRole: string): string => {
|
||||||
if (!userRole) {
|
if (!userRole) {
|
||||||
return "Undefined Role";
|
return "Undefined Role";
|
||||||
|
|
@ -66,6 +86,9 @@ export const formatUserRole = (userRole: string): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isOrgAdminSessionRole = (userRole?: string | null): boolean =>
|
||||||
|
userRole === ORG_ADMIN_MEMBERSHIP_ROLE || userRole === formatUserRole(ORG_ADMIN_MEMBERSHIP_ROLE);
|
||||||
|
|
||||||
const viewOnlyRawRoles = ["proxy_admin_viewer", "internal_user_viewer", "internal_viewer"];
|
const viewOnlyRawRoles = ["proxy_admin_viewer", "internal_user_viewer", "internal_viewer"];
|
||||||
|
|
||||||
export const effectiveSessionRole = (rawUserRole?: string): string => {
|
export const effectiveSessionRole = (rawUserRole?: string): string => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue