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:
Yuneng Jiang 2026-08-10 16:11:05 -07:00
parent f1ed4690bb
commit f306927853
No known key found for this signature in database
12 changed files with 293 additions and 30 deletions

View file

@ -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;

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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";

View file

@ -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"],
internalUserRoles: ["internal"],
rolesWithWriteAccess: ["admin", "internal"],
@ -91,6 +93,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} />);
@ -174,19 +181,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.
@ -199,7 +206,7 @@ describe("Sidebar (leftnav)", () => {
});
it("shows Logs to Admin Viewer", () => {
mockUseAuthorized.mockReturnValueOnce(adminViewerAuth);
mockUseAuthorized.mockReturnValue(adminViewerAuth);
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByText("Logs")).toBeInTheDocument();
});
@ -269,10 +276,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,
@ -280,7 +288,7 @@ describe("Sidebar (leftnav)", () => {
showSSOBanner: false,
});
mockUseOrganizations.mockReturnValueOnce({
mockUseOrganizations.mockReturnValue({
data: [
{
organization_id: "org-1",

View file

@ -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";
@ -414,7 +413,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);
@ -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 filterItemsByRole = (items: MenuItem[]): MenuItem[] => {

View file

@ -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");

View file

@ -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();

View file

@ -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", () => {
it("should return a copy so callers cannot mutate the capability map", () => {
const roles = rolesWithCapability("viewToolPolicies");

View file

@ -12,7 +12,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]];

View file

@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
effectiveSessionRole,
isAdminRole,
isOrgAdminForAnyOrg,
isOrgAdminSessionRole,
isProxyAdminRole,
isUserTeamAdminForAnyTeam,
isUserTeamAdminForSingleTeam,
@ -9,7 +11,10 @@ import {
rolesAllowedToViewWriteScopedPages,
rolesWithWriteAccess,
} 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", () => {
@ -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", () => {
it("includes Admin Viewer (both display and stored forms)", () => {
// Admin Viewer follows the read-parity rule — they must be able to

View file

@ -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 => {