fix(ui): one shared Projects access rule for nav, data and actions

The Projects query skipped global org_admin users, the page-visibility picker
could never offer Projects, and New/Edit showed to users the backend would
reject. Nav and queries now share one rule, Projects is selectable in the
allowlist, New/Edit follow team_admin_editable_team_fields, and the project
modal only lists teams the user administers.
This commit is contained in:
ryan-crabbe-berri 2026-10-02 16:49:19 -07:00
parent 0b4fd60aee
commit ce2f7a60d1
15 changed files with 248 additions and 37 deletions

View file

@ -0,0 +1,46 @@
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { parseTeamAdminEditableFields } from "@/components/team/teamAdminEditAccess";
import { all_admin_roles, internalUserRoles, isAdminRole, isProxyAdminRole } from "@/utils/roles";
const TEAM_ADMIN_PROJECTS_PERMISSION = "projects";
export const projectReaderRoles: readonly string[] = [...all_admin_roles, "Org Admin", ...internalUserRoles];
export const canReadProjects = (userRole: string | null): boolean => projectReaderRoles.includes(userRole ?? "");
export interface ProjectsPageViewer {
readonly userRole: string;
readonly isOrgAdmin: boolean;
readonly isTeamAdmin: boolean;
}
export const canViewProjectsPage = ({ userRole, isOrgAdmin, isTeamAdmin }: ProjectsPageViewer): boolean =>
canReadProjects(userRole) && (isAdminRole(userRole) || isOrgAdmin || isTeamAdmin);
export interface ProjectManager {
readonly userRole: string;
readonly isViewOnly: boolean;
readonly isTeamAdmin: boolean;
readonly teamAdminEditableFields: readonly string[];
}
export const canManageProjects = ({
userRole,
isViewOnly,
isTeamAdmin,
teamAdminEditableFields,
}: ProjectManager): boolean =>
!isViewOnly &&
(isProxyAdminRole(userRole) || (isTeamAdmin && teamAdminEditableFields.includes(TEAM_ADMIN_PROJECTS_PERMISSION)));
export const useCanManageProjects = (isTeamAdmin: boolean): boolean => {
const { userRole, isViewOnly } = useAuthorized();
const { data: uiSettings } = useUISettings();
return canManageProjects({
userRole,
isViewOnly,
isTeamAdmin,
teamAdminEditableFields: parseTeamAdminEditableFields(uiSettings?.values),
});
};

View file

@ -103,8 +103,18 @@ describe("useProjectDetails", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("should not fetch when userRole is not an admin role", () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" });
it.each(["Internal User", "Org Admin"])("should fetch when userRole is %s", async (userRole) => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole });
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(global.fetch).toHaveBeenCalled();
});
it("should not fetch when userRole cannot read projects", () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "regular_user" });
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});

View file

@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { canReadProjects } from "./projectAccess";
import { ProjectResponse, projectKeys } from "./useProjects";
// ── Fetch function ───────────────────────────────────────────────────────────
@ -37,7 +37,7 @@ export const useProjectDetails = (projectId?: string) => {
return useQuery<ProjectResponse>({
queryKey: projectKeys.detail(projectId!),
queryFn: async () => fetchProjectDetails(accessToken!, projectId!),
enabled: Boolean(accessToken && projectId) && all_admin_roles.includes(userRole || ""),
enabled: Boolean(accessToken && projectId) && canReadProjects(userRole),
// Seed from the list cache when available
initialData: () => {

View file

@ -115,8 +115,8 @@ describe("useProjects", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("should fetch when userRole is an internal user role", async () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" });
it.each(["Internal User", "Org Admin"])("should fetch when userRole is %s", async (userRole) => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole });
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects });
const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) });
await waitFor(() => expect(result.current.isSuccess).toBe(true));

View file

