fix(projects): show Projects to team and org admins and scope it correctly (#41325)

* fix(ui): show Projects nav to team and org admins

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): satisfy frontend lint budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(ui): format leftnav regression tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(projects): let org admins see projects of every team in their orgs

/project/list and /project/info only knew proxy admins and team members, so an
org admin saw projects only for teams they had personally joined. Both now use
the same team access check as /team/info.

* 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.

* fix(projects): record IN-list bounds for project visibility filters

Both filters are bounded by one caller's team memberships and admin orgs. Also
cover a project whose team was deleted.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
This commit is contained in:
devin-ai-integration[bot] 2026-10-06 14:33:31 -07:00 • committed by GitHub
parent 53eaa0c357
commit 3fec4705d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 616 additions and 60 deletions

View file

@ -22,7 +22,9 @@ from litellm._uuid import uuid
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import delete_cached_project_object
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management.teams.authz import is_team_admin
from litellm.proxy.management.teams.authz import TEAM_OR_ORG_ADMIN, TeamAccess, is_team_admin, is_team_member
from litellm.proxy.management.teams.dependencies import get_team_access
from litellm.proxy.management.users.service import org_admin_org_ids
from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field
from litellm.proxy.management_endpoints.team_admin_field_permissions import team_admin_may_manage_projects
from litellm.proxy.management_helpers.utils import (
@ -118,6 +120,37 @@ async def _check_user_permission_for_project(
return is_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or [])
async def _can_view_team_projects(
user_api_key_dict: UserAPIKeyAuth,
team_id: str | None,
prisma_client: PrismaClient,
team_access: TeamAccess,
) -> bool:
if user_api_key_has_admin_view(user_api_key_dict):
return True
if not team_id or not user_api_key_dict.user_id:
return False
team_row: Final = await _team_table(prisma_client).find_unique(where={"team_id": team_id})
if team_row is None:
return False
team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump())
return is_team_member(user_api_key_dict, team) or await team_access.allows(
user_api_key_dict, team, TEAM_OR_ORG_ADMIN
)
def _visible_projects_where(team_ids: list[str], admin_org_ids: frozenset[str]) -> dict[str, object]:
# bounded-ok: one caller's team memberships
member_scope: Final[dict[str, object]] = {"team_id": {"in": team_ids}}
if not admin_org_ids:
return member_scope
org_scope: Final[dict[str, object]] = {
# bounded-ok: orgs one caller administers
"litellm_team_table": {"is": {"organization_id": {"in": sorted(admin_org_ids)}}}
}
return {"OR": [member_scope, org_scope]}
async def _validate_team_exists(
team_id: str,
prisma_client: PrismaClient,
@ -973,6 +1006,7 @@ async def delete_project(
async def project_info(
project_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
team_access: TeamAccess = Depends(get_team_access),
):
"""
Get information about a specific project
@ -1009,21 +1043,7 @@ async def project_info(
param="project_id",
)
# Check if user has access to this project (admin or team member)
is_admin = user_api_key_has_admin_view(user_api_key_dict)
is_team_member = False
if project.team_id and user_api_key_dict.user_id:
team = await _team_table(prisma_client).find_unique(where={"team_id": project.team_id})
if team:
caller_user_id = user_api_key_dict.user_id
for m in team.members_with_roles or []:
m_user_id = m.get("user_id") if isinstance(m, dict) else getattr(m, "user_id", None)
if m_user_id == caller_user_id:
is_team_member = True
break
if not (is_admin or is_team_member):
if not await _can_view_team_projects(user_api_key_dict, project.team_id, prisma_client, team_access):
raise HTTPException(
status_code=403,
detail={"error": "You don't have access to this project"},
@ -1072,16 +1092,13 @@ async def list_projects(
include={"litellm_budget_table": True, "object_permission": True}
)
else:
# Look up the user's team memberships via the reverse-index on
# LiteLLM_UserTable.teams (maintained by team_member_add alongside
# members_with_roles). This avoids a full scan of all team rows.
user_record: Final = await _user_table(prisma_client).find_unique(
where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
)
user_team_ids: list[str] = user_record.teams if user_record is not None and user_record.teams else []
user: Final = None if user_record is None else LiteLLM_UserTable.model_validate(user_record.model_dump())
projects = await _project_table(prisma_client).find_many(
where={"team_id": {"in": user_team_ids}},
where=_visible_projects_where(user.teams if user is not None else [], org_admin_org_ids(user)),
include={"litellm_budget_table": True, "object_permission": True},
)

View file

@ -44,6 +44,12 @@ class TeamAccess:
return await self.org_roles.is_org_admin(caller.user_id, team.organization_id)
def is_team_member(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
return user_api_key_dict.user_id is not None and any(
member.user_id == user_api_key_dict.user_id for member in team_obj.members_with_roles
)
def is_team_admin(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
return any(
member.user_id is not None and member.user_id == user_api_key_dict.user_id and member.role == "admin"

View file

@ -10,13 +10,20 @@ if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient, ProxyLogging
def holds_org_admin(user: LiteLLM_UserTable | None, organization_id: str) -> bool:
return user is not None and any(
membership.organization_id == organization_id and membership.user_role == LitellmUserRoles.ORG_ADMIN.value
def org_admin_org_ids(user: LiteLLM_UserTable | None) -> frozenset[str]:
if user is None:
return frozenset()
return frozenset(
membership.organization_id
for membership in user.organization_memberships or []
if membership.user_role == LitellmUserRoles.ORG_ADMIN.value
)
def holds_org_admin(user: LiteLLM_UserTable | None, organization_id: str) -> bool:
return organization_id in org_admin_org_ids(user)
@dataclass(frozen=True, slots=True)
class PrismaOrgRoles:
prisma_client: PrismaClient | None

View file

@ -1,7 +1,6 @@
# Grandfathered findings of check_unbounded_in_lists.py: path::scope::kind::subject::occurrence.
# Fix a site and delete its line; regenerate with `check_unbounded_in_lists.py --update-baseline`.
enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py CheckResponsesCost.check_responses_cost prisma id.in `[job.id for job in completed_jobs]` 0
enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py list_projects prisma team_id.in `user_team_ids` 0
litellm/integrations/shadow_eval_logger.py ShadowEvalLogger._active_jobs prisma job_id.in `[str(record.id) for record in records]` 0
litellm/llms/litellm_proxy/skills/handler.py LiteLLMSkillsHandler.list_skills prisma created_by.in `owner_scopes` 0
litellm/proxy/_experimental/mcp_server/db.py get_mcp_servers prisma server_id.in `server_ids` 0

View file

@ -1,5 +1,9 @@
import os
import traceback
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Final
from litellm._uuid import uuid
from unittest import mock
@ -37,8 +41,14 @@ from litellm.proxy._types import (
DeleteProjectRequest,
NewTeamRequest,
UserAPIKeyAuth,
LiteLLM_OrganizationMembershipTable,
LiteLLM_TeamTable,
LiteLLM_UserTable,
Member,
ProxyException,
)
from litellm.proxy.management.teams.authz import TeamAccess
from litellm.proxy.management.users.service import PrismaOrgRoles
from tests._master_key import MASTER_KEY
proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
@ -1381,3 +1391,208 @@ async def test_new_project_flag_on_access_group_model_returns_400(monkeypatch):
assert "prod-models" in str(exc_info.value)
assert "expand to multiple models at request time" in str(exc_info.value)
_ACCESS_NOW: Final = datetime.now(timezone.utc)
@dataclass(frozen=True, slots=True)
class _ProjectRow:
project_id: str
team_id: str | None
def _membership(user_id: str, org_id: str, role: LitellmUserRoles) -> LiteLLM_OrganizationMembershipTable:
return LiteLLM_OrganizationMembershipTable(
user_id=user_id, organization_id=org_id, user_role=role.value, created_at=_ACCESS_NOW, updated_at=_ACCESS_NOW
)
def _user(user_id: str, teams: tuple[str, ...] = (), admin_of: tuple[str, ...] = ()) -> LiteLLM_UserTable:
return LiteLLM_UserTable(
user_id=user_id,
teams=list(teams),
organization_memberships=[_membership(user_id, "org-a", LitellmUserRoles.INTERNAL_USER)]
+ [_membership(user_id, org_id, LitellmUserRoles.ORG_ADMIN) for org_id in admin_of],
)
_TEAMS: Final = {
team.team_id: team
for team in (
LiteLLM_TeamTable(
team_id="team-a1",
organization_id="org-a",
members_with_roles=[Member(user_id="member", role="user"), Member(user_id="team-admin", role="admin")],
),
LiteLLM_TeamTable(team_id="team-a2", organization_id="org-a"),
LiteLLM_TeamTable(
team_id="team-b1",
organization_id="org-b",
members_with_roles=[Member(user_id="org-admin-a-member-b1", role="user")],
),
LiteLLM_TeamTable(team_id="team-orgless"),
)
}
_PROJECTS: Final = (
_ProjectRow("p-a1", "team-a1"),
_ProjectRow("p-a2", "team-a2"),
_ProjectRow("p-b1", "team-b1"),
_ProjectRow("p-orgless", "team-orgless"),
_ProjectRow("p-teamless", None),
_ProjectRow("p-deleted-team", "team-deleted"),
)
_USERS: Final = {
user.user_id: user
for user in (
_user("member", teams=("team-a1",)),
_user("team-admin", teams=("team-a1",)),
_user("org-admin-a", admin_of=("org-a",)),
_user("org-admin-b", admin_of=("org-b",)),
_user("org-admin-a-member-b1", teams=("team-b1",), admin_of=("org-a",)),
)
}
def _row_matches(row: object, where: Mapping[str, object]) -> bool:
return all(_condition_holds(row, key, condition) for key, condition in where.items())
def _condition_holds(row: object, key: str, condition) -> bool:
if key == "OR":
return any(_row_matches(row, branch) for branch in condition)
if key == "litellm_team_table":
team = _TEAMS.get(getattr(row, "team_id"))
return team is not None and _row_matches(team, condition["is"])
value = getattr(row, key)
return value in condition["in"] if isinstance(condition, dict) else value == condition
class _FakeTeamTable:
async def find_unique(self, where: Mapping[str, str], include: object = None) -> LiteLLM_TeamTable | None:
return _TEAMS.get(where["team_id"])
class _FakeProjectTable:
async def find_unique(self, where: Mapping[str, str], include: object = None) -> _ProjectRow | None:
return next((p for p in _PROJECTS if p.project_id == where["project_id"]), None)
async def find_many(self, where: Mapping[str, object] | None = None, include: object = None) -> list[_ProjectRow]:
return [p for p in _PROJECTS if where is None or _row_matches(p, where)]
class _FakeUserTable:
async def find_unique(
self, where: Mapping[str, str], include: Mapping[str, bool] | None = None
) -> LiteLLM_UserTable | None:
user = _USERS.get(where["user_id"])
if user is None or (include or {}).get("organization_memberships"):
return user
return user.model_copy(update={"organization_memberships": None})
@pytest.fixture
def project_access_db(monkeypatch):
db = mock.MagicMock(
litellm_teamtable=_FakeTeamTable(), litellm_projecttable=_FakeProjectTable(), litellm_usertable=_FakeUserTable()
)
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock.MagicMock(db=db))
async def _team_access_over_cached_users() -> TeamAccess:
cache = UserApiKeyCache()
for user in _USERS.values():
await cache.async_set_cache(key=user.user_id, value=user)
return TeamAccess(org_roles=PrismaOrgRoles(None, cache, proxy_logging_obj))
def _caller(user_id: str, role: LitellmUserRoles = LitellmUserRoles.INTERNAL_USER) -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_role=role, api_key="sk-caller", user_id=user_id)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("user_id", "expected"),
[
("member", {"p-a1"}),
("team-admin", {"p-a1"}),
("org-admin-a", {"p-a1", "p-a2"}),
("org-admin-b", {"p-b1"}),
("org-admin-a-member-b1", {"p-a1", "p-a2", "p-b1"}),
("unknown-user", set()),
],
)
async def test_list_projects_scopes_to_teams_the_caller_can_view(project_access_db, user_id, expected):
from litellm_enterprise.proxy.management_endpoints.project_endpoints import list_projects
projects = await list_projects(user_api_key_dict=_caller(user_id))
assert {p.project_id for p in projects} == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
async def test_list_projects_admin_view_sees_every_project(project_access_db, role):
from litellm_enterprise.proxy.management_endpoints.project_endpoints import list_projects
projects = await list_projects(user_api_key_dict=_caller("someone", role))
assert {p.project_id for p in projects} == {p.project_id for p in _PROJECTS}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("user_id", "project_id"),
[
("member", "p-a1"),
("team-admin", "p-a1"),
("org-admin-a", "p-a1"),
("org-admin-a", "p-a2"),
("org-admin-a-member-b1", "p-b1"),
],
)
async def test_project_info_allows_team_members_and_org_admins_of_the_team_org(project_access_db, user_id, project_id):
project = await project_info(
project_id=project_id,
user_api_key_dict=_caller(user_id),
team_access=await _team_access_over_cached_users(),
)
assert project.project_id == project_id
@pytest.mark.asyncio
@pytest.mark.parametrize(
("user_id", "project_id"),
[
("member", "p-a2"),
("team-admin", "p-b1"),
("org-admin-b", "p-a2"),
("org-admin-a", "p-b1"),
("org-admin-a", "p-orgless"),
("org-admin-a", "p-teamless"),
("member", "p-deleted-team"),
],
)
async def test_project_info_denies_callers_who_cannot_view_the_team(project_access_db, user_id, project_id):
with pytest.raises(ProxyException) as exc_info:
await project_info(
project_id=project_id,
user_api_key_dict=_caller(user_id),
team_access=await _team_access_over_cached_users(),
)
assert str(exc_info.value.code) == "403"
assert "You don't have access to this project" in exc_info.value.message
@pytest.mark.asyncio
async def test_project_info_missing_project_is_404_even_for_org_admins(project_access_db):
with pytest.raises(ProxyException) as exc_info:
await project_info(
project_id="p-missing",
user_api_key_dict=_caller("org-admin-a"),
team_access=await _team_access_over_cached_users(),
)
assert str(exc_info.value.code) == "404"

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,6 +1,9 @@
import { useCanManageProjects } from "@/app/(dashboard)/hooks/projects/projectAccess";
import { Page, PageContent } from "@/components/shared/Page";
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";
@ -15,6 +18,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",
@ -64,12 +69,14 @@ export function ProjectsPage() {
Projects
</PageHeaderTitle>
<PageHeaderDescription>Manage projects within your teams</PageHeaderDescription>
<PageHeaderControls>
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Project
</Button>
</PageHeaderControls>
{canCreateProject && (
<PageHeaderControls>
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Project
</Button>
</PageHeaderControls>
)}
</PageHeader>
<PageContent className="gap-3">

View file

@ -13,7 +13,7 @@ vi.mock("../utils/roles", async (importOriginal) => {
rolesWithWriteAccess: ["admin", "internal"],
rolesAllowedToViewWriteScopedPages: ["admin", "internal", "admin_viewer"],
isAdminRole: (role: string) => role === "admin" || role === "admin_viewer",
isUserTeamAdminForAnyTeam: () => false,
isUserTeamAdminForAnyTeam: actual.isUserTeamAdminForAnyTeam,
};
});
@ -24,7 +24,7 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => {
const { mockUseAuthorized, mockUseOrganizations, mockUseTeams } = vi.hoisted(() => {
const mockUseAuthorized = vi.fn(() => ({
userId: "test-user-id",
accessToken: "test-access-token",
@ -43,7 +43,13 @@ const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => {
error: null,
}));
return { mockUseAuthorized, mockUseOrganizations };
const mockUseTeams = vi.fn(() => ({
data: [] as Array<{ members_with_roles: Array<{ user_id: string; role: string }> }>,
isLoading: false,
error: null,
}));
return { mockUseAuthorized, mockUseOrganizations, mockUseTeams };
});
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
@ -55,7 +61,7 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => ({ data: [], isLoading: false, error: null }),
useTeams: mockUseTeams,
}));
vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => {
@ -103,6 +109,30 @@ const placementsOf = (page: string): string[] =>
),
]);
const teamAdminAuthorization = {
userId: "team-admin-user-id",
accessToken: "test-access-token",
userRole: "internal",
isViewOnly: false,
token: "test-token",
userEmail: "teamadmin@example.com",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
};
const teamMemberAuthorization = {
userId: "team-member-user-id",
accessToken: "test-access-token",
userRole: "internal",
isViewOnly: false,
token: "test-token",
userEmail: "teamuser@example.com",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
};
describe("Sidebar (leftnav)", () => {
const defaultProps = {
collapsed: false,
@ -111,6 +141,12 @@ describe("Sidebar (leftnav)", () => {
afterEach(() => {
mockUseAuthorized.mockReset();
mockUseOrganizations.mockReset();
mockUseTeams.mockReset();
mockUseTeams.mockReturnValue({
data: [],
isLoading: false,
error: null,
});
mockUseThemeImpl = unbrandedTheme;
navState.pathname = "/ui/api-keys";
});
@ -547,6 +583,76 @@ describe("Sidebar (leftnav)", () => {
expect(screen.getByText("Organizations")).toBeInTheDocument();
});
it("shows Projects to an internal user who administers a team", () => {
mockUseAuthorized.mockReturnValue(teamAdminAuthorization);
mockUseTeams.mockReturnValue({
data: [
{
members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }],
},
],
isLoading: false,
error: null,
});
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
expect(screen.getByRole("link", { name: /Projects/ })).toBeInTheDocument();
});
it("hides Projects when the feature flag is disabled for a team admin", () => {
mockUseAuthorized.mockReturnValue(teamAdminAuthorization);
mockUseTeams.mockReturnValue({
data: [
{
members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }],
},
],
isLoading: false,
error: null,
});
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI={false} />);
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
});
it("hides Projects from an internal user who is not a team admin", () => {
mockUseAuthorized.mockReturnValue(teamMemberAuthorization);
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
});
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: [
{
members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }],
},
],
isLoading: false,
error: null,
});
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI enabledPagesInternalUsers={allowlist} />);
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", () => {
navState.pathname = "/ui/logs";
renderWithProviders(<Sidebar {...defaultProps} />);

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";
@ -263,7 +264,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 },
{
@ -427,7 +428,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 } => {
@ -500,7 +501,11 @@ const Sidebar_: React.FC<SidebarProps> = ({
if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
return true;
}
if (item.key === "projects" && !enableProjectsUI) return false;
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",
});