From f5da73d62b740efdb12ff08948e9c640db26a735 Mon Sep 17 00:00:00 2001 From: isletspace Date: Fri, 4 Sep 2026 12:33:17 +0800 Subject: [PATCH 01/12] feat(ai-accounts): AI service accounts with scope enforcement on v1 API (PLANE-1 M1) Add a new plane/ai_accounts app implementing AI service accounts as first-class bot users: - AIAccount model: bot user + owner (delegation) + workspace - AIScopePolicy model: allow-list of project x resource-type x action (default-deny) - Scope enforcement hooked into the v1 API via AIScopeEnforcementMixin on BaseAPIView/BaseViewSet check_permissions: after the regular role-based permission classes pass, bot requests must also match a scope policy and the owner-subset rule (owner must remain an active member whose role covers the bot's) - Management endpoints (session auth, workspace admin only) for creating/listing/updating/deleting AI accounts and replacing their scope policies; the API token secret is returned once on creation - Tokens reuse APIToken with is_service=True; audit attribution comes for free via crum created_by/updated_by and IssueActivity.actor Core changes are limited to three thin injection points: INSTALLED_APPS registration, one URL include, and the permission mixin in plane/api/views/base.py. plane/db and plane/utils are untouched. --- apps/api/plane/ai_accounts/__init__.py | 3 + apps/api/plane/ai_accounts/apps.py | 9 + apps/api/plane/ai_accounts/constants.py | 61 ++++++ .../ai_accounts/migrations/0001_initial.py | 64 ++++++ .../plane/ai_accounts/migrations/__init__.py | 3 + apps/api/plane/ai_accounts/models.py | 69 +++++++ apps/api/plane/ai_accounts/policy.py | 185 ++++++++++++++++++ apps/api/plane/ai_accounts/serializers.py | 57 ++++++ apps/api/plane/ai_accounts/urls.py | 29 +++ apps/api/plane/ai_accounts/views.py | 184 +++++++++++++++++ apps/api/plane/api/views/base.py | 20 +- apps/api/plane/settings/common.py | 1 + .../contract/api/test_ai_scope_enforcement.py | 175 +++++++++++++++++ .../tests/contract/app/test_ai_accounts.py | 174 ++++++++++++++++ apps/api/plane/urls.py | 1 + 15 files changed, 1033 insertions(+), 2 deletions(-) create mode 100644 apps/api/plane/ai_accounts/__init__.py create mode 100644 apps/api/plane/ai_accounts/apps.py create mode 100644 apps/api/plane/ai_accounts/constants.py create mode 100644 apps/api/plane/ai_accounts/migrations/0001_initial.py create mode 100644 apps/api/plane/ai_accounts/migrations/__init__.py create mode 100644 apps/api/plane/ai_accounts/models.py create mode 100644 apps/api/plane/ai_accounts/policy.py create mode 100644 apps/api/plane/ai_accounts/serializers.py create mode 100644 apps/api/plane/ai_accounts/urls.py create mode 100644 apps/api/plane/ai_accounts/views.py create mode 100644 apps/api/plane/tests/contract/api/test_ai_scope_enforcement.py create mode 100644 apps/api/plane/tests/contract/app/test_ai_accounts.py diff --git a/apps/api/plane/ai_accounts/__init__.py b/apps/api/plane/ai_accounts/__init__.py new file mode 100644 index 00000000000..fcc34a703d7 --- /dev/null +++ b/apps/api/plane/ai_accounts/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. diff --git a/apps/api/plane/ai_accounts/apps.py b/apps/api/plane/ai_accounts/apps.py new file mode 100644 index 00000000000..4ecf1723331 --- /dev/null +++ b/apps/api/plane/ai_accounts/apps.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.apps import AppConfig + + +class AIAccountsConfig(AppConfig): + name = "plane.ai_accounts" diff --git a/apps/api/plane/ai_accounts/constants.py b/apps/api/plane/ai_accounts/constants.py new file mode 100644 index 00000000000..0cbbf74043b --- /dev/null +++ b/apps/api/plane/ai_accounts/constants.py @@ -0,0 +1,61 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# bot_type value written on the bot User rows backing AI accounts. +# Deliberately NOT added to plane.db BotTypeEnum: choices are not enforced at +# the DB level, and keeping plane/db untouched avoids migration conflicts when +# following upstream. +BOT_TYPE_AI_AGENT = "AI_AGENT" + + +class ResourceType: + PROJECT = "project" + MEMBER = "member" + USER = "user" + ASSET = "asset" + ESTIMATE = "estimate" + CYCLE = "cycle" + MODULE = "module" + STICKY = "sticky" + LABEL = "label" + INTAKE = "intake" + WORK_ITEM = "work_item" + COMMENT = "comment" + STATE = "state" + PAGE = "page" + INVITE = "invite" + + +RESOURCE_CHOICES = ( + (ResourceType.PROJECT, "Project"), + (ResourceType.MEMBER, "Member"), + (ResourceType.USER, "User"), + (ResourceType.ASSET, "Asset"), + (ResourceType.ESTIMATE, "Estimate"), + (ResourceType.CYCLE, "Cycle"), + (ResourceType.MODULE, "Module"), + (ResourceType.STICKY, "Sticky"), + (ResourceType.LABEL, "Label"), + (ResourceType.INTAKE, "Intake"), + (ResourceType.WORK_ITEM, "Work Item"), + (ResourceType.COMMENT, "Comment"), + (ResourceType.STATE, "State"), + (ResourceType.PAGE, "Page"), + (ResourceType.INVITE, "Invite"), +) + + +class Action: + READ = "read" + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + +ACTION_CHOICES = ( + (Action.READ, "Read"), + (Action.CREATE, "Create"), + (Action.UPDATE, "Update"), + (Action.DELETE, "Delete"), +) diff --git a/apps/api/plane/ai_accounts/migrations/0001_initial.py b/apps/api/plane/ai_accounts/migrations/0001_initial.py new file mode 100644 index 00000000000..58ae1d76480 --- /dev/null +++ b/apps/api/plane/ai_accounts/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.15 on 2026-09-04 04:29 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('db', '0122_alter_draftissue_assignees_alter_issue_assignees_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AIAccount', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('is_active', models.BooleanField(default=True)), + ('bot_user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ai_account', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_ai_accounts', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_accounts', to='db.workspace')), + ], + options={ + 'verbose_name': 'AI Account', + 'verbose_name_plural': 'AI Accounts', + 'db_table': 'ai_accounts', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='AIScopePolicy', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('resource_type', models.CharField(choices=[('project', 'Project'), ('member', 'Member'), ('user', 'User'), ('asset', 'Asset'), ('estimate', 'Estimate'), ('cycle', 'Cycle'), ('module', 'Module'), ('sticky', 'Sticky'), ('label', 'Label'), ('intake', 'Intake'), ('work_item', 'Work Item'), ('comment', 'Comment'), ('state', 'State'), ('page', 'Page'), ('invite', 'Invite')], max_length=50)), + ('action', models.CharField(choices=[('read', 'Read'), ('create', 'Create'), ('update', 'Update'), ('delete', 'Delete')], max_length=20)), + ('ai_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scope_policies', to='ai_accounts.aiaccount')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ai_scope_policies', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'AI Scope Policy', + 'verbose_name_plural': 'AI Scope Policies', + 'db_table': 'ai_scope_policies', + 'ordering': ('-created_at',), + 'unique_together': {('ai_account', 'project', 'resource_type', 'action', 'deleted_at')}, + }, + ), + ] diff --git a/apps/api/plane/ai_accounts/migrations/__init__.py b/apps/api/plane/ai_accounts/migrations/__init__.py new file mode 100644 index 00000000000..fcc34a703d7 --- /dev/null +++ b/apps/api/plane/ai_accounts/migrations/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. diff --git a/apps/api/plane/ai_accounts/models.py b/apps/api/plane/ai_accounts/models.py new file mode 100644 index 00000000000..a6113ab506e --- /dev/null +++ b/apps/api/plane/ai_accounts/models.py @@ -0,0 +1,69 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.conf import settings +from django.db import models + +from plane.db.models import BaseModel + +from .constants import ACTION_CHOICES, RESOURCE_CHOICES + + +class AIAccount(BaseModel): + """An AI service account: a bot user acting on behalf of an owner. + + The bot user (``User.is_bot=True``) can never log in interactively and only + acts through its API tokens. The owner's permissions cap everything the + account may do — the effective permission is the owner's role intersected + with the account's scope policies (enforced in ``policy.py``). + """ + + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="ai_accounts" + ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_ai_accounts" + ) + bot_user = models.OneToOneField( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ai_account" + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + is_active = models.BooleanField(default=True) + + class Meta: + verbose_name = "AI Account" + verbose_name_plural = "AI Accounts" + db_table = "ai_accounts" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.name} ({self.workspace.slug})" + + +class AIScopePolicy(BaseModel): + """Allow-list entry: the AI account may perform ``action`` on + ``resource_type`` inside ``project`` (null project = workspace-wide). + + Absence of a matching row means denied (default-deny). + """ + + ai_account = models.ForeignKey( + AIAccount, on_delete=models.CASCADE, related_name="scope_policies" + ) + project = models.ForeignKey( + "db.Project", on_delete=models.CASCADE, null=True, blank=True, related_name="ai_scope_policies" + ) + resource_type = models.CharField(max_length=50, choices=RESOURCE_CHOICES) + action = models.CharField(max_length=20, choices=ACTION_CHOICES) + + class Meta: + verbose_name = "AI Scope Policy" + verbose_name_plural = "AI Scope Policies" + db_table = "ai_scope_policies" + ordering = ("-created_at",) + unique_together = ["ai_account", "project", "resource_type", "action", "deleted_at"] + + def __str__(self): + return f"{self.ai_account.name}: {self.action} {self.resource_type}" diff --git a/apps/api/plane/ai_accounts/policy.py b/apps/api/plane/ai_accounts/policy.py new file mode 100644 index 00000000000..d8dd17e32df --- /dev/null +++ b/apps/api/plane/ai_accounts/policy.py @@ -0,0 +1,185 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Scope enforcement for AI service accounts on the public v1 API. + +Hooked from ``plane/api/views/base.py`` (BaseAPIView/BaseViewSet +``check_permissions``) — runs AFTER the regular role-based permission classes, +so a bot request must satisfy both: its own membership role AND a matching +``AIScopePolicy`` row AND the owner-subset rule. All failures are 403 and the +default for anything unmapped is deny. +""" + +from rest_framework.exceptions import PermissionDenied + +from plane.db.models import ProjectMember, WorkspaceMember + +from .constants import Action, ResourceType + +# v1 API url name -> resource type. Built from plane/api/urls/*.py (plus +# "pages"/"pages-detail" which arrive with the v1 pages endpoints). Router +# basenames expand to -list/-detail suffixes. +URL_RESOURCE_MAP = { + # member.py + "project-members": ResourceType.MEMBER, + "project-member": ResourceType.MEMBER, + "project-members-lite": ResourceType.MEMBER, + "workspace-members": ResourceType.MEMBER, + "workspace-members-lite": ResourceType.MEMBER, + # user.py + "users": ResourceType.USER, + # asset.py + "user-assets": ResourceType.ASSET, + "user-assets-detail": ResourceType.ASSET, + "user-server-assets": ResourceType.ASSET, + "user-server-assets-detail": ResourceType.ASSET, + "generic-asset": ResourceType.ASSET, + "generic-asset-detail": ResourceType.ASSET, + # estimate.py + "project-estimate": ResourceType.ESTIMATE, + "estimate-point-list-create": ResourceType.ESTIMATE, + "estimate-point-detail": ResourceType.ESTIMATE, + # cycle.py + "cycles": ResourceType.CYCLE, + "cycles-lite": ResourceType.CYCLE, + "cycle-issues": ResourceType.CYCLE, + "transfer-issues": ResourceType.CYCLE, + "cycle-archive-unarchive": ResourceType.CYCLE, + # module.py + "modules": ResourceType.MODULE, + "modules-lite": ResourceType.MODULE, + "modules-detail": ResourceType.MODULE, + "module-issues": ResourceType.MODULE, + "module-issues-detail": ResourceType.MODULE, + "module-archive": ResourceType.MODULE, + "module-archive-list": ResourceType.MODULE, + "module-unarchive": ResourceType.MODULE, + # sticky.py (router basename "workspace-stickies") + "workspace-stickies-list": ResourceType.STICKY, + "workspace-stickies-detail": ResourceType.STICKY, + # label.py + "label": ResourceType.LABEL, + # intake.py + "intake-issue": ResourceType.INTAKE, + # invite.py (router basename "workspace-invitations") + "workspace-invitations-list": ResourceType.INVITE, + "workspace-invitations-detail": ResourceType.INVITE, + # state.py + "states": ResourceType.STATE, + # project.py + "project": ResourceType.PROJECT, + "project-lite": ResourceType.PROJECT, + "project-archive-unarchive": ResourceType.PROJECT, + "project-summary": ResourceType.PROJECT, + # work_item.py — issue/work-item, links, attachments, relations, activity + "issue-search": ResourceType.WORK_ITEM, + "issue-by-identifier": ResourceType.WORK_ITEM, + "issue": ResourceType.WORK_ITEM, + "link": ResourceType.WORK_ITEM, + "attachment": ResourceType.WORK_ITEM, + "issue-attachment": ResourceType.WORK_ITEM, + "work-item-search": ResourceType.WORK_ITEM, + "work-item-by-identifier": ResourceType.WORK_ITEM, + "work-item-list": ResourceType.WORK_ITEM, + "work-item-detail": ResourceType.WORK_ITEM, + "work-item-link-list": ResourceType.WORK_ITEM, + "work-item-link-detail": ResourceType.WORK_ITEM, + "work-item-attachment-list": ResourceType.WORK_ITEM, + "work-item-attachment-detail": ResourceType.WORK_ITEM, + "work-item-activity-list": ResourceType.WORK_ITEM, + "work-item-activity-detail": ResourceType.WORK_ITEM, + "work-item-relation-list": ResourceType.WORK_ITEM, + "activity": ResourceType.WORK_ITEM, + # work_item.py — comments are a first-class resource type + "comment": ResourceType.COMMENT, + "work-item-comment-list": ResourceType.COMMENT, + "work-item-comment-detail": ResourceType.COMMENT, + # page.py (v1 pages endpoints) + "pages": ResourceType.PAGE, + "pages-detail": ResourceType.PAGE, +} + +ACTION_BY_METHOD = { + "GET": Action.READ, + "HEAD": Action.READ, + "OPTIONS": Action.READ, + "POST": Action.CREATE, + "PATCH": Action.UPDATE, + "PUT": Action.UPDATE, + "DELETE": Action.DELETE, +} + + +def get_ai_account(request): + """Lazily resolve and cache the AIAccount for the request's bot user.""" + cached = getattr(request, "_ai_account_cache", None) + if cached is not None: + return cached + + from .models import AIAccount + + account = ( + AIAccount.objects.filter(bot_user=request.user, is_active=True) + .select_related("owner") + .first() + ) + request._ai_account_cache = account + return account + + +def enforce_ai_scope(request, view): + """Raise PermissionDenied unless the bot's request is fully in scope.""" + account = get_ai_account(request) + if account is None: + raise PermissionDenied("AI account is missing or inactive.") + + # 1. Map the endpoint to (resource_type, action) + url_name = getattr(request.resolver_match, "url_name", None) + resource_type = URL_RESOURCE_MAP.get(url_name) + if resource_type is None: + raise PermissionDenied(f"Endpoint '{url_name}' is not available to AI accounts.") + action = ACTION_BY_METHOD.get(request.method) + if action is None: + raise PermissionDenied(f"Method {request.method} is not available to AI accounts.") + + # 2. Scope policy: project-specific row wins, else workspace-wide row + from .models import AIScopePolicy + + project_id = view.project_id + policies = AIScopePolicy.objects.filter( + ai_account=account, resource_type=resource_type, action=action + ) + if project_id: + allowed = policies.filter(project_id=project_id).exists() or policies.filter( + project__isnull=True + ).exists() + else: + allowed = policies.filter(project__isnull=True).exists() + if not allowed: + raise PermissionDenied( + f"AI account '{account.name}' is not allowed to {action} {resource_type}." + ) + + # 3. Owner-subset: the owner must still be an active workspace member, and + # for project resources an active project member whose role covers the bot's + slug = view.workspace_slug + if not WorkspaceMember.objects.filter( + workspace__slug=slug, member=account.owner, is_active=True + ).exists(): + raise PermissionDenied("AI account owner is not an active workspace member.") + + if project_id: + bot_membership = ProjectMember.objects.filter( + project_id=project_id, member=account.bot_user, is_active=True + ).first() + owner_membership = ProjectMember.objects.filter( + project_id=project_id, member=account.owner, is_active=True + ).first() + if bot_membership is not None: + if owner_membership is None: + raise PermissionDenied("AI account owner is not a member of this project.") + if owner_membership.role < bot_membership.role: + raise PermissionDenied( + "AI account owner no longer holds the role this account was granted." + ) diff --git a/apps/api/plane/ai_accounts/serializers.py b/apps/api/plane/ai_accounts/serializers.py new file mode 100644 index 00000000000..7131f1020f8 --- /dev/null +++ b/apps/api/plane/ai_accounts/serializers.py @@ -0,0 +1,57 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from rest_framework import serializers + +from plane.db.models import User + +from .constants import ACTION_CHOICES, RESOURCE_CHOICES +from .models import AIAccount, AIScopePolicy + + +class BotUserLiteSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "display_name", "email", "avatar_url", "is_bot", "bot_type"] + read_only_fields = fields + + +class AIScopePolicySerializer(serializers.ModelSerializer): + class Meta: + model = AIScopePolicy + fields = ["id", "project", "resource_type", "action"] + read_only_fields = ["id"] + + +class AIAccountSerializer(serializers.ModelSerializer): + bot_user = BotUserLiteSerializer(read_only=True) + scope_policies = AIScopePolicySerializer(many=True, read_only=True) + + class Meta: + model = AIAccount + fields = [ + "id", + "name", + "description", + "is_active", + "workspace", + "owner", + "bot_user", + "scope_policies", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "workspace", "owner", "bot_user", "created_at", "updated_at"] + + +class AIAccountCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255) + description = serializers.CharField(required=False, allow_blank=True, default="") + role = serializers.ChoiceField(choices=((15, "Member"), (5, "Guest")), default=15) + + +class AIScopePolicyInputSerializer(serializers.Serializer): + project = serializers.UUIDField(required=False, allow_null=True, default=None) + resource_type = serializers.ChoiceField(choices=[c[0] for c in RESOURCE_CHOICES]) + action = serializers.ChoiceField(choices=[c[0] for c in ACTION_CHOICES]) diff --git a/apps/api/plane/ai_accounts/urls.py b/apps/api/plane/ai_accounts/urls.py new file mode 100644 index 00000000000..ee949dcd8f4 --- /dev/null +++ b/apps/api/plane/ai_accounts/urls.py @@ -0,0 +1,29 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from .views import ( + AIAccountDetailAPIEndpoint, + AIAccountListCreateAPIEndpoint, + AIScopePolicyAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//ai-accounts/", + AIAccountListCreateAPIEndpoint.as_view(), + name="ai-accounts", + ), + path( + "workspaces//ai-accounts//", + AIAccountDetailAPIEndpoint.as_view(), + name="ai-accounts-detail", + ), + path( + "workspaces//ai-accounts//scopes/", + AIScopePolicyAPIEndpoint.as_view(), + name="ai-accounts-scopes", + ), +] diff --git a/apps/api/plane/ai_accounts/views.py b/apps/api/plane/ai_accounts/views.py new file mode 100644 index 00000000000..cca8f21ca9a --- /dev/null +++ b/apps/api/plane/ai_accounts/views.py @@ -0,0 +1,184 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from uuid import uuid4 +from urllib.parse import urlparse + +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.db import transaction +from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ROLE, allow_permission +from plane.app.views.base import BaseAPIView +from plane.db.models import APIToken, User, Workspace, WorkspaceMember + +from .constants import BOT_TYPE_AI_AGENT +from .models import AIAccount, AIScopePolicy +from .serializers import ( + AIAccountCreateSerializer, + AIAccountSerializer, + AIScopePolicyInputSerializer, + AIScopePolicySerializer, +) + + +class AIAccountListCreateAPIEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug): + accounts = AIAccount.objects.filter(workspace__slug=slug).select_related( + "bot_user", "owner" + ).prefetch_related("scope_policies") + serializer = AIAccountSerializer(accounts, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def post(self, request, slug): + serializer = AIAccountCreateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + workspace = Workspace.objects.get(slug=slug) + owner_membership = WorkspaceMember.objects.filter( + workspace=workspace, member=request.user, is_active=True + ).first() + if owner_membership is None: + return Response( + {"error": "You are not a member of this workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + name = serializer.validated_data["name"] + # The bot's workspace role is capped by the owner's role + role = min(serializer.validated_data["role"], owner_membership.role) + host = urlparse(settings.WEB_URL or "https://plane.so").hostname or "plane.so" + + with transaction.atomic(): + bot_user = User.objects.create( + username=f"ai_bot_{uuid4().hex[:12]}", + email=f"ai+{uuid4().hex}@{host}", + display_name=name, + first_name=name, + last_name="", + is_bot=True, + bot_type=BOT_TYPE_AI_AGENT, + password=make_password(uuid4().hex), + is_password_autoset=True, + ) + WorkspaceMember.objects.create( + workspace=workspace, member=bot_user, role=role + ) + account = AIAccount.objects.create( + workspace=workspace, + owner=request.user, + bot_user=bot_user, + name=name, + description=serializer.validated_data["description"], + ) + token = APIToken.objects.create( + user=bot_user, + label=f"ai:{name}", + user_type=1, + is_service=True, + workspace=workspace, + ) + + data = AIAccountSerializer(account).data + # The token secret is returned exactly once, on creation + data["token"] = token.token + return Response(data, status=status.HTTP_201_CREATED) + + +class AIAccountDetailAPIEndpoint(BaseAPIView): + def get_account(self, slug, pk): + return AIAccount.objects.select_related("bot_user", "owner").prefetch_related( + "scope_policies" + ).get(pk=pk, workspace__slug=slug) + + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug, pk): + account = self.get_account(slug, pk) + return Response(AIAccountSerializer(account).data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def patch(self, request, slug, pk): + account = self.get_account(slug, pk) + name = request.data.get("name", account.name) + description = request.data.get("description", account.description) + is_active = request.data.get("is_active", account.is_active) + + account.name = name + account.description = description + account.is_active = is_active + account.save() + + # Toggling the account toggles its tokens with it + APIToken.objects.filter(user=account.bot_user, is_service=True).update( + is_active=is_active + ) + return Response(AIAccountSerializer(account).data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def delete(self, request, slug, pk): + account = self.get_account(slug, pk) + with transaction.atomic(): + APIToken.objects.filter(user=account.bot_user, is_service=True).update( + is_active=False + ) + WorkspaceMember.objects.filter( + workspace__slug=slug, member=account.bot_user + ).update(is_active=False) + account.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class AIScopePolicyAPIEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug, pk): + account = AIAccount.objects.get(pk=pk, workspace__slug=slug) + policies = AIScopePolicy.objects.filter(ai_account=account) + return Response( + AIScopePolicySerializer(policies, many=True).data, status=status.HTTP_200_OK + ) + + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def put(self, request, slug, pk): + """Replace the account's scope policies with the submitted set.""" + account = AIAccount.objects.get(pk=pk, workspace__slug=slug) + items = request.data.get("scopes", []) + if not isinstance(items, list): + return Response( + {"error": "scopes must be a list"}, status=status.HTTP_400_BAD_REQUEST + ) + serializer = AIScopePolicyInputSerializer(data=items, many=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # All referenced projects must belong to this workspace + project_ids = [i["project"] for i in serializer.validated_data if i["project"]] + valid_count = account.workspace.workspace_project.filter(id__in=project_ids).count() + if valid_count != len(set(project_ids)): + return Response( + {"error": "All projects must belong to this workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + AIScopePolicy.objects.filter(ai_account=account).delete() + AIScopePolicy.objects.bulk_create( + [ + AIScopePolicy( + ai_account=account, + project_id=item["project"], + resource_type=item["resource_type"], + action=item["action"], + ) + for item in serializer.validated_data + ] + ) + policies = AIScopePolicy.objects.filter(ai_account=account) + return Response( + AIScopePolicySerializer(policies, many=True).data, status=status.HTTP_200_OK + ) diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py index 11e0b5a621c..01507d1aa75 100644 --- a/apps/api/plane/api/views/base.py +++ b/apps/api/plane/api/views/base.py @@ -46,7 +46,23 @@ def initial(self, request, *args, **kwargs): timezone.deactivate() -class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator): +class AIScopeEnforcementMixin: + """Enforce per-account scope policies for AI (bot) service accounts. + + Human requests are completely untouched. Bot requests must additionally + satisfy the allow-listed scope policies on their AIAccount (default-deny). + """ + + def check_permissions(self, request): + super().check_permissions(request) + if getattr(request.user, "is_bot", False): + # Local import to avoid a circular import at module load time + from plane.ai_accounts.policy import enforce_ai_scope + + enforce_ai_scope(request, self) + + +class BaseAPIView(AIScopeEnforcementMixin, TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator): authentication_classes = [APIKeyAuthentication] permission_classes = [IsAuthenticated] @@ -151,7 +167,7 @@ def expand(self): return expand if expand else None -class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator): +class BaseViewSet(AIScopeEnforcementMixin, TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator): model = None authentication_classes = [APIKeyAuthentication] diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 7f942a1bdca..80c422cd362 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -111,6 +111,7 @@ "plane.license", "plane.api", "plane.authentication", + "plane.ai_accounts", # Third-party things "rest_framework", "corsheaders", diff --git a/apps/api/plane/tests/contract/api/test_ai_scope_enforcement.py b/apps/api/plane/tests/contract/api/test_ai_scope_enforcement.py new file mode 100644 index 00000000000..a0cd7e598b6 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_ai_scope_enforcement.py @@ -0,0 +1,175 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests: AI service-account scope enforcement on the public v1 API.""" + +import pytest +from rest_framework import status + +from plane.ai_accounts.constants import BOT_TYPE_AI_AGENT +from plane.ai_accounts.models import AIAccount, AIScopePolicy +from plane.db.models import APIToken, Issue, Project, ProjectMember, WorkspaceMember + + +@pytest.fixture +def project(db, workspace, create_user): + """Project with the human user as admin member.""" + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + return project + + +@pytest.fixture +def bot_user(db): + from plane.db.models import User + + return User.objects.create( + username="ai_bot_test", + email="ai_bot_test@plane.so", + display_name="Test Bot", + first_name="Test Bot", + is_bot=True, + bot_type=BOT_TYPE_AI_AGENT, + ) + + +@pytest.fixture +def ai_account(db, workspace, create_user, bot_user, project): + """AI account owned by the human user; bot is a member of both levels.""" + WorkspaceMember.objects.create( + workspace=workspace, member=bot_user, role=15, is_active=True + ) + ProjectMember.objects.create( + project=project, member=bot_user, role=15, is_active=True, workspace=workspace + ) + return AIAccount.objects.create( + workspace=workspace, owner=create_user, bot_user=bot_user, name="test-bot" + ) + + +@pytest.fixture +def bot_token(db, bot_user, workspace): + return APIToken.objects.create( + user=bot_user, + label="ai:test-bot", + token="test-ai-bot-token-12345", + user_type=1, + is_service=True, + workspace=workspace, + ) + + +@pytest.fixture +def bot_client(api_client, bot_token): + api_client.credentials(HTTP_X_API_KEY=bot_token.token) + return api_client + + +def issues_url(workspace, project): + return f"/api/v1/workspaces/{workspace.slug}/projects/{project.id}/issues/" + + +@pytest.mark.contract +class TestAIScopeEnforcement: + """Scope policy enforcement for bot tokens on the v1 API.""" + + def test_allowed_when_policy_matches(self, bot_client, ai_account, workspace, project): + AIScopePolicy.objects.create( + ai_account=ai_account, + project=project, + resource_type="work_item", + action="read", + ) + response = bot_client.get(issues_url(workspace, project)) + assert response.status_code == status.HTTP_200_OK + + def test_denied_without_policy(self, bot_client, ai_account, workspace, project): + response = bot_client.get(issues_url(workspace, project)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_denied_for_other_action(self, bot_client, ai_account, workspace, project): + AIScopePolicy.objects.create( + ai_account=ai_account, + project=project, + resource_type="work_item", + action="read", + ) + response = bot_client.post( + issues_url(workspace, project), {"name": "bot issue"}, format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_workspace_wide_policy_applies(self, bot_client, ai_account, workspace, project): + AIScopePolicy.objects.create( + ai_account=ai_account, + project=None, + resource_type="work_item", + action="read", + ) + response = bot_client.get(issues_url(workspace, project)) + assert response.status_code == status.HTTP_200_OK + + def test_denied_when_account_inactive(self, bot_client, ai_account, workspace, project): + ai_account.is_active = False + ai_account.save() + AIScopePolicy.objects.create( + ai_account=ai_account, + project=project, + resource_type="work_item", + action="read", + ) + response = bot_client.get(issues_url(workspace, project)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_denied_when_owner_leaves_project(self, bot_client, ai_account, workspace, project, create_user): + AIScopePolicy.objects.create( + ai_account=ai_account, + project=project, + resource_type="work_item", + action="read", + ) + ProjectMember.objects.filter(project=project, member=create_user).update( + is_active=False + ) + response = bot_client.get(issues_url(workspace, project)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_denied_when_owner_role_below_bot(self, bot_client, ai_account, workspace, project, create_user): + AIScopePolicy.objects.create( + ai_account=ai_account, + project=project, + resource_type="work_item", + action="read", + ) + # Bot somehow holds a higher project role than its owner + ProjectMember.objects.filter(project=project, member=create_user).update(role=15) + ProjectMember.objects.filter(project=project, member=ai_account.bot_user).update(role=20) + response = bot_client.get(issues_url(workspace, project)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_human_token_unaffected(self, api_key_client, ai_account, workspace, project): + """Regression: human API tokens must not hit scope enforcement.""" + response = api_key_client.get(issues_url(workspace, project)) + assert response.status_code == status.HTTP_200_OK + + def test_audit_trail_created_by_bot(self, bot_client, ai_account, workspace, project): + AIScopePolicy.objects.create( + ai_account=ai_account, + project=project, + resource_type="work_item", + action="create", + ) + response = bot_client.post( + issues_url(workspace, project), {"name": "bot created issue"}, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + issue = Issue.objects.get(pk=response.data["id"]) + assert issue.created_by_id == ai_account.bot_user_id diff --git a/apps/api/plane/tests/contract/app/test_ai_accounts.py b/apps/api/plane/tests/contract/app/test_ai_accounts.py new file mode 100644 index 00000000000..7c73d890f54 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_ai_accounts.py @@ -0,0 +1,174 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests: AI account management endpoints (internal app API, session auth).""" + +import pytest +from rest_framework import status + +from plane.ai_accounts.constants import BOT_TYPE_AI_AGENT +from plane.ai_accounts.models import AIAccount +from plane.db.models import APIToken, Project, ProjectMember, User, WorkspaceMember + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + return project + + +@pytest.fixture +def member_user(db): + """A non-admin workspace member.""" + user = User.objects.create(email="member@plane.so", username="member-user") + user.set_password("password") + user.save() + return user + + +def accounts_url(slug): + return f"/api/workspaces/{slug}/ai-accounts/" + + +@pytest.mark.contract +class TestAIAccountManagement: + def test_create_account_returns_token_once(self, session_client, workspace): + response = session_client.post( + accounts_url(workspace.slug), + {"name": "review-bot", "description": "RENG reviewer", "role": 15}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.data + assert data["token"].startswith("plane_api_") + assert data["bot_user"]["is_bot"] is True + assert data["bot_user"]["bot_type"] == BOT_TYPE_AI_AGENT + + account = AIAccount.objects.get(pk=data["id"]) + token = APIToken.objects.get(user=account.bot_user) + assert token.is_service is True + assert token.user_type == 1 + # Bot joined the workspace as a member + assert WorkspaceMember.objects.filter( + workspace=workspace, member=account.bot_user, role=15, is_active=True + ).exists() + + def test_create_forbidden_for_non_admin( + self, api_client, workspace, member_user + ): + WorkspaceMember.objects.create( + workspace=workspace, member=member_user, role=15, is_active=True + ) + api_client.force_authenticate(user=member_user) + response = api_client.post( + accounts_url(workspace.slug), {"name": "x", "role": 15}, format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_list_and_detail(self, session_client, workspace): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-1", "role": 15}, format="json" + ) + account_id = create.data["id"] + + response = session_client.get(accounts_url(workspace.slug)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert "token" not in response.data[0] + + response = session_client.get(f"{accounts_url(workspace.slug)}{account_id}/") + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "bot-1" + assert "token" not in response.data + + def test_patch_deactivate_disables_token(self, session_client, workspace): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-2", "role": 15}, format="json" + ) + account = AIAccount.objects.get(pk=create.data["id"]) + + response = session_client.patch( + f"{accounts_url(workspace.slug)}{account.id}/", + {"is_active": False}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["is_active"] is False + assert not APIToken.objects.get(user=account.bot_user).is_active + + def test_delete_disables_everything(self, session_client, workspace): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-3", "role": 15}, format="json" + ) + account = AIAccount.objects.get(pk=create.data["id"]) + + response = session_client.delete(f"{accounts_url(workspace.slug)}{account.id}/") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not APIToken.objects.get(user=account.bot_user).is_active + assert not WorkspaceMember.objects.get( + workspace=workspace, member=account.bot_user + ).is_active + + def test_scopes_replace(self, session_client, workspace, project): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-4", "role": 15}, format="json" + ) + account_id = create.data["id"] + + response = session_client.put( + f"{accounts_url(workspace.slug)}{account_id}/scopes/", + { + "scopes": [ + { + "project": str(project.id), + "resource_type": "work_item", + "action": "read", + }, + {"project": None, "resource_type": "comment", "action": "create"}, + ] + }, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 + + # Second PUT replaces rather than appends + response = session_client.put( + f"{accounts_url(workspace.slug)}{account_id}/scopes/", + {"scopes": [{"project": None, "resource_type": "state", "action": "read"}]}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["resource_type"] == "state" + + def test_scopes_reject_foreign_project(self, session_client, workspace, project): + import uuid + + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-5", "role": 15}, format="json" + ) + # A project id that does not exist in this workspace + response = session_client.put( + f"{accounts_url(workspace.slug)}{create.data['id']}/scopes/", + { + "scopes": [ + { + "project": str(uuid.uuid4()), + "resource_type": "work_item", + "action": "read", + } + ] + }, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/apps/api/plane/urls.py b/apps/api/plane/urls.py index 44761bd6b55..ff74975d7db 100644 --- a/apps/api/plane/urls.py +++ b/apps/api/plane/urls.py @@ -20,6 +20,7 @@ path("api/public/", include("plane.space.urls")), path("api/instances/", include("plane.license.urls")), path("api/v1/", include("plane.api.urls")), + path("api/", include("plane.ai_accounts.urls")), path("auth/", include("plane.authentication.urls")), path("", include("plane.web.urls")), ] From a57d08cfdab596b778d2626fe566184c1d1c7ec6 Mon Sep 17 00:00:00 2001 From: isletspace Date: Fri, 4 Sep 2026 13:29:08 +0800 Subject: [PATCH 02/12] feat(ai-accounts): workspace settings UI for AI service accounts (PLANE-11) --- .../(workspace)/ai-accounts/header.tsx | 42 ++++ .../settings/(workspace)/ai-accounts/page.tsx | 109 ++++++++ apps/web/app/routes/core.ts | 4 + .../components/ai-accounts/account-form.tsx | 98 ++++++++ .../ai-accounts/ai-accounts-list-item.tsx | 120 +++++++++ .../ai-accounts/ai-accounts-list.tsx | 27 ++ .../core/components/ai-accounts/constants.ts | 32 +++ .../ai-accounts/create-account-modal.tsx | 88 +++++++ .../ai-accounts/delete-account-modal.tsx | 70 ++++++ .../ai-accounts/edit-account-modal.tsx | 78 ++++++ .../ai-accounts/generated-token-details.tsx | 65 +++++ apps/web/core/components/ai-accounts/index.ts | 11 + .../components/ai-accounts/scopes-modal.tsx | 233 ++++++++++++++++++ .../settings/workspace/sidebar/item-icon.tsx | 2 + .../ui/loader/settings/ai-account.tsx | 30 +++ apps/web/core/services/ai-account.service.ts | 76 ++++++ packages/constants/src/settings/workspace.ts | 9 +- packages/i18n/src/locales/en/empty-state.json | 5 + .../src/locales/en/workspace-settings.json | 76 ++++++ .../i18n/src/locales/zh-CN/empty-state.json | 5 + .../src/locales/zh-CN/workspace-settings.json | 76 ++++++ packages/types/src/ai-account.ts | 71 ++++++ packages/types/src/index.ts | 1 + packages/types/src/settings.ts | 8 +- 24 files changed, 1334 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/page.tsx create mode 100644 apps/web/core/components/ai-accounts/account-form.tsx create mode 100644 apps/web/core/components/ai-accounts/ai-accounts-list-item.tsx create mode 100644 apps/web/core/components/ai-accounts/ai-accounts-list.tsx create mode 100644 apps/web/core/components/ai-accounts/constants.ts create mode 100644 apps/web/core/components/ai-accounts/create-account-modal.tsx create mode 100644 apps/web/core/components/ai-accounts/delete-account-modal.tsx create mode 100644 apps/web/core/components/ai-accounts/edit-account-modal.tsx create mode 100644 apps/web/core/components/ai-accounts/generated-token-details.tsx create mode 100644 apps/web/core/components/ai-accounts/index.ts create mode 100644 apps/web/core/components/ai-accounts/scopes-modal.tsx create mode 100644 apps/web/core/components/ui/loader/settings/ai-account.tsx create mode 100644 apps/web/core/services/ai-account.service.ts create mode 100644 packages/types/src/ai-account.ts diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/header.tsx new file mode 100644 index 00000000000..b1fbee6e5f2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const AIAccountsWorkspaceSettingsHeader = observer(function AIAccountsWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS["ai-accounts"]; + const Icon = WORKSPACE_SETTINGS_ICONS["ai-accounts"]; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/page.tsx new file mode 100644 index 00000000000..ed0fa88e42d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/page.tsx @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +// components +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import { AIAccountsList, CreateAIAccountModal } from "@/components/ai-accounts"; +import { AI_ACCOUNTS_LIST } from "@/components/ai-accounts/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsHeading } from "@/components/settings/heading"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { AIAccountSettingsLoader } from "@/components/ui/loader/settings/ai-account"; +import { aiAccountService } from "@/services/ai-account.service"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { AIAccountsWorkspaceSettingsHeader } from "./header"; + +function AIAccountsListPage({ params }: Route.ComponentProps) { + // states + const [showCreateAccountModal, setShowCreateAccountModal] = useState(false); + // router + const { workspaceSlug } = params; + // plane hooks + const { t } = useTranslation(); + // mobx store + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + const { data: accounts, isLoading } = useSWR( + canPerformWorkspaceAdminActions ? AI_ACCOUNTS_LIST(workspaceSlug) : null, + canPerformWorkspaceAdminActions ? () => aiAccountService.fetchAIAccountsList(workspaceSlug) : null + ); + + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.ai_accounts.title")}` + : undefined; + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + return ( + }> + +
+ setShowCreateAccountModal(false)} + workspaceSlug={workspaceSlug} + /> + setShowCreateAccountModal(true)}> + {t("workspace_settings.settings.ai_accounts.add_account")} + + } + /> + {isLoading || !accounts ? ( +
+ +
+ ) : accounts.length > 0 ? ( +
+ +
+ ) : ( +
+
+ { + setShowCreateAccountModal(true); + }, + }, + ]} + align="start" + rootClassName="py-20" + /> +
+
+ )} +
+
+ ); +} + +export default observer(AIAccountsListPage); diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index c9c82bd2475..18841b128b4 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -282,6 +282,10 @@ export const coreRoutes: RouteConfigEntry[] = [ ":workspaceSlug/settings/webhooks/:webhookId", "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx" ), + route( + ":workspaceSlug/settings/ai-accounts", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-accounts/page.tsx" + ), ]), // -------------------------------------------------------------------- diff --git a/apps/web/core/components/ai-accounts/account-form.tsx b/apps/web/core/components/ai-accounts/account-form.tsx new file mode 100644 index 00000000000..089eaf2db5b --- /dev/null +++ b/apps/web/core/components/ai-accounts/account-form.tsx @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Controller, useForm } from "react-hook-form"; +// plane imports +import { Field } from "@makeplane/propel/components/field"; +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +// ui +import { TextArea } from "@plane/ui"; + +export type TAIAccountFormValues = { + name: string; + description: string; +}; + +type Props = { + defaultValues: TAIAccountFormValues; + handleClose: () => void; + isSubmitting: boolean; + loadingLabel: string; + submitLabel: string; + title: string; + onSubmit: (data: TAIAccountFormValues) => Promise; +}; + +export function AIAccountForm(props: Props) { + const { defaultValues, handleClose, isSubmitting, loadingLabel, submitLabel, title, onSubmit } = props; + // form + const { + control, + formState: { errors }, + handleSubmit, + } = useForm({ defaultValues }); + // hooks + const { t } = useTranslation(); + + return ( +
+
+

{title}

+
+
+ + val.trim() !== "" || t("workspace_settings.settings.ai_accounts.modal.name_required"), + }} + render={({ field: { value, onChange } }) => ( + + + + + + )} + /> + {errors.name && {errors.name.message}} +
+ ( +