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..ce0c9eb2cf9 --- /dev/null +++ b/apps/api/plane/ai_accounts/apps.py @@ -0,0 +1,12 @@ +# 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" + + def ready(self): + from . import signals # noqa: F401 diff --git a/apps/api/plane/ai_accounts/constants.py b/apps/api/plane/ai_accounts/constants.py new file mode 100644 index 00000000000..2e9c8405080 --- /dev/null +++ b/apps/api/plane/ai_accounts/constants.py @@ -0,0 +1,75 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Django imports +from django.db.models import Q + +# 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" + +# Predicate for member querysets: AI agent bots are treated as regular +# members (visible, removable, role-editable) while other bot types +# (e.g. WORKSPACE_SEED) stay hidden. Use as a positional filter arg. +AI_VISIBLE_MEMBER_Q = Q(member__is_bot=False) | Q(member__bot_type=BOT_TYPE_AI_AGENT) + + +class ResourceType: + # Wildcard: a policy row with this resource type matches any resource + ALL = "all" + 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.ALL, "All"), + (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: + # Wildcard: a policy row with this action matches any action + ALL = "all" + READ = "read" + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + +ACTION_CHOICES = ( + (Action.ALL, "All"), + (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/0002_alter_aiscopepolicy_action_and_more.py b/apps/api/plane/ai_accounts/migrations/0002_alter_aiscopepolicy_action_and_more.py new file mode 100644 index 00000000000..3c57f5f5f2e --- /dev/null +++ b/apps/api/plane/ai_accounts/migrations/0002_alter_aiscopepolicy_action_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-09-04 06:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='aiscopepolicy', + name='action', + field=models.CharField(choices=[('all', 'All'), ('read', 'Read'), ('create', 'Create'), ('update', 'Update'), ('delete', 'Delete')], max_length=20), + ), + migrations.AlterField( + model_name='aiscopepolicy', + name='resource_type', + field=models.CharField(choices=[('all', 'All'), ('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), + ), + ] 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..64579c93d8c --- /dev/null +++ b/apps/api/plane/ai_accounts/policy.py @@ -0,0 +1,195 @@ +# 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, +} + + +# Sentinel for the per-request AIAccount cache: a cached None (no account) +# must not be confused with "not resolved yet". +_CACHE_MISS = object() + + +def get_ai_account(request): + """Lazily resolve and cache the AIAccount for the request's bot user.""" + cached = getattr(request, "_ai_account_cache", _CACHE_MISS) + if cached is not _CACHE_MISS: + 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. + # A policy row may use the "all" wildcard for resource_type and/or action. + from .models import AIScopePolicy + + project_id = view.project_id + policies = AIScopePolicy.objects.filter( + ai_account=account, + resource_type__in=[resource_type, ResourceType.ALL], + action__in=[action, Action.ALL], + ) + 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. + # Endpoints without a workspace in the URL (e.g. /users/me/) fall back to + # the account's own workspace. + slug = view.workspace_slug or account.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/signals.py b/apps/api/plane/ai_accounts/signals.py new file mode 100644 index 00000000000..2c951380818 --- /dev/null +++ b/apps/api/plane/ai_accounts/signals.py @@ -0,0 +1,52 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Signals for AI service accounts. + +Workspace membership of an AI bot inherits into projects: when a new project +is created, every active AI account of that workspace is added as a project +member automatically (decision recorded on PLANE-11). Implemented as a signal +so upstream project-creation code paths stay untouched. +""" + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from plane.db.models import Project, ProjectMember, WorkspaceMember + + +@receiver(post_save, sender=Project) +def add_ai_bots_to_new_project(sender, instance, created, **kwargs): + if not created: + return + + from .models import AIAccount + + accounts = AIAccount.objects.filter( + workspace_id=instance.workspace_id, is_active=True + ).select_related("bot_user") + for account in accounts: + # The project role mirrors the bot's workspace role. A bot that has + # been removed from the workspace has no active membership — skip it + # instead of re-activating it in the new project with a default role. + role = WorkspaceMember.objects.filter( + workspace_id=instance.workspace_id, + member=account.bot_user, + is_active=True, + ).values_list("role", flat=True).first() + if role is None: + continue + membership = ProjectMember.objects.filter( + project=instance, member=account.bot_user + ).first() + if membership is None: + ProjectMember.objects.create( + project=instance, + member=account.bot_user, + role=role, + workspace_id=instance.workspace_id, + ) + elif not membership.is_active: + membership.is_active = True + membership.save(update_fields=["is_active", "updated_at"]) 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/utils.py b/apps/api/plane/ai_accounts/utils.py new file mode 100644 index 00000000000..7a46562020d --- /dev/null +++ b/apps/api/plane/ai_accounts/utils.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.db.models import Count, Q + +from plane.db.models import Project + + +def is_sole_project_admin(slug, member_id): + """True when the member is the only active admin of any project.""" + return ( + Project.objects.annotate( + # Count active admins, not all active members: a project with one + # admin plus other non-admin members still loses its only admin + total_admins=Count( + "project_projectmember", + filter=Q( + project_projectmember__role=20, + project_projectmember__is_active=True, + ), + ), + member_with_role=Count( + "project_projectmember", + filter=Q( + project_projectmember__member_id=member_id, + project_projectmember__role=20, + project_projectmember__is_active=True, + ), + ), + ) + .filter(total_admins=1, member_with_role=1, workspace__slug=slug) + .exists() + ) diff --git a/apps/api/plane/ai_accounts/views.py b/apps/api/plane/ai_accounts/views.py new file mode 100644 index 00000000000..72abf22ad04 --- /dev/null +++ b/apps/api/plane/ai_accounts/views.py @@ -0,0 +1,262 @@ +# 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 UUID, 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 django.utils import timezone +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, FileAsset, ProjectMember, User, Workspace, WorkspaceMember + +from .constants import BOT_TYPE_AI_AGENT +from .models import AIAccount, AIScopePolicy +from .serializers import ( + AIAccountCreateSerializer, + AIAccountSerializer, + AIScopePolicyInputSerializer, + AIScopePolicySerializer, +) +from .utils import is_sole_project_admin + + +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 + ) + # Workspace membership inherits into all existing projects; + # future projects are covered by the post_save signal + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project_id=project_id, + member=bot_user, + role=role, + workspace=workspace, + ) + for project_id in workspace.workspace_project.values_list( + "id", flat=True + ) + ] + ) + 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) + + bot_user = account.bot_user + avatar_provided = "avatar" in request.data + new_avatar_asset = None + old_asset_id = bot_user.avatar_asset_id + + # Custom avatar for the backing bot user. The avatar rides the + # avatar_asset FK (same model as regular user avatars): the asset is + # uploaded as a workspace asset bound to the bot, so it is never + # touched by the uploader's own profile-avatar replacement flow. + # The asset is resolved and validated BEFORE anything is saved, so an + # invalid avatar rejects the whole PATCH instead of leaving the + # account fields half-updated. + if avatar_provided: + avatar_url = request.data.get("avatar") or "" + if avatar_url: + asset_id = avatar_url.rstrip("/").rsplit("/", 1)[-1] + try: + asset_id = UUID(asset_id) + except ValueError: + asset_id = None + # Only assets uploaded for THIS bot (workspace asset with the + # bot as entity) may be attached — anything else is rejected. + new_avatar_asset = ( + FileAsset.objects.filter( + id=asset_id, + workspace__slug=slug, + entity_type=FileAsset.EntityTypeContext.USER_AVATAR, + entity_identifier=str(bot_user.id), + is_deleted=False, + ).first() + if asset_id + else None + ) + if new_avatar_asset is None: + return Response( + {"error": "Avatar asset not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + account.name = name + account.description = description + account.is_active = is_active + account.save() + + if avatar_provided: + bot_user.avatar_asset = new_avatar_asset + bot_user.avatar = "" + bot_user.save(update_fields=["avatar", "avatar_asset", "updated_at"]) + + # Delete the previously attached avatar asset + if old_asset_id and old_asset_id != bot_user.avatar_asset_id: + FileAsset.objects.filter(id=old_asset_id).update( + is_deleted=True, deleted_at=timezone.now() + ) + + # Toggling the account toggles its tokens with it + APIToken.objects.filter(user=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) + # Deleting the account removes the bot from every project; refuse when + # the bot is the only active admin of one (same protection as removing + # a human member) + if is_sole_project_admin(slug, account.bot_user_id): + return Response( + { + "error": "This AI account is the only admin of some projects. Promote another member to admin before deleting it." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + 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/app/serializers/user.py b/apps/api/plane/app/serializers/user.py index aeef4ee28fb..6ce9ebbd743 100644 --- a/apps/api/plane/app/serializers/user.py +++ b/apps/api/plane/app/serializers/user.py @@ -148,9 +148,10 @@ class Meta: "avatar", "avatar_url", "is_bot", + "bot_type", "display_name", ] - read_only_fields = ["id", "is_bot"] + read_only_fields = ["id", "is_bot", "bot_type"] class UserAdminLiteSerializer(BaseSerializer): @@ -163,11 +164,12 @@ class Meta: "avatar", "avatar_url", "is_bot", + "bot_type", "display_name", "email", "last_login_medium", ] - read_only_fields = ["id", "is_bot"] + read_only_fields = ["id", "is_bot", "bot_type"] class ChangePasswordSerializer(serializers.Serializer): diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 973462182ab..fe96262c1f4 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -19,6 +19,7 @@ from plane.app.permissions import WorkspaceUserPermission from plane.db.models import Project, ProjectMember, ProjectUserProperty, WorkspaceMember +from plane.ai_accounts.constants import AI_VISIBLE_MEMBER_Q from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.utils.host import base_host from plane.app.permissions.base import allow_permission, ROLE @@ -36,7 +37,7 @@ def get_queryset(self): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(member__is_bot=False) + .filter(AI_VISIBLE_MEMBER_Q) .filter() .select_related("project") .select_related("member") @@ -157,9 +158,9 @@ def create(self, request, slug, project_id): def list(self, request, slug, project_id): # Get the list of project members for the project project_members = ProjectMember.objects.filter( + AI_VISIBLE_MEMBER_Q, project_id=project_id, workspace__slug=slug, - member__is_bot=False, is_active=True, member__member_workspace__workspace__slug=slug, member__member_workspace__is_active=True, @@ -179,10 +180,10 @@ def retrieve(self, request, slug, project_id, pk): project_member = ( ProjectMember.objects.filter( + AI_VISIBLE_MEMBER_Q, pk=pk, project_id=project_id, workspace__slug=slug, - member__is_bot=False, is_active=True, ) .select_related("project", "member", "workspace") @@ -290,10 +291,10 @@ def partial_update(self, request, slug, project_id, pk): @allow_permission([ROLE.ADMIN]) def destroy(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( + AI_VISIBLE_MEMBER_Q, workspace__slug=slug, project_id=project_id, pk=pk, - member__is_bot=False, is_active=True, ) # check requesting user role diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py index 67c7637a8c2..e059ef99f90 100644 --- a/apps/api/plane/app/views/workspace/member.py +++ b/apps/api/plane/app/views/workspace/member.py @@ -21,7 +21,10 @@ WorkSpaceMemberSerializer, ) from plane.app.views.base import BaseAPIView -from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue +from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue, APIToken +from plane.ai_accounts.constants import AI_VISIBLE_MEMBER_Q +from plane.ai_accounts.models import AIAccount +from plane.ai_accounts.utils import is_sole_project_admin from plane.utils.cache import invalidate_cache from .. import BaseViewSet @@ -76,7 +79,7 @@ def retrieve(self, request, slug, pk): @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def partial_update(self, request, slug, pk): workspace_member = WorkspaceMember.objects.get( - pk=pk, workspace__slug=slug, member__is_bot=False, is_active=True + AI_VISIBLE_MEMBER_Q, pk=pk, workspace__slug=slug, is_active=True ) if request.user.id == workspace_member.member_id: return Response( @@ -99,7 +102,7 @@ def partial_update(self, request, slug, pk): def destroy(self, request, slug, pk): # Check the user role who is deleting the user workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, pk=pk, member__is_bot=False, is_active=True + AI_VISIBLE_MEMBER_Q, workspace__slug=slug, pk=pk, is_active=True ) # check requesting user role @@ -140,6 +143,24 @@ def destroy(self, request, slug, pk): status=status.HTTP_400_BAD_REQUEST, ) + # Removing an AI agent bot from the workspace also deactivates the + # backing AI account, so the settings page and the members page stay + # consistent (mirrors AIAccountDetailAPIEndpoint.delete) + ai_account = AIAccount.objects.filter( + workspace__slug=slug, bot_user_id=workspace_member.member_id + ).first() + + # Removing the bot deactivates it in every project; refuse when it is + # the only active admin of one. (The generic sole-admin check above + # never matches bots: it compares member_id to the membership id.) + if ai_account and is_sole_project_admin(slug, workspace_member.member_id): + return Response( + { + "error": "This AI account is the only admin of some projects. Promote another member to admin before removing it." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Deactivate the users from the projects where the user is part of _ = ProjectMember.objects.filter( workspace__slug=slug, member_id=workspace_member.member_id, is_active=True @@ -147,6 +168,11 @@ def destroy(self, request, slug, pk): workspace_member.is_active = False workspace_member.save() + + if ai_account: + APIToken.objects.filter(user_id=workspace_member.member_id, is_service=True).update(is_active=False) + ai_account.delete() + return Response(status=status.HTTP_204_NO_CONTENT) @invalidate_cache( 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..3b4883ba21a --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_ai_scope_enforcement.py @@ -0,0 +1,213 @@ +# 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_wildcard_action_matches_any_action(self, bot_client, ai_account, workspace, project): + AIScopePolicy.objects.create( + ai_account=ai_account, + project=project, + resource_type="work_item", + action="all", + ) + assert bot_client.get(issues_url(workspace, project)).status_code == status.HTTP_200_OK + response = bot_client.post( + issues_url(workspace, project), {"name": "bot issue"}, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + + def test_wildcard_resource_matches_any_resource(self, bot_client, ai_account, workspace, project): + AIScopePolicy.objects.create( + ai_account=ai_account, + project=project, + resource_type="all", + action="read", + ) + assert bot_client.get(issues_url(workspace, project)).status_code == status.HTTP_200_OK + cycles_url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project.id}/cycles/" + ) + assert bot_client.get(cycles_url).status_code == status.HTTP_200_OK + + def test_users_me_allowed_with_user_scope(self, bot_client, ai_account): + """Slug-less endpoints fall back to the account's own workspace.""" + AIScopePolicy.objects.create( + ai_account=ai_account, project=None, resource_type="user", action="read" + ) + response = bot_client.get("/api/v1/users/me/") + assert response.status_code == status.HTTP_200_OK + + def test_users_me_denied_without_scope(self, bot_client, ai_account): + response = bot_client.get("/api/v1/users/me/") + assert response.status_code == status.HTTP_403_FORBIDDEN + + 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..938549a2de8 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_ai_accounts.py @@ -0,0 +1,376 @@ +# 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).""" + +from uuid import uuid4 + +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, FileAsset, 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_joins_existing_projects(self, session_client, workspace, project): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-join", "role": 15}, format="json" + ) + account = AIAccount.objects.get(pk=create.data["id"]) + assert ProjectMember.objects.filter( + project=project, member=account.bot_user, role=15, is_active=True + ).exists() + + def test_new_project_auto_adds_bot(self, session_client, workspace, create_user): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-inherit", "role": 15}, format="json" + ) + account = AIAccount.objects.get(pk=create.data["id"]) + + # A project created after the account picks the bot up via signal + new_project = Project.objects.create( + name="Later Project", identifier="LP", workspace=workspace, created_by=create_user + ) + assert ProjectMember.objects.filter( + project=new_project, member=account.bot_user, role=15, is_active=True + ).exists() + + # Deactivated membership is reactivated when the project signal re-fires + # (covered by create path above); inactive accounts are not added + account.is_active = False + account.save() + other_project = Project.objects.create( + name="Other Project", identifier="OP", workspace=workspace, created_by=create_user + ) + assert not ProjectMember.objects.filter( + project=other_project, member=account.bot_user + ).exists() + + def test_new_project_skips_bot_without_workspace_membership( + self, session_client, workspace, create_user + ): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-removed", "role": 15}, format="json" + ) + account = AIAccount.objects.get(pk=create.data["id"]) + # The bot was removed from the workspace but the account stayed active; + # the signal must not re-activate it in new projects + WorkspaceMember.objects.filter( + workspace=workspace, member=account.bot_user + ).update(is_active=False) + + project = Project.objects.create( + name="Skip Project", identifier="SP", workspace=workspace, created_by=create_user + ) + assert not ProjectMember.objects.filter( + project=project, member=account.bot_user + ).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_patch_avatar_sets_bot_user_avatar(self, session_client, workspace): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-avatar", "role": 15}, format="json" + ) + account = AIAccount.objects.get(pk=create.data["id"]) + + # Avatar assets are workspace assets bound to the bot user + asset = FileAsset.objects.create( + attributes={"name": "avatar.png", "type": "image/png", "size": 100}, + asset=f"{workspace.id}/avatar.png", + size=100, + workspace=workspace, + entity_type="USER_AVATAR", + entity_identifier=str(account.bot_user.id), + is_uploaded=True, + ) + + response = session_client.patch( + f"{accounts_url(workspace.slug)}{account.id}/", + {"avatar": asset.asset_url}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + account.bot_user.refresh_from_db() + assert account.bot_user.avatar_asset_id == asset.id + assert account.bot_user.avatar == "" + assert response.data["bot_user"]["avatar_url"] == asset.asset_url + + # Unknown or malformed asset references are rejected + response = session_client.patch( + f"{accounts_url(workspace.slug)}{account.id}/", + {"avatar": "/api/assets/v2/static/not-a-uuid/"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + response = session_client.patch( + f"{accounts_url(workspace.slug)}{account.id}/", + {"avatar": f"/api/assets/v2/static/{uuid4()}/"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Assets uploaded for a different entity cannot be attached + other_asset = FileAsset.objects.create( + attributes={"name": "other.png", "type": "image/png", "size": 100}, + asset=f"{workspace.id}/other.png", + size=100, + workspace=workspace, + entity_type="USER_AVATAR", + entity_identifier=str(uuid4()), + is_uploaded=True, + ) + response = session_client.patch( + f"{accounts_url(workspace.slug)}{account.id}/", + {"avatar": other_asset.asset_url}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Omitting the key leaves the avatar untouched; empty string clears it + # and deletes the previously attached asset + session_client.patch( + f"{accounts_url(workspace.slug)}{account.id}/", + {"description": "no avatar key"}, + format="json", + ) + account.bot_user.refresh_from_db() + assert account.bot_user.avatar_asset_id == asset.id + session_client.patch( + f"{accounts_url(workspace.slug)}{account.id}/", {"avatar": ""}, format="json" + ) + account.bot_user.refresh_from_db() + assert account.bot_user.avatar_asset_id is None + assert account.bot_user.avatar == "" + asset.refresh_from_db() + assert asset.is_deleted is True + + def test_patch_with_invalid_avatar_changes_nothing(self, session_client, workspace): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-atomic", "role": 15}, format="json" + ) + account = AIAccount.objects.get(pk=create.data["id"]) + + # An invalid avatar must reject the whole PATCH, not just the avatar part + response = session_client.patch( + f"{accounts_url(workspace.slug)}{account.id}/", + {"name": "renamed", "avatar": f"/api/assets/v2/static/{uuid4()}/"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + account.refresh_from_db() + assert account.name == "bot-atomic" + + 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_delete_blocked_when_bot_is_sole_project_admin( + self, session_client, workspace, create_user + ): + create = session_client.post( + accounts_url(workspace.slug), {"name": "bot-admin", "role": 15}, format="json" + ) + account = AIAccount.objects.get(pk=create.data["id"]) + + # A project where the bot ends up as the only active admin (an admin + # promoted it through the project member endpoint) + project = Project.objects.create( + name="Bot Owned", identifier="BO", workspace=workspace, created_by=create_user + ) + membership = ProjectMember.objects.get(project=project, member=account.bot_user) + membership.role = 20 + membership.save() + + response = session_client.delete(f"{accounts_url(workspace.slug)}{account.id}/") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert AIAccount.objects.filter(pk=account.pk).exists() + + # An active non-admin member does not change anything: the bot is + # still the only admin + member_user = User.objects.create(email="plain-member@plane.so", username="plain-member") + ProjectMember.objects.create( + project=project, member=member_user, role=15, workspace=workspace, is_active=True + ) + response = session_client.delete(f"{accounts_url(workspace.slug)}{account.id}/") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Promoting another admin unblocks the deletion + ProjectMember.objects.filter(project=project, member=member_user).update(role=20) + response = session_client.delete(f"{accounts_url(workspace.slug)}{account.id}/") + assert response.status_code == status.HTTP_204_NO_CONTENT + + 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 + + +@pytest.mark.contract +class TestAIAccountPolicyCache: + def test_get_ai_account_caches_negative_result( + self, db, create_user, django_assert_num_queries + ): + """A request without an AI account must hit the DB only once.""" + from plane.ai_accounts.policy import get_ai_account + + class _Request: + def __init__(self, user): + self.user = user + + request = _Request(create_user) + assert get_ai_account(request) is None + with django_assert_num_queries(0): + assert get_ai_account(request) is None diff --git a/apps/api/plane/tests/contract/app/test_ai_bot_member_management.py b/apps/api/plane/tests/contract/app/test_ai_bot_member_management.py new file mode 100644 index 00000000000..2d325d45f5c --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_ai_bot_member_management.py @@ -0,0 +1,203 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Contract tests for managing AI agent bots through the regular member +endpoints (PLANE-11). + +AI agent bots (``bot_type="AI_AGENT"``) are treated as regular members: +they appear in project member list/retrieve, can be removed from projects, +and can be role-edited / removed at the workspace level. Removing an AI bot +from the workspace also deactivates its service tokens and deletes the +backing AIAccount. Other bot types (e.g. WORKSPACE_SEED) stay hidden. +""" + +import uuid + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +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, +) + + +def _make_bot(email: str, bot_type: str | None = BOT_TYPE_AI_AGENT) -> User: + local_part = email.split("@")[0] + return User.objects.create( + email=email, + username=local_part, + first_name=local_part, + is_bot=True, + bot_type=bot_type, + is_active=True, + ) + + +def _add_member(workspace, project, user, *, ws_role: int = 15, project_role: int = 15) -> ProjectMember: + WorkspaceMember.objects.create(workspace=workspace, member=user, role=ws_role, is_active=True) + return ProjectMember.objects.create( + workspace=workspace, project=project, member=user, role=project_role, is_active=True + ) + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Bot Project", + identifier="BOT", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + workspace=workspace, project=project, member=create_user, role=20, is_active=True + ) + return project + + +@pytest.fixture +def admin_client(create_user): + client = APIClient() + client.force_authenticate(user=create_user) + return client + + +@pytest.mark.contract +@pytest.mark.django_db +class TestProjectMemberAIBots: + def test_list_includes_ai_bot_but_not_other_bots(self, workspace, project, admin_client): + ai_bot = _make_bot("ai-bot@plane.so") + _add_member(workspace, project, ai_bot) + seed_bot = _make_bot("seed-bot@plane.so", bot_type="WORKSPACE_SEED") + _add_member(workspace, project, seed_bot) + + response = admin_client.get(f"/api/workspaces/{workspace.slug}/projects/{project.id}/members/") + + assert response.status_code == status.HTTP_200_OK + member_ids = [str(m["member"]) for m in response.data] + assert str(ai_bot.id) in member_ids + assert str(seed_bot.id) not in member_ids + + def test_retrieve_ai_bot(self, workspace, project, admin_client): + ai_bot = _make_bot("ai-bot@plane.so") + membership = _add_member(workspace, project, ai_bot) + + response = admin_client.get( + f"/api/workspaces/{workspace.slug}/projects/{project.id}/members/{membership.id}/" + ) + + assert response.status_code == status.HTTP_200_OK + assert str(response.data["member"]["id"]) == str(ai_bot.id) + assert response.data["member"]["bot_type"] == BOT_TYPE_AI_AGENT + + def test_destroy_removes_ai_bot_from_project(self, workspace, project, admin_client): + ai_bot = _make_bot("ai-bot@plane.so") + membership = _add_member(workspace, project, ai_bot) + + response = admin_client.delete( + f"/api/workspaces/{workspace.slug}/projects/{project.id}/members/{membership.id}/" + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + membership.refresh_from_db() + assert membership.is_active is False + + def test_destroy_still_rejects_other_bot_types(self, workspace, project, admin_client): + seed_bot = _make_bot("seed-bot@plane.so", bot_type="WORKSPACE_SEED") + membership = _add_member(workspace, project, seed_bot) + + response = admin_client.delete( + f"/api/workspaces/{workspace.slug}/projects/{project.id}/members/{membership.id}/" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.contract +@pytest.mark.django_db +class TestWorkspaceMemberAIBots: + def test_partial_update_ai_bot_role(self, workspace, create_user, admin_client): + ai_bot = _make_bot("ai-bot@plane.so") + ws_membership = WorkspaceMember.objects.create( + workspace=workspace, member=ai_bot, role=15, is_active=True + ) + + response = admin_client.patch( + f"/api/workspaces/{workspace.slug}/members/{ws_membership.id}/", + {"role": 5}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + ws_membership.refresh_from_db() + assert ws_membership.role == 5 + + def test_destroy_ai_bot_cascades_to_ai_account(self, workspace, create_user, admin_client): + ai_bot = _make_bot("ai-bot@plane.so") + ws_membership = WorkspaceMember.objects.create( + workspace=workspace, member=ai_bot, role=15, is_active=True + ) + account = AIAccount.objects.create( + workspace=workspace, owner=create_user, bot_user=ai_bot, name="test-bot" + ) + token = APIToken.objects.create( + user=ai_bot, workspace=workspace, user_type=1, is_service=True + ) + + response = admin_client.delete(f"/api/workspaces/{workspace.slug}/members/{ws_membership.id}/") + + assert response.status_code == status.HTTP_204_NO_CONTENT + ws_membership.refresh_from_db() + assert ws_membership.is_active is False + token.refresh_from_db() + assert token.is_active is False + assert not AIAccount.objects.filter(pk=account.pk).exists() + + def test_destroy_still_rejects_other_bot_types(self, workspace, admin_client): + seed_bot = _make_bot("seed-bot@plane.so", bot_type="WORKSPACE_SEED") + ws_membership = WorkspaceMember.objects.create( + workspace=workspace, member=seed_bot, role=15, is_active=True + ) + + response = admin_client.delete(f"/api/workspaces/{workspace.slug}/members/{ws_membership.id}/") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_destroy_ai_bot_blocked_when_sole_project_admin(self, workspace, project, create_user, admin_client): + ai_bot = _make_bot("ai-bot@plane.so") + ws_membership = WorkspaceMember.objects.create( + workspace=workspace, member=ai_bot, role=20, is_active=True + ) + AIAccount.objects.create( + workspace=workspace, owner=create_user, bot_user=ai_bot, name="test-bot" + ) + # The bot is the only active admin of a project: the post_save signal + # auto-joins it with its workspace role (20) + owned_project = Project.objects.create( + name="Bot Owned", identifier="BOWN", workspace=workspace, created_by=create_user + ) + assert ProjectMember.objects.filter( + project=owned_project, member=ai_bot, role=20, is_active=True + ).exists() + + response = admin_client.delete(f"/api/workspaces/{workspace.slug}/members/{ws_membership.id}/") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + ws_membership.refresh_from_db() + assert ws_membership.is_active is True + + # Promoting another admin unblocks the removal + ProjectMember.objects.create( + workspace=workspace, project=owned_project, member=create_user, role=20, is_active=True + ) + response = admin_client.delete(f"/api/workspaces/{workspace.slug}/members/{ws_membership.id}/") + + assert response.status_code == status.HTTP_204_NO_CONTENT 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")), ] 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}} +
+ ( +