FEAT - TASKER CODEX: user-scoped multi-workspace grants
This commit is contained in:
parent
8f87f03ee6
commit
491a2b52c8
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,9 @@ from .codex_agents import (
|
||||||
CodexAgentDetailEndpoint,
|
CodexAgentDetailEndpoint,
|
||||||
CodexAgentGrantListEndpoint,
|
CodexAgentGrantListEndpoint,
|
||||||
CodexAgentGrantReplaceEndpoint,
|
CodexAgentGrantReplaceEndpoint,
|
||||||
|
CodexAgentGrantReplaceProjectsEndpoint,
|
||||||
CodexAgentListEndpoint,
|
CodexAgentListEndpoint,
|
||||||
|
CodexAgentProjectAccessEndpoint,
|
||||||
CodexAgentRevokeEndpoint,
|
CodexAgentRevokeEndpoint,
|
||||||
CodexAgentSetupEndpoint,
|
CodexAgentSetupEndpoint,
|
||||||
CodexAgentTokenListEndpoint,
|
CodexAgentTokenListEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
createResponse.agent.id,
|
||||||
|
{
|
||||||
|
grants: [
|
||||||
|
{
|
||||||
|
workspace_slug: initialGrant.workspaceSlug,
|
||||||
|
project_id: initialGrant.projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
scopes: TASK_AUTHOR_SCOPES,
|
scopes: TASK_AUTHOR_SCOPES,
|
||||||
mode: "voluntary",
|
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,22 +891,37 @@ 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 (
|
||||||
|
<div key={workspaceGroup.slug} className="nodedc-project-grants-workspace rounded-2xl p-2">
|
||||||
|
<div className="px-1 pb-1.5">
|
||||||
|
<div className="truncate text-12 font-semibold text-primary">{workspaceGroup.name}</div>
|
||||||
|
<div className="mt-0.5 truncate text-10 tracking-wide text-tertiary uppercase">
|
||||||
|
{workspaceGroup.slug}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-0">
|
||||||
|
{workspaceGroup.projects.map((project) => {
|
||||||
|
const grantKey = buildProjectGrantKey(workspaceGroup.slug, project.id);
|
||||||
|
const isChecked = props.draftGrantKeys.includes(grantKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={grantKey}
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
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(project.id)}
|
onClick={() => props.onToggleProject(grantKey)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-13 font-medium text-primary">{project.name}</span>
|
<span className="block truncate text-13 font-medium text-primary">{project.name}</span>
|
||||||
{project.identifier && (
|
{project.identifier && (
|
||||||
<span className="mt-0.5 block truncate text-11 text-tertiary">{project.identifier}</span>
|
<span className="mt-0.5 block truncate text-11 text-tertiary">
|
||||||
|
{project.identifier}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
|
@ -905,13 +937,17 @@ function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue