mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
53eaa0c357
commit
3fec4705d7
20 changed files with 616 additions and 60 deletions
|
|
@ -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},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue