mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(proxy): report the caller's team edit access on /team/info
The dashboard gated the team settings form on a role it guessed from the is_* props, the members list and the org list. The org list is premium gated and empty while loading, so a team admin who is also an org admin was told team admins cannot edit, although /team/update accepts them as an org admin /team/info now returns caller_edit_access, resolved by the same helper /team/update uses, and TeamInfo keys the form and the toast off that field. The org list is only read for the organization dropdown now, and general_settings is read through one validated accessor in both handlers
This commit is contained in:
parent
50f890d02d
commit
66519da9b6
7 changed files with 196 additions and 79 deletions
|
|
@ -4470,6 +4470,29 @@ class TeamInfoMember(Member):
|
|||
user_alias: str | None = None
|
||||
|
||||
|
||||
class TeamEditUnrestricted(BaseModel):
|
||||
kind: Literal["unrestricted"] = "unrestricted"
|
||||
|
||||
|
||||
class TeamEditAsTeamAdmin(BaseModel):
|
||||
kind: Literal["team_admin"] = "team_admin"
|
||||
editable_fields: tuple[str, ...]
|
||||
|
||||
|
||||
class TeamEditAsTeamAdminDisabled(BaseModel):
|
||||
kind: Literal["team_admin_disabled"] = "team_admin_disabled"
|
||||
|
||||
|
||||
class TeamEditNone(BaseModel):
|
||||
kind: Literal["none"] = "none"
|
||||
|
||||
|
||||
TeamEditAccess = Annotated[
|
||||
TeamEditUnrestricted | TeamEditAsTeamAdmin | TeamEditAsTeamAdminDisabled | TeamEditNone,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
|
||||
|
||||
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
|
||||
members_with_roles: tuple[TeamInfoMember, ...] = ()
|
||||
team_member_budget_table: LiteLLM_BudgetTableFull | None = None
|
||||
|
|
@ -4482,6 +4505,7 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
|
|||
# None = no org or not a manager; [] or ["all-proxy-models"] = no ceiling.
|
||||
organization_models: list[str] | None = None
|
||||
model_max_budget_usage: Mapping[str, Mapping[str, object]] | None = None
|
||||
caller_edit_access: TeamEditAccess = Field(default_factory=TeamEditNone)
|
||||
|
||||
|
||||
class TeamInfoResponseObject(TypedDict):
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ from typing import (
|
|||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from pydantic import BaseModel, JsonValue, ValidationError
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, TypedDict, assert_never
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -73,6 +73,11 @@ from litellm.proxy._types import (
|
|||
SpecialProxyStrings,
|
||||
TeamAccessGroupModelGrant,
|
||||
TeamAddMemberResponse,
|
||||
TeamEditAccess,
|
||||
TeamEditAsTeamAdmin,
|
||||
TeamEditAsTeamAdminDisabled,
|
||||
TeamEditNone,
|
||||
TeamEditUnrestricted,
|
||||
TeamInfoMember,
|
||||
TeamInfoResponseObject,
|
||||
TeamInfoResponseObjectTeamTable,
|
||||
|
|
@ -495,6 +500,33 @@ async def _verify_team_access(
|
|||
_raise_team_access_denied()
|
||||
|
||||
|
||||
_GENERAL_SETTINGS: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def _general_settings() -> Mapping[str, object]:
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
return _GENERAL_SETTINGS.validate_python(cast(object, proxy_server.general_settings))
|
||||
|
||||
|
||||
def _caller_edit_access(role: TeamAccessRole | None, general_settings: Mapping[str, object]) -> TeamEditAccess:
|
||||
"""What the caller may change on /team/update, reported on /team/info so the dashboard never re-derives it."""
|
||||
match role:
|
||||
case "proxy_admin" | "org_admin":
|
||||
return TeamEditUnrestricted()
|
||||
case "team_admin":
|
||||
permitted: Final = resolve_team_admin_editable_fields(
|
||||
general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
|
||||
)
|
||||
if not permitted:
|
||||
return TeamEditAsTeamAdminDisabled()
|
||||
return TeamEditAsTeamAdmin(editable_fields=tuple(sorted(permitted)))
|
||||
case None:
|
||||
return TeamEditNone()
|
||||
case _:
|
||||
assert_never(role)
|
||||
|
||||
|
||||
class TeamMemberBudgetHandler:
|
||||
"""Helper class to handle team member budget, RPM, and TPM limit operations"""
|
||||
|
||||
|
|
@ -2124,7 +2156,6 @@ async def update_team(
|
|||
try:
|
||||
from litellm.proxy.management_helpers.audit_logs import is_audit_logging_enabled
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings,
|
||||
litellm_proxy_admin_name,
|
||||
llm_router,
|
||||
premium_user,
|
||||
|
|
@ -2192,7 +2223,7 @@ async def update_team(
|
|||
data=data,
|
||||
existing=existing_team,
|
||||
permitted=resolve_team_admin_editable_fields(
|
||||
general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
|
||||
_general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -4625,10 +4656,9 @@ async def team_info(
|
|||
)
|
||||
team_table: Final = LiteLLM_TeamTable.model_validate(team_info.model_dump())
|
||||
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_table)
|
||||
access_role: Final = await _resolve_team_access(team_obj=team_table, user_api_key_dict=user_api_key_dict)
|
||||
organization_models: Final[list[str] | None] = (
|
||||
_parent_organization_models(team_info)
|
||||
if await _resolve_team_access(team_obj=team_table, user_api_key_dict=user_api_key_dict) is not None
|
||||
else None
|
||||
_parent_organization_models(team_info) if access_role is not None else None
|
||||
)
|
||||
|
||||
## GET ALL KEYS ##
|
||||
|
|
@ -4697,6 +4727,7 @@ async def team_info(
|
|||
model_max_budget=resolved_team_info.model_max_budget,
|
||||
cache=model_max_budget_limiter.dual_cache,
|
||||
),
|
||||
"caller_edit_access": _caller_edit_access(access_role, _general_settings()),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15122,3 +15122,65 @@ async def test_resolve_team_access_ranks_proxy_admin_then_org_admin_then_team_ad
|
|||
assert await _resolve_team_access(team_obj=team, user_api_key_dict=outsider) is None
|
||||
org_lookup.return_value = True
|
||||
assert await _resolve_team_access(team_obj=team, user_api_key_dict=roster_admin) == "org_admin"
|
||||
|
||||
|
||||
_ROSTER_ADMIN_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="admin-1")
|
||||
_MEMBER_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="member-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"caller, org_admin, enabled_fields, expected",
|
||||
[
|
||||
pytest.param(_PROXY_ADMIN_CALLER, False, (), {"kind": "unrestricted"}, id="proxy-admin"),
|
||||
pytest.param(
|
||||
UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, user_id="viewer"),
|
||||
False,
|
||||
("tpm_limit",),
|
||||
{"kind": "none"},
|
||||
id="proxy-admin-viewer",
|
||||
),
|
||||
pytest.param(_ROSTER_ADMIN_CALLER, True, (), {"kind": "unrestricted"}, id="org-admin-who-is-also-team-admin"),
|
||||
pytest.param(_ROSTER_ADMIN_CALLER, False, (), {"kind": "team_admin_disabled"}, id="team-admin-nothing-enabled"),
|
||||
pytest.param(
|
||||
_ROSTER_ADMIN_CALLER,
|
||||
False,
|
||||
("tpm_limit",),
|
||||
{"kind": "team_admin", "editable_fields": ["tpm_limit"]},
|
||||
id="team-admin-field-enabled",
|
||||
),
|
||||
pytest.param(_MEMBER_CALLER, False, ("tpm_limit",), {"kind": "none"}, id="plain-member"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_info_reports_what_the_caller_may_edit(caller, org_admin, enabled_fields, expected):
|
||||
"""The dashboard gates its edit form on this field instead of guessing the caller's role from the org list,
|
||||
which is premium-gated and can be empty for a dual-role org admin."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints import team_endpoints
|
||||
|
||||
team_row = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
organization_id="org-1",
|
||||
members_with_roles=[Member(user_id="admin-1", role="admin"), Member(user_id="member-1", role="user")],
|
||||
)
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
|
||||
mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma.get_data = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: no seam on team_info
|
||||
patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[])), # test-quality-ok: no seam on team_info
|
||||
patch.object( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
|
||||
team_endpoints, "_is_user_org_admin_for_team", AsyncMock(return_value=org_admin)
|
||||
),
|
||||
_team_admin_may_edit(*enabled_fields),
|
||||
):
|
||||
response = await team_endpoints.team_info(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id="team-1",
|
||||
user_api_key_dict=caller,
|
||||
)
|
||||
|
||||
assert response["team_info"].caller_edit_access.model_dump(mode="json") == expected
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ const seedDefaultMocks = () => {
|
|||
isError: false,
|
||||
} as any);
|
||||
mockUseUISettings.mockReturnValue({
|
||||
data: { values: { team_admin_editable_team_fields: [] } },
|
||||
data: { values: {} },
|
||||
isLoading: false,
|
||||
} as any);
|
||||
mockUseKeys.mockReturnValue({
|
||||
|
|
@ -666,19 +666,9 @@ describe("TeamInfoView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows edit tabs when the fetched team data marks the session user as team admin, even without the is_team_admin prop", async () => {
|
||||
it("shows edit tabs when the proxy reports the session user may edit, even without the is_team_admin prop", async () => {
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
members_with_roles: [
|
||||
{
|
||||
user_id: "user-1",
|
||||
user_email: "admin@test.com",
|
||||
role: "admin",
|
||||
spend: 0,
|
||||
budget_id: "budget1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
createMockTeamData({ caller_edit_access: { kind: "team_admin_disabled" } }),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} is_team_admin={false} is_proxy_admin={false} />);
|
||||
|
|
@ -1881,9 +1871,11 @@ describe("TeamInfoView", () => {
|
|||
authState.userRole = "Internal User";
|
||||
});
|
||||
|
||||
it("tells a team admin to ask a proxy admin when no team field is enabled for them", async () => {
|
||||
it("tells a team admin to ask a proxy admin when the proxy reports no team field is enabled for them", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ caller_edit_access: { kind: "team_admin_disabled" } }),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
|
||||
|
||||
|
|
@ -1897,13 +1889,26 @@ describe("TeamInfoView", () => {
|
|||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the form for a team admin once a proxy admin has enabled a field", async () => {
|
||||
mockUseUISettings.mockReturnValue({
|
||||
data: { values: { team_admin_editable_team_fields: ["tpm_limit"] } },
|
||||
isLoading: false,
|
||||
} as any);
|
||||
it("opens the form for a team admin once the proxy reports an enabled field", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ caller_edit_access: { kind: "team_admin", editable_fields: ["tpm_limit"] } }),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
|
||||
expect(await screen.findByLabelText("Team Name")).toBeInTheDocument();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the form when the proxy reports unrestricted access although the props only mark a team admin", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ caller_edit_access: { kind: "unrestricted" } }),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
|
||||
|
||||
|
|
@ -1917,7 +1922,9 @@ describe("TeamInfoView", () => {
|
|||
it("never gates a proxy admin on the team admin field list", async () => {
|
||||
authState.userRole = "Admin";
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ caller_edit_access: { kind: "unrestricted" } }),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import UserSearchModal from "@/components/common_components/user_search_modal";
|
||||
import {
|
||||
|
|
@ -50,7 +49,8 @@ import { useFieldArray } from "react-hook-form";
|
|||
import { z } from "zod/v4";
|
||||
import GuardrailsSelect from "./GuardrailsSelect";
|
||||
import {
|
||||
resolveTeamEditAccess,
|
||||
type CallerEditAccess,
|
||||
parseTeamEditAccess,
|
||||
TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION,
|
||||
TEAM_ADMIN_EDITING_DISABLED_TITLE,
|
||||
} from "./teamAdminEditAccess";
|
||||
|
|
@ -303,6 +303,7 @@ export interface TeamData {
|
|||
guardrails?: string[];
|
||||
policies?: string[];
|
||||
object_permission?: ObjectPermission | null;
|
||||
caller_edit_access?: CallerEditAccess;
|
||||
team_member_budget_table: {
|
||||
max_budget: number;
|
||||
budget_duration: string | null;
|
||||
|
|
@ -321,7 +322,6 @@ export interface TeamInfoProps {
|
|||
accessToken: string | null;
|
||||
is_team_admin: boolean;
|
||||
is_proxy_admin: boolean;
|
||||
is_org_admin?: boolean;
|
||||
userModels: string[];
|
||||
editTeam: boolean;
|
||||
premiumUser?: boolean;
|
||||
|
|
@ -537,7 +537,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
accessToken,
|
||||
is_team_admin,
|
||||
is_proxy_admin,
|
||||
is_org_admin = false,
|
||||
userModels,
|
||||
editTeam,
|
||||
premiumUser = false,
|
||||
|
|
@ -581,25 +580,16 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
const [teamModelMaxBudget, setTeamModelMaxBudget] = useState<ModelMaxBudget>({});
|
||||
const routerSettingsRef = React.useRef<RouterSettingsAccordionRef>(null);
|
||||
const [organization, setOrganization] = useState<Organization | null>(null);
|
||||
const { userRole, userId } = useAuthorized();
|
||||
const { userRole } = useAuthorized();
|
||||
const { data: allMcpServers = [], isError: mcpServersFailed, isLoading: mcpServersLoading } = useMCPServers();
|
||||
const { data: allMcpToolsets = [], isError: mcpToolsetsFailed, isLoading: mcpToolsetsLoading } = useMCPToolsets();
|
||||
const { data: allAccessGroups = [], isError: accessGroupsFailed, isLoading: accessGroupsLoading } = useAccessGroups();
|
||||
const canEditTeamEstimates = isProxyAdminRole(userRole);
|
||||
const teamEstimateTooltip = estimateTooltips(canEditTeamEstimates, "team");
|
||||
const { data: userOrganizations = [] } = useOrganizations();
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Check if user is org admin for this team's organization
|
||||
const isOrgAdminForTeam = useMemo(() => {
|
||||
const teamOrgId = teamData?.team_info?.organization_id;
|
||||
if (!teamOrgId || !userId) return false;
|
||||
const org = userOrganizations.find((o) => o.organization_id === teamOrgId);
|
||||
return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false;
|
||||
}, [teamData, userOrganizations, userId]);
|
||||
|
||||
// Models currently selected in the team edit form, used to scope the per-model
|
||||
// rate limit dropdown to models this team actually has access to.
|
||||
const watchedModels = form.watch("models");
|
||||
|
|
@ -623,22 +613,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
return unfurlWildcardModelsInList(selected, userModels);
|
||||
}, [watchedModels, teamData, userModels]);
|
||||
|
||||
const isTeamAdminFromTeamData = useMemo(
|
||||
() =>
|
||||
teamData?.team_info?.members_with_roles?.some(
|
||||
(member) => member.user_id != null && member.user_id === userId && member.role === "admin",
|
||||
) ?? false,
|
||||
[teamData, userId],
|
||||
);
|
||||
|
||||
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
|
||||
const viewerIsProxyAdmin = is_proxy_admin || isProxyAdminRole(userRole);
|
||||
const viewerIsOrgAdmin = is_org_admin || isOrgAdminForTeam;
|
||||
const editsAsTeamAdmin = canEditTeam && !viewerIsProxyAdmin && !viewerIsOrgAdmin;
|
||||
const teamEditAccess = useMemo(
|
||||
() => resolveTeamEditAccess(editsAsTeamAdmin, uiSettingsData?.values),
|
||||
[editsAsTeamAdmin, uiSettingsData],
|
||||
);
|
||||
const teamEditAccess = useMemo(() => parseTeamEditAccess(teamData?.team_info?.caller_edit_access), [teamData]);
|
||||
const canEditTeam = is_team_admin || is_proxy_admin || teamEditAccess.kind !== "none";
|
||||
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
|
||||
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
|
||||
const { onTabChange, hasVisited } = useVisitedTabs(defaultTabKey);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
parseSupportedTeamAdminEditableFields,
|
||||
parseTeamAdminEditableFields,
|
||||
resolveTeamEditAccess,
|
||||
parseTeamEditAccess,
|
||||
} from "./teamAdminEditAccess";
|
||||
|
||||
describe("parseTeamAdminEditableFields", () => {
|
||||
|
|
@ -48,22 +48,28 @@ describe("parseSupportedTeamAdminEditableFields", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("resolveTeamEditAccess", () => {
|
||||
it("does not restrict callers who edit as proxy or org admin", () => {
|
||||
expect(resolveTeamEditAccess(false, { team_admin_editable_team_fields: [] })).toEqual({ kind: "unrestricted" });
|
||||
describe("parseTeamEditAccess", () => {
|
||||
it.each([
|
||||
["unrestricted", { kind: "unrestricted" }],
|
||||
["team_admin_disabled", { kind: "team_admin_disabled" }],
|
||||
["none", { kind: "none" }],
|
||||
])("passes the proxy's %s verdict through", (_kind, verdict) => {
|
||||
expect(parseTeamEditAccess(verdict)).toEqual(verdict);
|
||||
});
|
||||
|
||||
it("disables editing for a team admin when no field is enabled", () => {
|
||||
expect(resolveTeamEditAccess(true, { team_admin_editable_team_fields: [] })).toEqual({
|
||||
kind: "team_admin_disabled",
|
||||
});
|
||||
expect(resolveTeamEditAccess(true, undefined)).toEqual({ kind: "team_admin_disabled" });
|
||||
});
|
||||
|
||||
it("hands a team admin the enabled fields", () => {
|
||||
expect(resolveTeamEditAccess(true, { team_admin_editable_team_fields: ["tpm_limit"] })).toEqual({
|
||||
it("hands a team admin the fields the proxy enabled", () => {
|
||||
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["tpm_limit"] })).toEqual({
|
||||
kind: "team_admin",
|
||||
editableFields: new Set(["tpm_limit"]),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the proxy sent nothing", undefined],
|
||||
["the kind is unknown", { kind: "owner" }],
|
||||
["a team admin verdict lacks its field list", { kind: "team_admin" }],
|
||||
["the field list holds a non-string", { kind: "team_admin", editable_fields: [7] }],
|
||||
])("fails closed to no access when %s", (_label, value) => {
|
||||
expect(parseTeamEditAccess(value)).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,10 +6,20 @@ export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team s
|
|||
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
|
||||
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
|
||||
|
||||
const callerEditAccessSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("unrestricted") }),
|
||||
z.object({ kind: z.literal("team_admin"), editable_fields: z.array(z.string()) }),
|
||||
z.object({ kind: z.literal("team_admin_disabled") }),
|
||||
z.object({ kind: z.literal("none") }),
|
||||
]);
|
||||
|
||||
export type CallerEditAccess = z.infer<typeof callerEditAccessSchema>;
|
||||
|
||||
export type TeamEditAccess =
|
||||
| { readonly kind: "unrestricted" }
|
||||
| { readonly kind: "team_admin"; readonly editableFields: ReadonlySet<string> }
|
||||
| { readonly kind: "team_admin_disabled" };
|
||||
| { readonly kind: "team_admin_disabled" }
|
||||
| { readonly kind: "none" };
|
||||
|
||||
const fieldListSchema = z.array(z.string()).catch([]);
|
||||
|
||||
|
|
@ -29,10 +39,11 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk
|
|||
return items.success ? fieldListSchema.parse(items.data.enum) : [];
|
||||
};
|
||||
|
||||
export const resolveTeamEditAccess = (editsAsTeamAdmin: boolean, uiSettingsValues: unknown): TeamEditAccess => {
|
||||
if (!editsAsTeamAdmin) return { kind: "unrestricted" };
|
||||
const editableFields = parseTeamAdminEditableFields(uiSettingsValues);
|
||||
return editableFields.length === 0
|
||||
? { kind: "team_admin_disabled" }
|
||||
: { kind: "team_admin", editableFields: new Set(editableFields) };
|
||||
export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => {
|
||||
const parsed = callerEditAccessSchema.safeParse(callerEditAccess);
|
||||
if (!parsed.success) return { kind: "none" };
|
||||
if (parsed.data.kind === "team_admin") {
|
||||
return { kind: "team_admin", editableFields: new Set(parsed.data.editable_fields) };
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue