mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36478 from BerriAI/litellm_/vibrant-booth-d4258b
fix(ui): restore the Logs Deleted Teams tab for organization admins
This commit is contained in:
commit
80f34cb6fc
12 changed files with 298 additions and 30 deletions
|
|
@ -3,10 +3,12 @@
|
|||
import { hasCapability, type Capability } from "@/utils/capabilities";
|
||||
|
||||
import useAuthorized from "./useAuthorized";
|
||||
import useIsOrgAdmin from "./useIsOrgAdmin";
|
||||
|
||||
const useCan = (capability: Capability): boolean => {
|
||||
const { userRole } = useAuthorized();
|
||||
return hasCapability(userRole, capability);
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
return hasCapability(userRole, capability, isOrgAdmin);
|
||||
};
|
||||
|
||||
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 ChatUI from "./ChatUI";
|
||||
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 Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
|
||||
|
||||
vi.mock("../utils/roles", () => {
|
||||
vi.mock("../utils/roles", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/roles")>();
|
||||
return {
|
||||
...actual,
|
||||
all_admin_roles: ["admin", "admin_viewer"],
|
||||
old_admin_roles: ["admin", "admin_viewer"],
|
||||
internalUserRoles: ["internal"],
|
||||
|
|
@ -92,6 +94,11 @@ describe("Sidebar (leftnav)", () => {
|
|||
collapsed: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mockUseAuthorized.mockReset();
|
||||
mockUseOrganizations.mockReset();
|
||||
});
|
||||
|
||||
it("should link the logo to the UI home route rather than the proxy origin", () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
|
|
@ -175,19 +182,19 @@ describe("Sidebar (leftnav)", () => {
|
|||
};
|
||||
|
||||
it("hides Playground from Admin Viewer (cost-incurring action)", () => {
|
||||
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
|
||||
mockUseAuthorized.mockReturnValue(adminViewerAuth);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
expect(screen.queryByText("Playground")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Models + Endpoints to Admin Viewer (read-only)", () => {
|
||||
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
|
||||
mockUseAuthorized.mockReturnValue(adminViewerAuth);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
expect(screen.getByText("Models + Endpoints")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Agents (under Agentic) to Admin Viewer (read-only)", async () => {
|
||||
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
|
||||
mockUseAuthorized.mockReturnValue(adminViewerAuth);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
// Agents is now nested under the "Agentic" submenu — expand parent
|
||||
// first to render the children, then assert Agents is visible.
|
||||
|
|
@ -200,7 +207,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
});
|
||||
|
||||
it("shows Logs to Admin Viewer", () => {
|
||||
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
|
||||
mockUseAuthorized.mockReturnValue(adminViewerAuth);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -384,10 +391,11 @@ describe("Sidebar (leftnav)", () => {
|
|||
});
|
||||
|
||||
it("should show Organizations tab for organization admins", () => {
|
||||
mockUseAuthorized.mockReturnValueOnce({
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
userId: "org-admin-user-id",
|
||||
accessToken: "test-access-token",
|
||||
userRole: "viewer",
|
||||
isViewOnly: false,
|
||||
token: "test-token",
|
||||
userEmail: "orgadmin@example.com",
|
||||
premiumUser: false,
|
||||
|
|
@ -395,7 +403,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
showSSOBanner: false,
|
||||
});
|
||||
|
||||
mockUseOrganizations.mockReturnValueOnce({
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
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 useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
|
|
@ -75,7 +75,6 @@ import {
|
|||
} from "../utils/roles";
|
||||
import BetaBadge from "./BetaBadge";
|
||||
import NewBadge from "./common_components/NewBadge";
|
||||
import type { Organization } from "./networking";
|
||||
import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu";
|
||||
import SidebarUsageCard from "./SidebarUsageCard";
|
||||
import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages";
|
||||
|
|
@ -432,7 +431,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
allowVectorStoresForTeamAdmins,
|
||||
}) => {
|
||||
const { userId, accessToken, userRole, isViewOnly } = useAuthorized();
|
||||
const { data: organizations } = useOrganizations();
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const { data: teams } = useTeams();
|
||||
const { logoUrl } = useTheme();
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
|
|
@ -459,13 +458,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 filterItemsByRole = (items: MenuItem[]): MenuItem[] => {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import SpendLogsTable from "./index";
|
||||
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", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: useOrganizationsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./RequestLogsPanel", () => ({
|
||||
default: function RequestLogsPanelMock() {
|
||||
return <div data-testid="request-logs-panel" />;
|
||||
|
|
@ -37,8 +44,16 @@ const defaultProps = {
|
|||
premiumUser: true,
|
||||
};
|
||||
|
||||
const renderAs = (sessionRole: string) => {
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userRole: sessionRole, premiumUser: true });
|
||||
const ORG_ADMIN_MEMBERSHIPS = [{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "org_admin" }] }];
|
||||
|
||||
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} />);
|
||||
};
|
||||
|
||||
|
|
@ -46,6 +61,7 @@ describe("SpendLogsTable network access by role", () => {
|
|||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
useOrganizationsMock.mockReturnValue({ data: [] });
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (String(url).includes("/audit")) {
|
||||
return jsonResponse(emptyAuditLogs);
|
||||
|
|
@ -73,6 +89,16 @@ describe("SpendLogsTable network access by role", () => {
|
|||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
renderAs("Admin");
|
||||
|
|
|
|||
|
|
@ -4,12 +4,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import SpendLogsTable from "./index";
|
||||
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", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: useOrganizationsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./RequestLogsPanel", () => ({
|
||||
default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) {
|
||||
return <div data-testid="request-logs-panel">{isActive ? "active" : "inactive"}</div>;
|
||||
|
|
@ -42,14 +49,20 @@ const defaultProps = {
|
|||
premiumUser: false,
|
||||
};
|
||||
|
||||
const renderAs = (sessionRole: string) => {
|
||||
useAuthorizedMock.mockReturnValue({ userRole: sessionRole });
|
||||
const ORG_ADMIN_MEMBERSHIPS = [{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "org_admin" }] }];
|
||||
|
||||
const renderAs = (sessionRole: string, organizations: unknown[] = []) => {
|
||||
useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole: sessionRole });
|
||||
useOrganizationsMock.mockReturnValue({ data: organizations });
|
||||
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
|
||||
};
|
||||
|
||||
const tabNames = () => screen.getAllByRole("tab").map((tab) => tab.textContent);
|
||||
|
||||
describe("SpendLogsTable", () => {
|
||||
beforeEach(() => {
|
||||
useAuthorizedMock.mockReturnValue({ userRole: "Admin" });
|
||||
useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole: "Admin" });
|
||||
useOrganizationsMock.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("activates the panel the admin selected, not the one at the old hardcoded index", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
|
|
|||
|
|
@ -45,6 +45,43 @@ describe("hasCapability", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES = "Internal User";
|
||||
|
||||
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(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, 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(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability, false)).toBe(false);
|
||||
expect(hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the org-admin allowance opt-in per capability", () => {
|
||||
const orgAdminCapabilities = ADMIN_ONLY_CAPABILITIES.filter((capability) =>
|
||||
hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability, true),
|
||||
);
|
||||
expect(orgAdminCapabilities).toEqual(["viewDeletedTeams"]);
|
||||
});
|
||||
|
||||
it("does not let the org-admin allowance reopen the proxy-admin-only viewGlobalSpend gate", () => {
|
||||
expect(hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, "viewGlobalSpend", true)).toBe(false);
|
||||
expect(hasCapability("Org Admin", "viewGlobalSpend", true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCapability - viewGlobalSpend", () => {
|
||||
it.each(ADMIN_ROLES)("should grant it to %s", (role) => {
|
||||
expect(hasCapability(role, "viewGlobalSpend")).toBe(true);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,14 @@ const CAPABILITY_ROLES = {
|
|||
|
||||
export type Capability = keyof typeof CAPABILITY_ROLES;
|
||||
|
||||
export const hasCapability = (userRole: string | null | undefined, capability: Capability): boolean =>
|
||||
userRole != null && CAPABILITY_ROLES[capability].includes(userRole);
|
||||
const ORG_ADMIN_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>(["viewDeletedTeams"]);
|
||||
|
||||
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]];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
all_admin_roles,
|
||||
effectiveSessionRole,
|
||||
isAdminRole,
|
||||
isOrgAdminForAnyOrg,
|
||||
isOrgAdminSessionRole,
|
||||
isProxyAdminRole,
|
||||
isUserTeamAdminForAnyTeam,
|
||||
isUserTeamAdminForSingleTeam,
|
||||
|
|
@ -11,7 +13,10 @@ import {
|
|||
rolesWithWriteAccess,
|
||||
teamListScopeUserId,
|
||||
} 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("isAdminRole", () => {
|
||||
|
|
@ -156,6 +161,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", () => {
|
||||
it("includes Admin Viewer (both display and stored forms)", () => {
|
||||
// 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
|
||||
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");
|
||||
};
|
||||
|
||||
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 => {
|
||||
if (!userRole) {
|
||||
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"];
|
||||
|
||||
export const effectiveSessionRole = (rawUserRole?: string): string => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue