diff --git a/plane-src/apps/api/plane/app/urls/codex_agents.py b/plane-src/apps/api/plane/app/urls/codex_agents.py index e6d1edc..ff3077a 100644 --- a/plane-src/apps/api/plane/app/urls/codex_agents.py +++ b/plane-src/apps/api/plane/app/urls/codex_agents.py @@ -8,7 +8,9 @@ from plane.app.views import ( CodexAgentDetailEndpoint, CodexAgentGrantListEndpoint, CodexAgentGrantReplaceEndpoint, + CodexAgentGrantReplaceProjectsEndpoint, CodexAgentListEndpoint, + CodexAgentProjectAccessEndpoint, CodexAgentRevokeEndpoint, CodexAgentSetupEndpoint, CodexAgentTokenListEndpoint, @@ -17,6 +19,11 @@ from plane.app.views import ( urlpatterns = [ + path( + "workspaces//codex-agent-api/project-access/", + CodexAgentProjectAccessEndpoint.as_view(), + name="codex-agent-api-project-access", + ), path( "workspaces//codex-agent-api/agents/", CodexAgentListEndpoint.as_view(), @@ -42,6 +49,11 @@ urlpatterns = [ CodexAgentGrantReplaceEndpoint.as_view(), name="codex-agent-api-agent-grants-replace", ), + path( + "workspaces//codex-agent-api/agents//grants/replace-projects/", + CodexAgentGrantReplaceProjectsEndpoint.as_view(), + name="codex-agent-api-agent-grants-replace-projects", + ), path( "workspaces//codex-agent-api/agents//tokens/", CodexAgentTokenListEndpoint.as_view(), diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 130a8ed..ca3486f 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -178,7 +178,9 @@ from .codex_agents import ( CodexAgentDetailEndpoint, CodexAgentGrantListEndpoint, CodexAgentGrantReplaceEndpoint, + CodexAgentGrantReplaceProjectsEndpoint, CodexAgentListEndpoint, + CodexAgentProjectAccessEndpoint, CodexAgentRevokeEndpoint, CodexAgentSetupEndpoint, CodexAgentTokenListEndpoint, diff --git a/plane-src/apps/api/plane/app/views/codex_agents.py b/plane-src/apps/api/plane/app/views/codex_agents.py index 32be6b3..12b0f1c 100644 --- a/plane-src/apps/api/plane/app/views/codex_agents.py +++ b/plane-src/apps/api/plane/app/views/codex_agents.py @@ -86,6 +86,8 @@ def is_workspace_admin(user, workspace): member=user, role=ROLE.ADMIN.value, is_active=True, + is_banned=False, + deleted_at__isnull=True, ).exists() @@ -104,7 +106,12 @@ def validate_project_in_workspace(workspace, project_id, user): return None try: - project = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).first() + project = Project.objects.filter( + id=project_id, + workspace=workspace, + archived_at__isnull=True, + deleted_at__isnull=True, + ).first() except ValidationError: project = None @@ -126,6 +133,7 @@ def validate_project_in_workspace(workspace, project_id, user): member=user, role__gte=ROLE.MEMBER.value, is_active=True, + deleted_at__isnull=True, ).exists(): return Response( { @@ -174,6 +182,111 @@ def validate_projects_in_workspace(workspace, project_ids, user): return normalized_project_ids +def get_accessible_projects_for_workspace(workspace, user): + queryset = Project.objects.filter( + workspace=workspace, + archived_at__isnull=True, + deleted_at__isnull=True, + ) + + if is_workspace_admin(user, workspace): + return queryset.order_by("name") + + project_ids = ProjectMember.objects.filter( + workspace=workspace, + member=user, + role__gte=ROLE.MEMBER.value, + is_active=True, + deleted_at__isnull=True, + project__archived_at__isnull=True, + project__deleted_at__isnull=True, + ).values_list("project_id", flat=True) + + return queryset.filter(id__in=project_ids).order_by("name") + + +def serialize_accessible_project(project): + return { + "id": str(project.id), + "workspace_slug": project.workspace.slug, + "name": project.name, + "identifier": project.identifier, + } + + +def serialize_accessible_workspace(workspace_member, projects): + workspace = workspace_member.workspace + return { + "id": str(workspace.id), + "slug": workspace.slug, + "name": workspace.name, + "role": workspace_member.role, + "projects": [serialize_accessible_project(project) for project in projects], + } + + +def normalize_project_grants(raw_grants): + if not isinstance(raw_grants, list) or len(raw_grants) == 0: + return Response( + { + "ok": False, + "error": "grants_required", + "message": "Select at least one workspace/project grant.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + normalized_grants = [] + seen_keys = set() + for raw_grant in raw_grants: + if not isinstance(raw_grant, dict): + continue + workspace_slug = str(raw_grant.get("workspace_slug") or "").strip() + project_id = str(raw_grant.get("project_id") or "").strip() + if not workspace_slug or not project_id: + continue + + grant_key = f"{workspace_slug}:{project_id}" + if grant_key in seen_keys: + continue + seen_keys.add(grant_key) + normalized_grants.append( + { + "workspace_slug": workspace_slug, + "project_id": project_id, + } + ) + + if not normalized_grants: + return Response( + { + "ok": False, + "error": "grants_required", + "message": "Select at least one workspace/project grant.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + return normalized_grants + + +def validate_project_grants(grants, user): + for grant in grants: + workspace, workspace_error = require_workspace(grant["workspace_slug"]) + if workspace_error is not None: + return workspace_error + + _, entitlement_error = require_codex_agent_entitlement(user, workspace.slug) + if entitlement_error is not None: + return entitlement_error + + project_error = validate_project_in_workspace(workspace, grant["project_id"], user) + if project_error is not None: + return project_error + + return None + + def gateway_request(method, path, payload=None): config, error_response = require_gateway_config() if error_response is not None: @@ -249,6 +362,38 @@ class CodexAgentListEndpoint(CodexAgentEntitledEndpoint): return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents", payload) +class CodexAgentProjectAccessEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request, slug): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + workspace_members = ( + WorkspaceMember.objects.filter( + member=request.user, + is_active=True, + is_banned=False, + deleted_at__isnull=True, + workspace__deleted_at__isnull=True, + ) + .select_related("workspace") + .order_by("workspace__name") + ) + + workspaces = [] + for workspace_member in workspace_members: + workspace = workspace_member.workspace + _, workspace_entitlement_error = require_codex_agent_entitlement(request.user, workspace.slug) + if workspace_entitlement_error is not None: + continue + + projects = list(get_accessible_projects_for_workspace(workspace, request.user)) + workspaces.append(serialize_accessible_workspace(workspace_member, projects)) + + return Response({"ok": True, "workspaces": workspaces}) + + class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, agent_id): @@ -347,6 +492,33 @@ class CodexAgentGrantReplaceEndpoint(CodexAgentEntitledEndpoint): ) +class CodexAgentGrantReplaceProjectsEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + grants_or_error = normalize_project_grants(request.data.get("grants")) + if isinstance(grants_or_error, Response): + return grants_or_error + + validation_response = validate_project_grants(grants_or_error, request.user) + if validation_response is not None: + return validation_response + + payload = { + "grants": grants_or_error, + "scopes": request.data.get("scopes") or [], + "mode": request.data.get("mode") or "voluntary", + } + return gateway_request( + "POST", + f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants/replace-projects", + payload, + ) + + class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, agent_id): diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py index 52f4711..f46aaa6 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py @@ -10,6 +10,7 @@ from django.views import View from django.views.decorators.csrf import csrf_exempt from plane.app.realtime.issue_events import publish_issue_event_on_commit +from plane.app.permissions import ROLE from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy from plane.authentication.views.nodedc_workspace_adapter import parse_json_body @@ -347,6 +348,92 @@ def validate_agent_workspace_entitlement(request, workspace_slug): return None +def get_agent_owner_workspace_membership(request, workspace): + owner = resolve_agent_owner(request) + if owner is None: + return None, validation_error("agent_owner_not_found", status=403) + + workspace_member = WorkspaceMember.objects.filter( + workspace=workspace, + member=owner, + is_active=True, + is_banned=False, + deleted_at__isnull=True, + ).first() + if workspace_member is None: + return None, validation_error("workspace_access_denied", status=403) + + return workspace_member, None + + +def validate_agent_project_access(request, project): + entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) + if entitlement_error is not None: + return entitlement_error + + workspace_member, membership_error = get_agent_owner_workspace_membership(request, project.workspace) + if membership_error is not None: + return membership_error + + if workspace_member.role == ROLE.ADMIN.value: + return None + + owner = resolve_agent_owner(request) + if owner is None: + return validation_error("agent_owner_not_found", status=403) + + if ProjectMember.objects.filter( + project=project, + member=owner, + role__gte=ROLE.MEMBER.value, + is_active=True, + deleted_at__isnull=True, + ).exists(): + return None + + return validation_error("project_access_denied", status=403) + + +def get_agent_accessible_projects(request, workspace_slug): + if not workspace_slug: + return [], validation_error("workspace_slug_required") + + entitlement_error = validate_agent_workspace_entitlement(request, workspace_slug) + if entitlement_error is not None: + return [], entitlement_error + + project_queryset = Project.objects.filter( + workspace__slug=workspace_slug, + deleted_at__isnull=True, + archived_at__isnull=True, + ).select_related("workspace") + first_project = project_queryset.first() + if first_project is None: + return [], None + + workspace_member, membership_error = get_agent_owner_workspace_membership(request, first_project.workspace) + if membership_error is not None: + return [], membership_error + + if workspace_member.role == ROLE.ADMIN.value: + return list(project_queryset), None + + owner = resolve_agent_owner(request) + if owner is None: + return [], validation_error("agent_owner_not_found", status=403) + + project_ids = ProjectMember.objects.filter( + workspace=first_project.workspace, + member=owner, + role__gte=ROLE.MEMBER.value, + is_active=True, + deleted_at__isnull=True, + project__deleted_at__isnull=True, + project__archived_at__isnull=True, + ).values_list("project_id", flat=True) + return list(project_queryset.filter(id__in=project_ids)), None + + def html_from_text(value): text = value.strip() if isinstance(value, str) else "" return f"

{escape(text)}

" if text else "

" @@ -586,9 +673,6 @@ class NodeDCAgentProjectResolveEndpoint(View): project_id = grant.get("project_id") if not isinstance(workspace_slug, str) or not workspace_slug: continue - entitlement_error = validate_agent_workspace_entitlement(request, workspace_slug) - if entitlement_error is not None: - continue if isinstance(project_id, str) and project_id: project_filters.append((workspace_slug, project_id)) else: @@ -596,16 +680,14 @@ class NodeDCAgentProjectResolveEndpoint(View): projects = [] if workspace_slugs: - projects.extend( - Project.objects.filter( - workspace__slug__in=workspace_slugs, - deleted_at__isnull=True, - archived_at__isnull=True, - ).select_related("workspace") - ) + for workspace_slug in workspace_slugs: + accessible_projects, access_error = get_agent_accessible_projects(request, workspace_slug) + if access_error is not None: + continue + projects.extend(accessible_projects) for workspace_slug, project_id in project_filters: project = resolve_project(project_id, workspace_slug) - if project is not None: + if project is not None and validate_agent_project_access(request, project) is None: projects.append(project) unique_projects = {str(project.id): project for project in projects} @@ -628,9 +710,9 @@ class NodeDCAgentProjectContextEndpoint(View): if project is None: return validation_error("project_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence") labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order") @@ -666,9 +748,9 @@ class NodeDCAgentProjectLabelsEnsureEndpoint(View): if project is None: return validation_error("project_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: @@ -702,9 +784,9 @@ class NodeDCAgentIssueListEndpoint(View): if project is None: return validation_error("project_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error queryset = ( Issue.issue_objects.filter(project=project) @@ -730,9 +812,9 @@ class NodeDCAgentIssueListEndpoint(View): if project is None: return validation_error("project_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error title = payload.get("title") if not isinstance(title, str) or not title.strip(): @@ -784,9 +866,9 @@ class NodeDCAgentIssueUpdateEndpoint(View): if issue is None: return validation_error("issue_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error detail_layout = ( merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks")) @@ -841,9 +923,9 @@ class NodeDCAgentIssueMoveEndpoint(View): if issue is None: return validation_error("issue_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first() if state is None: @@ -881,9 +963,9 @@ class NodeDCAgentIssueCommentEndpoint(View): if issue is None: return validation_error("issue_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error body = payload.get("body") if not isinstance(body, str) or not body.strip(): @@ -922,9 +1004,9 @@ class NodeDCAgentIssueLabelsEndpoint(View): if issue is None: return validation_error("issue_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error label_ids = payload.get("label_ids") if not isinstance(label_ids, list): @@ -959,9 +1041,9 @@ class NodeDCAgentIssueAssigneesEndpoint(View): if issue is None: return validation_error("issue_not_found", status=404) - entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) - if entitlement_error is not None: - return entitlement_error + project_access_error = validate_agent_project_access(request, project) + if project_access_error is not None: + return project_access_error member_ids = payload.get("member_ids") if not isinstance(member_ids, list): diff --git a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx index 46f10d5..7a191c2 100644 --- a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx @@ -16,11 +16,11 @@ import { SettingsHeading } from "@/components/settings/heading"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; // services -import { ProjectService } from "@/services/project/project.service"; import { WorkspaceCodexAgentService, type TCodexAgent, type TCodexAgentGrant, + type TCodexAgentGrantableWorkspace, type TCodexAgentSetupPacket, type TCodexAgentToken, } from "@/services/workspace-codex-agent.service"; @@ -49,7 +49,6 @@ const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent"; const DEFAULT_OPS_AGENT_MCP_ENDPOINT = "https://ops-agents.nodedc.ru/mcp"; const codexAgentService = new WorkspaceCodexAgentService(); -const projectService = new ProjectService(); const workspaceService = new WorkspaceService(); type TProps = { @@ -67,7 +66,10 @@ type TAgentSetupCard = { type TProjectAccessOption = { id: string; identifier?: string | null; + key: string; name: string; + workspaceName: string; + workspaceSlug: string; }; export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) { @@ -77,7 +79,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti const [isCreatingAgent, setIsCreatingAgent] = useState(false); const [newAgentName, setNewAgentName] = useState("Local Codex"); const [newAgentAvatarUrl, setNewAgentAvatarUrl] = useState(null); - const [selectedProjectId, setSelectedProjectId] = useState(""); + const [selectedProjectKey, setSelectedProjectKey] = useState(""); const [agentDraftNames, setAgentDraftNames] = useState>({}); const [createdSetupCards, setCreatedSetupCards] = useState([]); const [revealedTokens, setRevealedTokens] = useState>({}); @@ -100,8 +102,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_AGENTS_${workspaceSlug}` : null, () => codexAgentService.listAgents(workspaceSlug) ); - const { data: projects } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECTS_${workspaceSlug}` : null, () => - projectService.getProjectsLite(workspaceSlug) + const { data: projectAccessPayload } = useSWR( + isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECT_ACCESS_${workspaceSlug}` : null, + () => codexAgentService.listProjectAccess(workspaceSlug) ); const activeAgents = useMemo( () => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"), @@ -134,8 +137,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti }) ) ); - const projectOptions = projects ?? []; - const effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || ""; + const workspaceProjectGroups = projectAccessPayload?.workspaces ?? []; + const projectOptions = useMemo(() => flattenProjectAccessOptions(workspaceProjectGroups), [workspaceProjectGroups]); + const effectiveSelectedProjectKey = selectedProjectKey || projectOptions[0]?.key || ""; const setupCards = useMemo( () => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards), [createdSetupCards, persistedSetupCards] @@ -191,7 +195,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti const handleCreateAgent = async () => { const displayName = newAgentName.trim(); - if (!displayName || !effectiveSelectedProjectId) return; + const initialGrant = parseProjectGrantKey(effectiveSelectedProjectKey); + if (!displayName || !initialGrant) return; setIsCreatingAgent(true); try { @@ -199,11 +204,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti display_name: displayName, avatar_url: newAgentAvatarUrl, }); - const grantResponse = await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, { - project_id: effectiveSelectedProjectId, - scopes: TASK_AUTHOR_SCOPES, - mode: "voluntary", - }); + const grantsResponse = await codexAgentService.replaceProjectGrantsAcrossWorkspaces( + workspaceSlug, + createResponse.agent.id, + { + grants: [ + { + workspace_slug: initialGrant.workspaceSlug, + project_id: initialGrant.projectId, + }, + ], + scopes: TASK_AUTHOR_SCOPES, + mode: "voluntary", + } + ); const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, { name: `${displayName} local token`, }); @@ -212,9 +226,13 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti [tokenResponse.token_record.id]: tokenResponse.token, })); setCreatedSetupCards((currentCards) => - upsertSetupCardToken(currentCards, createResponse.agent, tokenResponse.token_record, tokenResponse.setup, [ - grantResponse.grant, - ]) + upsertSetupCardToken( + currentCards, + createResponse.agent, + tokenResponse.token_record, + tokenResponse.setup, + grantsResponse.grants + ) ); await mutateCodexAgents(); await mutateSetupCards(); @@ -265,35 +283,35 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti } }; - const handleToggleProjectGrant = (agentId: string, currentProjectIds: string[], projectId: string) => { + const handleToggleProjectGrant = (agentId: string, currentGrantKeys: string[], grantKey: string) => { setProjectGrantDrafts((currentDrafts) => { - const currentDraftProjectIds = currentDrafts[agentId] ?? currentProjectIds; - const nextProjectIds = currentDraftProjectIds.includes(projectId) - ? currentDraftProjectIds.filter((currentProjectId) => currentProjectId !== projectId) - : [...currentDraftProjectIds, projectId]; + const currentDraftGrantKeys = currentDrafts[agentId] ?? currentGrantKeys; + const nextGrantKeys = currentDraftGrantKeys.includes(grantKey) + ? currentDraftGrantKeys.filter((currentGrantKey) => currentGrantKey !== grantKey) + : [...currentDraftGrantKeys, grantKey]; return { ...currentDrafts, - [agentId]: nextProjectIds, + [agentId]: nextGrantKeys, }; }); }; - const handleSaveProjectAccess = async (agent: TCodexAgent, selectedProjectIds: string[]) => { - const projectIds = [...new Set(selectedProjectIds.filter(Boolean))]; - if (projectIds.length === 0) { + const handleSaveProjectAccess = async (agent: TCodexAgent, selectedGrantKeys: string[]) => { + const projectGrants = buildProjectGrantPayload(selectedGrantKeys); + if (projectGrants.length === 0) { setToast({ type: TOAST_TYPE.ERROR, title: "Выберите project", - message: "У агента должен быть доступ хотя бы к одному project в workspace.", + message: "У агента должен быть доступ хотя бы к одному project.", }); return; } setSavingProjectGrantAgentIds((current) => ({ ...current, [agent.id]: true })); try { - const grantsResponse = await codexAgentService.replaceProjectGrants(workspaceSlug, agent.id, { - project_ids: projectIds, + const grantsResponse = await codexAgentService.replaceProjectGrantsAcrossWorkspaces(workspaceSlug, agent.id, { + grants: projectGrants, scopes: TASK_AUTHOR_SCOPES, mode: "voluntary", }); @@ -302,7 +320,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti card.agent.id === agent.id ? { ...card, - grants: mergeAgentGrants(card.grants, grantsResponse.grants, workspaceSlug), + grants: mergeAgentGrants(card.grants, grantsResponse.grants, { replaceAllProjectGrants: true }), } : card ) @@ -316,7 +334,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti setToast({ type: TOAST_TYPE.SUCCESS, title: "Доступы Codex обновлены", - message: "Agent token теперь работает только с выбранными projects.", + message: "Agent token теперь работает только с выбранными workspace/projects.", }); } catch (error: any) { setToast({ @@ -457,12 +475,12 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti Выберите project @@ -471,7 +489,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti variant="primary" size="lg" className="nodedc-settings-save-button h-12 min-w-[11rem] self-end px-5" - disabled={!newAgentName.trim() || !effectiveSelectedProjectId} + disabled={!newAgentName.trim() || !effectiveSelectedProjectKey} loading={isCreatingAgent} onClick={handleCreateAgent} > @@ -498,8 +516,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti const setupCard = setupCards.find((card) => card.agent.id === agent.id); const agentTokens = setupCard?.tokens ?? []; const agentGrants = setupCard?.grants ?? []; - const currentProjectIds = getGrantedProjectIds(agentGrants, workspaceSlug); - const draftProjectIds = projectGrantDrafts[agent.id] ?? currentProjectIds; + const currentGrantKeys = getGrantedProjectKeys(agentGrants); + const draftGrantKeys = projectGrantDrafts[agent.id] ?? currentGrantKeys; const isProjectAccessOpen = openProjectAccessAgentId === agent.id; const isSavingProjectAccess = savingProjectGrantAgentIds[agent.id] === true; @@ -632,12 +650,12 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti )} void handleSaveProjectAccess(agent, draftProjectIds)} + workspaceGroups={workspaceProjectGroups} + onSave={() => void handleSaveProjectAccess(agent, draftGrantKeys)} onToggleOpen={() => { setOpenProjectAccessAgentId(isProjectAccessOpen ? null : agent.id); setProjectGrantDrafts((currentDrafts) => @@ -645,13 +663,11 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti ? currentDrafts : { ...currentDrafts, - [agent.id]: currentProjectIds, + [agent.id]: currentGrantKeys, } ); }} - onToggleProject={(projectId) => - handleToggleProjectGrant(agent.id, currentProjectIds, projectId) - } + onToggleProject={(grantKey) => handleToggleProjectGrant(agent.id, currentGrantKeys, grantKey)} /> ); @@ -828,19 +844,20 @@ function AgentAvatarButton(props: { } type TAgentProjectAccessPanelProps = { - currentProjectIds: string[]; - draftProjectIds: string[]; + currentGrantKeys: string[]; + draftGrantKeys: string[]; isOpen: boolean; isSaving: boolean; onSave: () => void; onToggleOpen: () => void; - onToggleProject: (projectId: string) => void; - projects: TProjectAccessOption[]; + onToggleProject: (grantKey: string) => void; + workspaceGroups: TCodexAgentGrantableWorkspace[]; }; function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) { - const isDirty = !areProjectSelectionsEqual(props.currentProjectIds, props.draftProjectIds); - const selectedCount = props.draftProjectIds.length; + const isDirty = !areProjectSelectionsEqual(props.currentGrantKeys, props.draftGrantKeys); + const selectedCount = props.draftGrantKeys.length; + const projectsCount = props.workspaceGroups.reduce((count, group) => count + group.projects.length, 0); const summary = selectedCount === 0 ? "Нет выбранных projects" @@ -856,9 +873,9 @@ function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) {
-
Доступы к проектам
+
Доступы к workspace и projects
- Выберите projects, куда этот agent token может читать и писать карточки. + Выберите workspaces/projects, куда этот agent token может читать и писать карточки.
@@ -874,44 +891,63 @@ function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) { {props.isOpen && (
- {props.projects.length > 0 ? ( -
- {props.projects.map((project) => { - const isChecked = props.draftProjectIds.includes(project.id); + {projectsCount > 0 ? ( +
+ {props.workspaceGroups.map((workspaceGroup) => { + if (workspaceGroup.projects.length === 0) return null; return ( - +
+
+
{workspaceGroup.name}
+
+ {workspaceGroup.slug} +
+
+
+ {workspaceGroup.projects.map((project) => { + const grantKey = buildProjectGrantKey(workspaceGroup.slug, project.id); + const isChecked = props.draftGrantKeys.includes(grantKey); + + return ( + + ); + })} +
+
); })}
) : ( -
В workspace нет доступных projects.
+
Нет доступных workspace/projects.
)}
- Сохранение заменяет grants текущего workspace: снятые галочки сразу отзывают доступ. + Сохранение заменяет grants выбранных workspace/projects: снятые галочки сразу отзывают доступ.