FEAT - TASKER CODEX: user-scoped multi-workspace grants

This commit is contained in:
DCCONSTRUCTIONS 2026-05-16 14:22:33 +03:00
parent 8f87f03ee6
commit 491a2b52c8
7 changed files with 544 additions and 125 deletions

View File

@ -8,7 +8,9 @@ from plane.app.views import (
CodexAgentDetailEndpoint, CodexAgentDetailEndpoint,
CodexAgentGrantListEndpoint, CodexAgentGrantListEndpoint,
CodexAgentGrantReplaceEndpoint, CodexAgentGrantReplaceEndpoint,
CodexAgentGrantReplaceProjectsEndpoint,
CodexAgentListEndpoint, CodexAgentListEndpoint,
CodexAgentProjectAccessEndpoint,
CodexAgentRevokeEndpoint, CodexAgentRevokeEndpoint,
CodexAgentSetupEndpoint, CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint, CodexAgentTokenListEndpoint,
@ -17,6 +19,11 @@ from plane.app.views import (
urlpatterns = [ urlpatterns = [
path(
"workspaces/<str:slug>/codex-agent-api/project-access/",
CodexAgentProjectAccessEndpoint.as_view(),
name="codex-agent-api-project-access",
),
path( path(
"workspaces/<str:slug>/codex-agent-api/agents/", "workspaces/<str:slug>/codex-agent-api/agents/",
CodexAgentListEndpoint.as_view(), CodexAgentListEndpoint.as_view(),
@ -42,6 +49,11 @@ urlpatterns = [
CodexAgentGrantReplaceEndpoint.as_view(), CodexAgentGrantReplaceEndpoint.as_view(),
name="codex-agent-api-agent-grants-replace", name="codex-agent-api-agent-grants-replace",
), ),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/grants/replace-projects/",
CodexAgentGrantReplaceProjectsEndpoint.as_view(),
name="codex-agent-api-agent-grants-replace-projects",
),
path( path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/", "workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/",
CodexAgentTokenListEndpoint.as_view(), CodexAgentTokenListEndpoint.as_view(),

View File

@ -178,7 +178,9 @@ from .codex_agents import (
CodexAgentDetailEndpoint, CodexAgentDetailEndpoint,
CodexAgentGrantListEndpoint, CodexAgentGrantListEndpoint,
CodexAgentGrantReplaceEndpoint, CodexAgentGrantReplaceEndpoint,
CodexAgentGrantReplaceProjectsEndpoint,
CodexAgentListEndpoint, CodexAgentListEndpoint,
CodexAgentProjectAccessEndpoint,
CodexAgentRevokeEndpoint, CodexAgentRevokeEndpoint,
CodexAgentSetupEndpoint, CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint, CodexAgentTokenListEndpoint,

View File

@ -86,6 +86,8 @@ def is_workspace_admin(user, workspace):
member=user, member=user,
role=ROLE.ADMIN.value, role=ROLE.ADMIN.value,
is_active=True, is_active=True,
is_banned=False,
deleted_at__isnull=True,
).exists() ).exists()
@ -104,7 +106,12 @@ def validate_project_in_workspace(workspace, project_id, user):
return None return None
try: 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: except ValidationError:
project = None project = None
@ -126,6 +133,7 @@ def validate_project_in_workspace(workspace, project_id, user):
member=user, member=user,
role__gte=ROLE.MEMBER.value, role__gte=ROLE.MEMBER.value,
is_active=True, is_active=True,
deleted_at__isnull=True,
).exists(): ).exists():
return Response( return Response(
{ {
@ -174,6 +182,111 @@ def validate_projects_in_workspace(workspace, project_ids, user):
return normalized_project_ids 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): def gateway_request(method, path, payload=None):
config, error_response = require_gateway_config() config, error_response = require_gateway_config()
if error_response is not None: 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) 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): class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, agent_id): 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): class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, agent_id): def get(self, request, slug, agent_id):

View File