@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
import { canReadProjects } from "./projectAccess";
// ── Types ────────────────────────────────────────────────────────────────────
@ -42,8 +42,6 @@ export interface ProjectResponse {
export const projectKeys = createQueryKeys("projects");
const projectReaderRoles = [...all_admin_roles, ...internalUserRoles];
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchProjects = async (accessToken: string): Promise<ProjectResponse[]> => {
@ -76,6 +74,6 @@ export const useProjects = () => {
return useQuery<ProjectResponse[]>({
queryKey: projectKeys.list({}),
queryFn: async () => fetchProjects(accessToken!),
enabled: Boolean(accessToken) && projectReaderRoles.includes(userRole!),
enabled: Boolean(accessToken) && canReadProjects(userRole),
});
};

View file

@ -14,6 +14,16 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeam: (id?: string) => mockUseTeam(id),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockUseUISettings = vi.fn();
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => mockUseUISettings(),
}));
vi.mock("./ProjectModals/EditProjectModal", () => ({
EditProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div data-testid="edit-modal" /> : null),
}));
@ -63,6 +73,8 @@ describe("ProjectDetail", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseTeam.mockReturnValue({ data: undefined, isLoading: false });
mockUseAuthorized.mockReturnValue({ userId: "admin-user", userRole: "Admin", isViewOnly: false });
mockUseUISettings.mockReturnValue({ data: { values: {} } });
});
describe("when loading", () => {
@ -171,6 +183,40 @@ describe("ProjectDetail", () => {
expect(screen.getByTestId("edit-modal")).toBeInTheDocument();
});
it.each([
{ who: "a proxy admin without the setting", role: "Admin", teamRole: "user", fields: [], visible: true },
{
who: "the project team's admin without the setting",
role: "Internal User",
teamRole: "admin",
fields: [],
visible: false,
},
{
who: "the project team's admin when the setting grants projects",
role: "Internal User",
teamRole: "admin",
fields: ["projects"],
visible: true,
},
{
who: "a plain member of the project team when the setting grants projects",
role: "Internal User",
teamRole: "user",
fields: ["projects"],
visible: false,
},
])("should gate 'Edit Project' for $who", ({ role, teamRole, fields, visible }) => {
mockUseAuthorized.mockReturnValue({ userId: "caller", userRole: role, isViewOnly: false });
mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: fields } } });
mockUseTeam.mockReturnValue({
data: { team_id: "team-1", members_with_roles: [{ user_id: "caller", role: teamRole }] },
isLoading: false,
});
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.queryByRole("button", { name: /edit project/i }) !== null).toBe(visible);
});
it("should show 'No team assigned' when the project has no team", () => {
mockUseProjectDetails.mockReturnValue({
data: { ...mockProject, team_id: null },

View file

@ -1,5 +1,8 @@
import { useCanManageProjects } from "@/app/(dashboard)/hooks/projects/projectAccess";
import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { BarChart } from "@/components/shared/charts";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
@ -24,6 +27,10 @@ const utilisationTone = (percent: number) => (percent >= 90 ? "over" : percent >
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { data: project, isLoading } = useProjectDetails(projectId);
const { data: teamInfo } = useTeam(project?.team_id ?? undefined);
const { userId } = useAuthorized();
const canEditProject = useCanManageProjects(
isUserTeamAdminForSingleTeam(teamInfo?.members_with_roles ?? null, userId ?? ""),
);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const spend = project?.spend ?? 0;
@ -87,10 +94,12 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</div>
</div>
</div>
<Button onClick={() => setIsEditModalVisible(true)}>
<EditIcon className="size-4" />
Edit Project
</Button>
{canEditProject && (
<Button onClick={() => setIsEditModalVisible(true)}>
<EditIcon className="size-4" />
Edit Project
</Button>
)}
</div>
<Card className="mb-6">

View file

@ -11,6 +11,11 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => mockUseTeams(),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
vi.mock("@/components/organisms/create_key_button", () => ({
fetchTeamModels: vi.fn().mockResolvedValue([]),
}));
@ -32,6 +37,7 @@ function FormWrapper() {
describe("ProjectBaseForm", () => {
beforeEach(() => {
mockUseTeams.mockReturnValue({ data: [], isLoading: false });
mockUseAuthorized.mockReturnValue({ accessToken: "token", userId: "admin-user", userRole: "Admin" });
});
it("should render", () => {
@ -83,6 +89,32 @@ describe("ProjectBaseForm", () => {
expect(screen.getByText("Sales")).toBeInTheDocument();
});
it("should offer a team admin only the teams they administer", async () => {
const user = userEvent.setup();
mockUseAuthorized.mockReturnValue({ accessToken: "token", userId: "team-admin", userRole: "Internal User" });
mockUseTeams.mockReturnValue({
data: [
{
team_id: "team-1",
team_alias: "Engineering",
models: [],
members_with_roles: [{ user_id: "team-admin", role: "admin" }],
},
{
team_id: "team-2",
team_alias: "Sales",
models: [],
members_with_roles: [{ user_id: "team-admin", role: "user" }],
},
],
isLoading: false,
});
renderWithProviders(<FormWrapper />);
await user.click(screen.getByLabelText("Team"));
expect(await screen.findByText("Engineering")).toBeInTheDocument();
expect(screen.queryByText("Sales")).not.toBeInTheDocument();
});
it("should show the Max Budget field", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByPlaceholderText("0.00")).toBeInTheDocument();

View file

@ -24,6 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { teamsUserCanAssign } from "@/utils/roles";
const toOptionalNumber = (raw: string): number | undefined => {
if (raw.trim() === "") return undefined;
@ -100,7 +101,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
form.setValue("models", []);
};
const teamOptions = (teams ?? []).map((team) => ({
const teamOptions = (teamsUserCanAssign(teams ?? null, userRole, userId) ?? []).map((team) => ({
value: team.team_id,
label: team.team_alias || team.team_id,
sublabel: team.team_id,

View file

@ -15,6 +15,16 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => mockUseTeams(),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockUseUISettings = vi.fn();
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => mockUseUISettings(),
}));
// Stub modals and the detail page to keep tests focused on the list page
vi.mock("./ProjectModals/CreateProjectModal", () => ({
CreateProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div data-testid="create-modal" /> : null),
@ -76,6 +86,8 @@ describe("ProjectsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseTeams.mockReturnValue({ data: [], isLoading: false });
mockUseAuthorized.mockReturnValue({ userId: "admin-user", userRole: "Admin", isViewOnly: false });
mockUseUISettings.mockReturnValue({ data: { values: {} } });
});
it("should render the Projects heading", () => {
@ -92,6 +104,41 @@ describe("ProjectsPage", () => {
expect(screen.getByRole("button", { name: /create project/i })).toBeInTheDocument();
});
it.each([
{ who: "a proxy admin without the setting", role: "Admin", isViewOnly: false, fields: [], visible: true },
{ who: "a proxy admin viewer", role: "Admin", isViewOnly: true, fields: ["projects"], visible: false },
{ who: "a team admin without the setting", role: "Internal User", isViewOnly: false, fields: [], visible: false },
{
who: "a team admin when the setting grants projects",
role: "Internal User",
isViewOnly: false,
fields: ["projects"],
visible: true,
},
])("should gate 'Create Project' for $who", ({ role, isViewOnly, fields, visible }) => {
mockUseAuthorized.mockReturnValue({ userId: "team-admin", userRole: role, isViewOnly });
mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: fields } } });
mockUseTeams.mockReturnValue({
data: [{ team_id: "team-1", members_with_roles: [{ user_id: "team-admin", role: "admin" }] }],
isLoading: false,
});
mockUseProjects.mockReturnValue({ data: [], isLoading: false });
renderWithProviders(<ProjectsPage />);
expect(screen.queryByRole("button", { name: /create project/i }) !== null).toBe(visible);
});
it("should hide 'Create Project' from a team member who administers no team even when the setting grants projects", () => {
mockUseAuthorized.mockReturnValue({ userId: "member", userRole: "Internal User", isViewOnly: false });
mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: ["projects"] } } });
mockUseTeams.mockReturnValue({
data: [{ team_id: "team-1", members_with_roles: [{ user_id: "member", role: "user" }] }],
isLoading: false,
});
mockUseProjects.mockReturnValue({ data: [], isLoading: false });
renderWithProviders(<ProjectsPage />);
expect(screen.queryByRole("button", { name: /create project/i })).not.toBeInTheDocument();
});
it("should render the projects table", () => {
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />);

View file

@ -1,5 +1,8 @@
import { useCanManageProjects } from "@/app/(dashboard)/hooks/projects/projectAccess";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { Folder, Plus, SearchIcon, X } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import { useMemo, useState } from "react";
@ -14,6 +17,8 @@ import { useClearProjectKeysTableState, useProjectsTableState } from "./useProje
export function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
const { userId } = useAuthorized();
const canCreateProject = useCanManageProjects(isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""));
const [selectedProjectId, setSelectedProjectId] = useQueryState(
"project",
@ -62,10 +67,12 @@ export function ProjectsPage() {
title="Projects"
subtitle="Manage projects within your teams"
primaryAction={
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Project
</Button>
canCreateProject && (
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Project
</Button>
)
}
/>

View file

@ -625,7 +625,10 @@ describe("Sidebar (leftnav)", () => {
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
});
it("applies the internal-user page allowlist to Projects for team admins", () => {
it.each([
{ allowlist: ["teams"], visible: false },
{ allowlist: ["teams", "projects"], visible: true },
])("applies the internal-user page allowlist $allowlist to Projects for team admins", ({ allowlist, visible }) => {
mockUseAuthorized.mockReturnValue(teamAdminAuthorization);
mockUseTeams.mockReturnValue({
data: [
@ -637,9 +640,17 @@ describe("Sidebar (leftnav)", () => {
error: null,
});
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI enabledPagesInternalUsers={["teams"]} />);
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI enabledPagesInternalUsers={allowlist} />);
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /Projects/ }) !== null).toBe(visible);
});
it("shows Projects to a user whose global role is org admin", () => {
mockUseAuthorized.mockReturnValue({ ...teamMemberAuthorization, userRole: "Org Admin" });
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
expect(screen.getByRole("link", { name: /Projects/ })).toHaveAttribute("href", "/ui/projects");
});
it("marks the nav item for the current route active", () => {

View file

@ -1,3 +1,4 @@
import { canViewProjectsPage, projectReaderRoles } from "@/app/(dashboard)/hooks/projects/projectAccess";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
@ -275,7 +276,7 @@ const menuGroups: MenuGroup[] = [
</span>
),
icon: <Folder {...ICON} />,
roles: all_admin_roles,
roles: [...projectReaderRoles],
},
{ key: "users", page: "users", label: "Internal Users", icon: <User {...ICON} />, roles: all_admin_roles },
{
@ -439,7 +440,7 @@ const prettify = (key: string): string =>
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key));
export const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key));
// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config.
export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => {
@ -512,13 +513,11 @@ const Sidebar_: React.FC<SidebarProps> = ({
if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
return true;
}
if (item.key === "projects") {
if (!enableProjectsUI) return false;
const hasRoleAccess = isAdmin || isOrgAdmin || isTeamAdmin;
if (!hasRoleAccess) return false;
if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
return true;
}
if (
item.key === "projects" &&
!(enableProjectsUI && canViewProjectsPage({ userRole, isOrgAdmin, isTeamAdmin }))
)
return false;
if (
!isAdmin &&
item.key === "agents" &&

View file

@ -195,6 +195,13 @@ describe("Page Utils - LeftNav Sync", () => {
expect(pageKeys.length, "All page keys should be unique (no duplicates)").toBe(uniquePageKeys.size);
});
it("offers Projects in the internal-user page picker so team admins can be granted it", () => {
expect(getAvailablePages().find((page) => page.page === "projects")).toMatchObject({
label: "Projects",
group: "ACCESS CONTROL",
});
});
it("should match the structure expected by PageVisibilitySettings component", () => {
const availablePages = getAvailablePages();

View file

@ -2,7 +2,7 @@
* Utility functions for working with navigation pages
*/
import { menuGroups } from "./leftnav";
import { labelText, menuGroups } from "./leftnav";
import { pageDescriptions, PageMetadata } from "./page_metadata";
import { internalUserRoles } from "@/utils/roles";
@ -43,10 +43,9 @@ export const getAvailablePages = (): PageMetadata[] => {
item.page !== "settings" &&
isPageAccessibleToInternalUsers(item.roles)
) {
const label = typeof item.label === "string" ? item.label : item.key;
pages.push({
page: item.page,
label: label,
label: labelText(item),
group: group.groupLabel,
description: pageDescriptions[item.page] || "No description available",
});
@ -54,14 +53,13 @@ export const getAvailablePages = (): PageMetadata[] => {
// Add children items (also skip those internal users cannot access)
if (item.children) {
const parentLabel = typeof item.label === "string" ? item.label : item.key;
const parentLabel = labelText(item);
item.children.forEach((child) => {
// Include if internal users can access
if (isPageAccessibleToInternalUsers(child.roles)) {
const childLabel = typeof child.label === "string" ? child.label : child.key;
pages.push({
page: child.page,
label: childLabel,
label: labelText(child),
group: `${group.groupLabel} > ${parentLabel}`,
description: pageDescriptions[child.page] || "No description available",
});