@ -10,6 +10,7 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from plane.app.realtime.issue_events import publish_issue_event_on_commit 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.views.nodedc_logout import is_internal_logout_request_authorized
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
from plane.authentication.views.nodedc_workspace_adapter import parse_json_body 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 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): def html_from_text(value):
text = value.strip() if isinstance(value, str) else "" text = value.strip() if isinstance(value, str) else ""
return f"<p>{escape(text)}</p>" if text else "<p></p>" return f"<p>{escape(text)}</p>" if text else "<p></p>"
@ -586,9 +673,6 @@ class NodeDCAgentProjectResolveEndpoint(View):
project_id = grant.get("project_id") project_id = grant.get("project_id")
if not isinstance(workspace_slug, str) or not workspace_slug: if not isinstance(workspace_slug, str) or not workspace_slug:
continue 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: if isinstance(project_id, str) and project_id:
project_filters.append((workspace_slug, project_id)) project_filters.append((workspace_slug, project_id))
else: else:
@ -596,16 +680,14 @@ class NodeDCAgentProjectResolveEndpoint(View):
projects = [] projects = []
if workspace_slugs: if workspace_slugs:
projects.extend( for workspace_slug in workspace_slugs:
Project.objects.filter( accessible_projects, access_error = get_agent_accessible_projects(request, workspace_slug)
workspace__slug__in=workspace_slugs, if access_error is not None:
deleted_at__isnull=True, continue
archived_at__isnull=True, projects.extend(accessible_projects)
).select_related("workspace")
)
for workspace_slug, project_id in project_filters: for workspace_slug, project_id in project_filters:
project = resolve_project(project_id, workspace_slug) 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) projects.append(project)
unique_projects = {str(project.id): project for project in projects} unique_projects = {str(project.id): project for project in projects}
@ -628,9 +710,9 @@ class NodeDCAgentProjectContextEndpoint(View):
if project is None: if project is None:
return validation_error("project_not_found", status=404) return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence") 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") 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: if project is None:
return validation_error("project_not_found", status=404) return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
actor = ensure_agent_actor(request, project.workspace, project, payload) actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None: if actor is None:
@ -702,9 +784,9 @@ class NodeDCAgentIssueListEndpoint(View):
if project is None: if project is None:
return validation_error("project_not_found", status=404) return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
queryset = ( queryset = (
Issue.issue_objects.filter(project=project) Issue.issue_objects.filter(project=project)
@ -730,9 +812,9 @@ class NodeDCAgentIssueListEndpoint(View):
if project is None: if project is None:
return validation_error("project_not_found", status=404) return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
title = payload.get("title") title = payload.get("title")
if not isinstance(title, str) or not title.strip(): if not isinstance(title, str) or not title.strip():
@ -784,9 +866,9 @@ class NodeDCAgentIssueUpdateEndpoint(View):
if issue is None: if issue is None:
return validation_error("issue_not_found", status=404) return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
detail_layout = ( detail_layout = (
merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks")) merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks"))
@ -841,9 +923,9 @@ class NodeDCAgentIssueMoveEndpoint(View):
if issue is None: if issue is None:
return validation_error("issue_not_found", status=404) return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first() state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first()
if state is None: if state is None:
@ -881,9 +963,9 @@ class NodeDCAgentIssueCommentEndpoint(View):
if issue is None: if issue is None:
return validation_error("issue_not_found", status=404) return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
body = payload.get("body") body = payload.get("body")
if not isinstance(body, str) or not body.strip(): if not isinstance(body, str) or not body.strip():
@ -922,9 +1004,9 @@ class NodeDCAgentIssueLabelsEndpoint(View):
if issue is None: if issue is None:
return validation_error("issue_not_found", status=404) return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
label_ids = payload.get("label_ids") label_ids = payload.get("label_ids")
if not isinstance(label_ids, list): if not isinstance(label_ids, list):
@ -959,9 +1041,9 @@ class NodeDCAgentIssueAssigneesEndpoint(View):
if issue is None: if issue is None:
return validation_error("issue_not_found", status=404) return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) project_access_error = validate_agent_project_access(request, project)
if entitlement_error is not None: if project_access_error is not None:
return entitlement_error return project_access_error
member_ids = payload.get("member_ids") member_ids = payload.get("member_ids")
if not isinstance(member_ids, list): if not isinstance(member_ids, list):

View File

@ -16,11 +16,11 @@ import { SettingsHeading } from "@/components/settings/heading";
// hooks // hooks
import { useWorkspace } from "@/hooks/store/use-workspace"; import { useWorkspace } from "@/hooks/store/use-workspace";
// services // services
import { ProjectService } from "@/services/project/project.service";
import { import {
WorkspaceCodexAgentService, WorkspaceCodexAgentService,
type TCodexAgent, type TCodexAgent,
type TCodexAgentGrant, type TCodexAgentGrant,
type TCodexAgentGrantableWorkspace,
type TCodexAgentSetupPacket, type TCodexAgentSetupPacket,
type TCodexAgentToken, type TCodexAgentToken,
} from "@/services/workspace-codex-agent.service"; } 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 DEFAULT_OPS_AGENT_MCP_ENDPOINT = "https://ops-agents.nodedc.ru/mcp";
const codexAgentService = new WorkspaceCodexAgentService(); const codexAgentService = new WorkspaceCodexAgentService();
const projectService = new ProjectService();
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
type TProps = { type TProps = {
@ -67,7 +66,10 @@ type TAgentSetupCard = {
type TProjectAccessOption = { type TProjectAccessOption = {
id: string; id: string;
identifier?: string | null; identifier?: string | null;
key: string;
name: string; name: string;
workspaceName: string;
workspaceSlug: string;
}; };
export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) { export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) {
@ -77,7 +79,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const [isCreatingAgent, setIsCreatingAgent] = useState(false); const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [newAgentName, setNewAgentName] = useState("Local Codex"); const [newAgentName, setNewAgentName] = useState("Local Codex");
const [newAgentAvatarUrl, setNewAgentAvatarUrl] = useState<string | null>(null); const [newAgentAvatarUrl, setNewAgentAvatarUrl] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState(""); const [selectedProjectKey, setSelectedProjectKey] = useState("");
const [agentDraftNames, setAgentDraftNames] = useState<Record<string, string>>({}); const [agentDraftNames, setAgentDraftNames] = useState<Record<string, string>>({});
const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]); const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({}); const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
@ -100,8 +102,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
} = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_AGENTS_${workspaceSlug}` : null, () => } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_AGENTS_${workspaceSlug}` : null, () =>
codexAgentService.listAgents(workspaceSlug) codexAgentService.listAgents(workspaceSlug)
); );
const { data: projects } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECTS_${workspaceSlug}` : null, () => const { data: projectAccessPayload } = useSWR(
projectService.getProjectsLite(workspaceSlug) isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECT_ACCESS_${workspaceSlug}` : null,
() => codexAgentService.listProjectAccess(workspaceSlug)
); );
const activeAgents = useMemo( const activeAgents = useMemo(
() => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"), () => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"),
@ -134,8 +137,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
}) })
) )
); );
const projectOptions = projects ?? []; const workspaceProjectGroups = projectAccessPayload?.workspaces ?? [];
const effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || ""; const projectOptions = useMemo(() => flattenProjectAccessOptions(workspaceProjectGroups), [workspaceProjectGroups]);
const effectiveSelectedProjectKey = selectedProjectKey || projectOptions[0]?.key || "";
const setupCards = useMemo( const setupCards = useMemo(
() => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards), () => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards),
[createdSetupCards, persistedSetupCards] [createdSetupCards, persistedSetupCards]
@ -191,7 +195,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const handleCreateAgent = async () => { const handleCreateAgent = async () => {
const displayName = newAgentName.trim(); const displayName = newAgentName.trim();
if (!displayName || !effectiveSelectedProjectId) return; const initialGrant = parseProjectGrantKey(effectiveSelectedProjectKey);
if (!displayName || !initialGrant) return;
setIsCreatingAgent(true); setIsCreatingAgent(true);
try { try {
@ -199,11 +204,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
display_name: displayName, display_name: displayName,
avatar_url: newAgentAvatarUrl, avatar_url: newAgentAvatarUrl,
}); });
const grantResponse = await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, { const grantsResponse = await codexAgentService.replaceProjectGrantsAcrossWorkspaces(
project_id: effectiveSelectedProjectId, workspaceSlug,
scopes: TASK_AUTHOR_SCOPES, createResponse.agent.id,
mode: "voluntary", {
}); grants: [
{
workspace_slug: initialGrant.workspaceSlug,
project_id: initialGrant.projectId,
},
],
scopes: TASK_AUTHOR_SCOPES,
mode: "voluntary",
}
);
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, { const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
name: `${displayName} local token`, name: `${displayName} local token`,
}); });
@ -212,9 +226,13 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
[tokenResponse.token_record.id]: tokenResponse.token, [tokenResponse.token_record.id]: tokenResponse.token,
})); }));
setCreatedSetupCards((currentCards) => setCreatedSetupCards((currentCards) =>
upsertSetupCardToken(currentCards, createResponse.agent, tokenResponse.token_record, tokenResponse.setup, [ upsertSetupCardToken(
grantResponse.grant, currentCards,
]) createResponse.agent,
tokenResponse.token_record,
tokenResponse.setup,
grantsResponse.grants
)
); );
await mutateCodexAgents(); await mutateCodexAgents();
await mutateSetupCards(); 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) => { setProjectGrantDrafts((currentDrafts) => {
const currentDraftProjectIds = currentDrafts[agentId] ?? currentProjectIds; const currentDraftGrantKeys = currentDrafts[agentId] ?? currentGrantKeys;
const nextProjectIds = currentDraftProjectIds.includes(projectId) const nextGrantKeys = currentDraftGrantKeys.includes(grantKey)
? currentDraftProjectIds.filter((currentProjectId) => currentProjectId !== projectId) ? currentDraftGrantKeys.filter((currentGrantKey) => currentGrantKey !== grantKey)
: [...currentDraftProjectIds, projectId]; : [...currentDraftGrantKeys, grantKey];
return { return {
...currentDrafts, ...currentDrafts,
[agentId]: nextProjectIds, [agentId]: nextGrantKeys,
}; };
}); });
}; };
const handleSaveProjectAccess = async (agent: TCodexAgent, selectedProjectIds: string[]) => { const handleSaveProjectAccess = async (agent: TCodexAgent, selectedGrantKeys: string[]) => {
const projectIds = [...new Set(selectedProjectIds.filter(Boolean))]; const projectGrants = buildProjectGrantPayload(selectedGrantKeys);
if (projectIds.length === 0) { if (projectGrants.length === 0) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Выберите project", title: "Выберите project",
message: "У агента должен быть доступ хотя бы к одному project в workspace.", message: "У агента должен быть доступ хотя бы к одному project.",
}); });
return; return;
} }
setSavingProjectGrantAgentIds((current) => ({ ...current, [agent.id]: true })); setSavingProjectGrantAgentIds((current) => ({ ...current, [agent.id]: true }));
try { try {
const grantsResponse = await codexAgentService.replaceProjectGrants(workspaceSlug, agent.id, { const grantsResponse = await codexAgentService.replaceProjectGrantsAcrossWorkspaces(workspaceSlug, agent.id, {
project_ids: projectIds, grants: projectGrants,
scopes: TASK_AUTHOR_SCOPES, scopes: TASK_AUTHOR_SCOPES,
mode: "voluntary", mode: "voluntary",
}); });
@ -302,7 +320,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
card.agent.id === agent.id card.agent.id === agent.id
? { ? {
...card, ...card,
grants: mergeAgentGrants(card.grants, grantsResponse.grants, workspaceSlug), grants: mergeAgentGrants(card.grants, grantsResponse.grants, { replaceAllProjectGrants: true }),
} }
: card : card
) )
@ -316,7 +334,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Доступы Codex обновлены", title: "Доступы Codex обновлены",
message: "Agent token теперь работает только с выбранными projects.", message: "Agent token теперь работает только с выбранными workspace/projects.",
}); });
} catch (error: any) { } catch (error: any) {
setToast({ setToast({
@ -457,12 +475,12 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
<span className="text-body-sm-medium text-tertiary">Выберите project</span> <span className="text-body-sm-medium text-tertiary">Выберите project</span>
<select <select
className="nodedc-settings-select h-12 w-full px-4 text-13" className="nodedc-settings-select h-12 w-full px-4 text-13"
value={effectiveSelectedProjectId} value={effectiveSelectedProjectKey}
onChange={(event) => setSelectedProjectId(event.target.value)} onChange={(event) => setSelectedProjectKey(event.target.value)}
> >
{projectOptions.map((project) => ( {projectOptions.map((project) => (
<option key={project.id} value={project.id}> <option key={project.key} value={project.key}>
{project.name} {project.workspaceName} · {project.name}
</option> </option>
))} ))}
</select> </select>
@ -471,7 +489,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
variant="primary" variant="primary"
size="lg" size="lg"
className="nodedc-settings-save-button h-12 min-w-[11rem] self-end px-5" className="nodedc-settings-save-button h-12 min-w-[11rem] self-end px-5"
disabled={!newAgentName.trim() || !effectiveSelectedProjectId} disabled={!newAgentName.trim() || !effectiveSelectedProjectKey}
loading={isCreatingAgent} loading={isCreatingAgent}
onClick={handleCreateAgent} onClick={handleCreateAgent}
> >
@ -498,8 +516,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const setupCard = setupCards.find((card) => card.agent.id === agent.id); const setupCard = setupCards.find((card) => card.agent.id === agent.id);
const agentTokens = setupCard?.tokens ?? []; const agentTokens = setupCard?.tokens ?? [];
const agentGrants = setupCard?.grants ?? []; const agentGrants = setupCard?.grants ?? [];
const currentProjectIds = getGrantedProjectIds(agentGrants, workspaceSlug); const currentGrantKeys = getGrantedProjectKeys(agentGrants);
const draftProjectIds = projectGrantDrafts[agent.id] ?? currentProjectIds; const draftGrantKeys = projectGrantDrafts[agent.id] ?? currentGrantKeys;
const isProjectAccessOpen = openProjectAccessAgentId === agent.id; const isProjectAccessOpen = openProjectAccessAgentId === agent.id;
const isSavingProjectAccess = savingProjectGrantAgentIds[agent.id] === true; const isSavingProjectAccess = savingProjectGrantAgentIds[agent.id] === true;
@ -632,12 +650,12 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
)} )}
<AgentProjectAccessPanel <AgentProjectAccessPanel
currentProjectIds={currentProjectIds} currentGrantKeys={currentGrantKeys}
draftProjectIds={draftProjectIds} draftGrantKeys={draftGrantKeys}
isOpen={isProjectAccessOpen} isOpen={isProjectAccessOpen}
isSaving={isSavingProjectAccess} isSaving={isSavingProjectAccess}
projects={projectOptions} workspaceGroups={workspaceProjectGroups}
onSave={() => void handleSaveProjectAccess(agent, draftProjectIds)} onSave={() => void handleSaveProjectAccess(agent, draftGrantKeys)}
onToggleOpen={() => { onToggleOpen={() => {
setOpenProjectAccessAgentId(isProjectAccessOpen ? null : agent.id); setOpenProjectAccessAgentId(isProjectAccessOpen ? null : agent.id);
setProjectGrantDrafts((currentDrafts) => setProjectGrantDrafts((currentDrafts) =>
@ -645,13 +663,11 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
? currentDrafts ? currentDrafts
: { : {
...currentDrafts, ...currentDrafts,
[agent.id]: currentProjectIds, [agent.id]: currentGrantKeys,
} }
); );
}} }}
onToggleProject={(projectId) => onToggleProject={(grantKey) => handleToggleProjectGrant(agent.id, currentGrantKeys, grantKey)}
handleToggleProjectGrant(agent.id, currentProjectIds, projectId)
}
/> />
</section> </section>
); );
@ -828,19 +844,20 @@ function AgentAvatarButton(props: {
} }
type TAgentProjectAccessPanelProps = { type TAgentProjectAccessPanelProps = {
currentProjectIds: string[]; currentGrantKeys: string[];
draftProjectIds: string[]; draftGrantKeys: string[];
isOpen: boolean; isOpen: boolean;
isSaving: boolean; isSaving: boolean;
onSave: () => void; onSave: () => void;
onToggleOpen: () => void; onToggleOpen: () => void;
onToggleProject: (projectId: string) => void; onToggleProject: (grantKey: string) => void;
projects: TProjectAccessOption[]; workspaceGroups: TCodexAgentGrantableWorkspace[];
}; };
function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) { function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) {
const isDirty = !areProjectSelectionsEqual(props.currentProjectIds, props.draftProjectIds); const isDirty = !areProjectSelectionsEqual(props.currentGrantKeys, props.draftGrantKeys);
const selectedCount = props.draftProjectIds.length; const selectedCount = props.draftGrantKeys.length;
const projectsCount = props.workspaceGroups.reduce((count, group) => count + group.projects.length, 0);
const summary = const summary =
selectedCount === 0 selectedCount === 0
? "Нет выбранных projects" ? "Нет выбранных projects"
@ -856,9 +873,9 @@ function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) {
<FolderKanban className="size-4" /> <FolderKanban className="size-4" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-13 font-semibold text-primary">Доступы к проектам</div> <div className="text-13 font-semibold text-primary">Доступы к workspace и projects</div>
<div className="mt-1 text-12 text-tertiary"> <div className="mt-1 text-12 text-tertiary">
Выберите projects, куда этот agent token может читать и писать карточки. Выберите workspaces/projects, куда этот agent token может читать и писать карточки.
</div> </div>
</div> </div>
</div> </div>
@ -874,44 +891,63 @@ function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) {
{props.isOpen && ( {props.isOpen && (
<div className="nodedc-project-grants-surface mt-4 rounded-3xl p-3"> <div className="nodedc-project-grants-surface mt-4 rounded-3xl p-3">
{props.projects.length > 0 ? ( {projectsCount > 0 ? (
<div className="grid max-h-80 gap-0 overflow-y-auto pr-1"> <div className="grid max-h-96 gap-3 overflow-y-auto pr-1">
{props.projects.map((project) => { {props.workspaceGroups.map((workspaceGroup) => {
const isChecked = props.draftProjectIds.includes(project.id); if (workspaceGroup.projects.length === 0) return null;
return ( return (
<button <div key={workspaceGroup.slug} className="nodedc-project-grants-workspace rounded-2xl p-2">
key={project.id} <div className="px-1 pb-1.5">
type="button" <div className="truncate text-12 font-semibold text-primary">{workspaceGroup.name}</div>
className="nodedc-project-grants-row flex min-h-12 items-center justify-between gap-3 rounded-2xl px-3 py-2 text-left outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none" <div className="mt-0.5 truncate text-10 tracking-wide text-tertiary uppercase">
onClick={() => props.onToggleProject(project.id)} {workspaceGroup.slug}
> </div>
<span className="min-w-0"> </div>
<span className="block truncate text-13 font-medium text-primary">{project.name}</span> <div className="grid gap-0">
{project.identifier && ( {workspaceGroup.projects.map((project) => {
<span className="mt-0.5 block truncate text-11 text-tertiary">{project.identifier}</span> const grantKey = buildProjectGrantKey(workspaceGroup.slug, project.id);
)} const isChecked = props.draftGrantKeys.includes(grantKey);
</span>
<span return (
className={`nodedc-project-grants-check grid size-5 shrink-0 place-items-center rounded-full transition ${ <button
isChecked key={grantKey}
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]" type="button"
: "bg-white/5 text-transparent" className="nodedc-project-grants-row flex min-h-12 items-center justify-between gap-3 rounded-2xl px-3 py-2 text-left outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none"
}`} onClick={() => props.onToggleProject(grantKey)}
> >
<Check className="size-3.5" /> <span className="min-w-0">
</span> <span className="block truncate text-13 font-medium text-primary">{project.name}</span>
</button> {project.identifier && (
<span className="mt-0.5 block truncate text-11 text-tertiary">
{project.identifier}
</span>
)}
</span>
<span
className={`nodedc-project-grants-check grid size-5 shrink-0 place-items-center rounded-full transition ${
isChecked
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
: "bg-white/5 text-transparent"
}`}
>
<Check className="size-3.5" />
</span>
</button>
);
})}
</div>
</div>
); );
})} })}
</div> </div>
) : ( ) : (
<div className="px-3 py-4 text-13 text-secondary">В workspace нет доступных projects.</div> <div className="px-3 py-4 text-13 text-secondary">Нет доступных workspace/projects.</div>
)} )}
<div className="mt-3 flex flex-col gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between"> <div className="mt-3 flex flex-col gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between">
<div className="text-12 text-tertiary"> <div className="text-12 text-tertiary">
Сохранение заменяет grants текущего workspace: снятые галочки сразу отзывают доступ. Сохранение заменяет grants выбранных workspace/projects: снятые галочки сразу отзывают доступ.
</div> </div>
<Button <Button
variant="primary" variant="primary"
@ -988,25 +1024,78 @@ function upsertSetupCardToken(
); );
} }
function getGrantedProjectIds(grants: TCodexAgentGrant[], workspaceSlug: string): string[] { function flattenProjectAccessOptions(workspaceGroups: TCodexAgentGrantableWorkspace[]): TProjectAccessOption[] {
return workspaceGroups.flatMap((workspaceGroup) =>
workspaceGroup.projects.map((project) => ({
id: project.id,
identifier: project.identifier,
key: buildProjectGrantKey(workspaceGroup.slug, project.id),
name: project.name,
workspaceName: workspaceGroup.name,
workspaceSlug: workspaceGroup.slug,
}))
);
}
function buildProjectGrantKey(workspaceSlug: string, projectId: string): string {
return `${workspaceSlug}:${projectId}`;
}
function parseProjectGrantKey(grantKey: string): { workspaceSlug: string; projectId: string } | null {
const separatorIndex = grantKey.indexOf(":");
if (separatorIndex <= 0 || separatorIndex >= grantKey.length - 1) return null;
return {
workspaceSlug: grantKey.slice(0, separatorIndex),
projectId: grantKey.slice(separatorIndex + 1),
};
}
function buildProjectGrantPayload(grantKeys: string[]): { workspace_slug: string; project_id: string }[] {
const seenKeys = new Set<string>();
const grants: { workspace_slug: string; project_id: string }[] = [];
for (const grantKey of grantKeys) {
if (seenKeys.has(grantKey)) continue;
seenKeys.add(grantKey);
const parsedGrant = parseProjectGrantKey(grantKey);
if (!parsedGrant) continue;
grants.push({
workspace_slug: parsedGrant.workspaceSlug,
project_id: parsedGrant.projectId,
});
}
return grants;
}
function getGrantedProjectKeys(grants: TCodexAgentGrant[]): string[] {
return [ return [
...new Set( ...new Set(
grants grants
.filter((grant) => grant.workspace_slug === workspaceSlug && grant.project_id) .filter((grant) => grant.project_id)
.map((grant) => String(grant.project_id)) .map((grant) => buildProjectGrantKey(grant.workspace_slug, String(grant.project_id)))
), ),
].sort(); ].sort();
} }
type TMergeAgentGrantsOptions = {
replaceAllProjectGrants?: boolean;
workspaceSlug?: string;
};
function mergeAgentGrants( function mergeAgentGrants(
currentGrants: TCodexAgentGrant[], currentGrants: TCodexAgentGrant[],
nextGrants: TCodexAgentGrant[], nextGrants: TCodexAgentGrant[],
workspaceSlug?: string options: TMergeAgentGrantsOptions = {}
): TCodexAgentGrant[] { ): TCodexAgentGrant[] {
const grantsByKey = new Map<string, TCodexAgentGrant>(); const grantsByKey = new Map<string, TCodexAgentGrant>();
for (const grant of currentGrants) { for (const grant of currentGrants) {
if (workspaceSlug && grant.workspace_slug === workspaceSlug) continue; if (options.replaceAllProjectGrants && grant.project_id) continue;
if (options.workspaceSlug && grant.workspace_slug === options.workspaceSlug) continue;
grantsByKey.set(buildGrantKey(grant), grant); grantsByKey.set(buildGrantKey(grant), grant);
} }

View File

@ -77,6 +77,31 @@ export type TCodexAgentGrantListResponse = {
grants: TCodexAgentGrant[]; grants: TCodexAgentGrant[];
}; };
export type TCodexAgentProjectGrantInput = {
project_id: string;
workspace_slug: string;
};
export type TCodexAgentGrantableProject = {
id: string;
workspace_slug: string;
name: string;
identifier?: string | null;
};
export type TCodexAgentGrantableWorkspace = {
id: string;
slug: string;
name: string;
role: number;
projects: TCodexAgentGrantableProject[];
};
export type TCodexAgentProjectAccessResponse = {
ok: boolean;
workspaces: TCodexAgentGrantableWorkspace[];
};
export type TCodexAgentSetupResponse = { export type TCodexAgentSetupResponse = {
ok: boolean; ok: boolean;
setup?: TCodexAgentSetupPacket; setup?: TCodexAgentSetupPacket;
@ -95,6 +120,14 @@ export class WorkspaceCodexAgentService extends APIService {
}); });
} }
async listProjectAccess(workspaceSlug: string): Promise<TCodexAgentProjectAccessResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/project-access/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createAgent( async createAgent(
workspaceSlug: string, workspaceSlug: string,
data: { display_name: string; avatar_url?: string | null } data: { display_name: string; avatar_url?: string | null }
@ -158,6 +191,25 @@ export class WorkspaceCodexAgentService extends APIService {
}); });
} }
async replaceProjectGrantsAcrossWorkspaces(
workspaceSlug: string,
agentId: string,
data: {
grants: TCodexAgentProjectGrantInput[];
mode?: TCodexAgentGrantMode;
scopes: string[];
}
): Promise<TCodexAgentGrantListResponse> {
return this.post(
`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/grants/replace-projects/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createToken( async createToken(
workspaceSlug: string, workspaceSlug: string,
agentId: string, agentId: string,

View File

@ -2236,6 +2236,15 @@
.nodedc-project-grants-surface { .nodedc-project-grants-surface {
background: rgba(0, 0, 0, 0.1) !important; background: rgba(0, 0, 0, 0.1) !important;
background-image: none !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
}
.nodedc-project-grants-workspace {
background: transparent !important;
background-image: none !important;
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
@ -2247,6 +2256,7 @@
.nodedc-project-grants-row:focus-visible, .nodedc-project-grants-row:focus-visible,
.nodedc-project-grants-row:active { .nodedc-project-grants-row:active {
background: transparent !important; background: transparent !important;
background-image: none !important;
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;