From 7bf416ec1f4869f623d920b56db751b7ba42a2bd Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 29 Apr 2026 00:38:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D0=BE=D1=80=D0=BA=D1=81=D0=BF=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20=D0=B8=20feature-gates=20=D0=B2=20God=20Mo?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(all)/(dashboard)/workspace/page.tsx | 50 ++- .../components/workspace/admin-modals.tsx | 318 ++++++++++++++++++ .../admin/components/workspace/list-item.tsx | 69 +++- .../apps/api/plane/app/views/voice_tasker.py | 29 +- .../0135_workspace_feature_entitlements.py | 93 +++++ .../apps/api/plane/db/models/__init__.py | 1 + .../apps/api/plane/db/models/workspace.py | 30 ++ .../api/plane/license/api/views/__init__.py | 2 + .../api/plane/license/api/views/workspace.py | 225 ++++++++++++- plane-src/apps/api/plane/license/urls.py | 17 + .../workspace/sidebar/item-categories.tsx | 18 +- .../settings/workspace-settings-modal.tsx | 30 +- .../workspace/instance-workspace.service.ts | 59 +++- plane-src/packages/types/src/ai.ts | 1 + plane-src/packages/types/src/workspace.ts | 21 ++ 15 files changed, 923 insertions(+), 40 deletions(-) create mode 100644 plane-src/apps/admin/components/workspace/admin-modals.tsx create mode 100644 plane-src/apps/api/plane/db/migrations/0135_workspace_feature_entitlements.py diff --git a/plane-src/apps/admin/app/(all)/(dashboard)/workspace/page.tsx b/plane-src/apps/admin/app/(all)/(dashboard)/workspace/page.tsx index 5816ac9..94a9ff5 100644 --- a/plane-src/apps/admin/app/(all)/(dashboard)/workspace/page.tsx +++ b/plane-src/apps/admin/app/(all)/(dashboard)/workspace/page.tsx @@ -17,6 +17,7 @@ import { Loader, ToggleSwitch } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { PageWrapper } from "@/components/common/page-wrapper"; +import { WorkspaceFeaturesModal, WorkspaceMembersModal } from "@/components/workspace/admin-modals"; import { WorkspaceListItem } from "@/components/workspace/list-item"; // hooks import { useInstance, useWorkspace } from "@/hooks/store"; @@ -26,6 +27,8 @@ import type { Route } from "./+types/page"; const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) { // states const [isSubmitting, setIsSubmitting] = useState(false); + const [membersWorkspaceId, setMembersWorkspaceId] = useState(null); + const [featuresWorkspaceId, setFeaturesWorkspaceId] = useState(null); // store const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance(); const { @@ -53,14 +56,14 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving configuration", + loading: "Сохранение конфигурации", success: { - title: "Success", - message: () => "Configuration saved successfully", + title: "Сохранено", + message: () => "Конфигурация обновлена", }, error: { - title: "Error", - message: () => "Failed to save configuration", + title: "Ошибка", + message: () => "Не удалось сохранить конфигурацию", }, }); @@ -77,8 +80,8 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props return (
@@ -86,9 +89,9 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
-
Prevent anyone else from creating a workspace.
+
Запретить пользователям создавать воркспейсы
- Toggling this on will let only you create workspaces. You will have to invite users to new workspaces. + Если включить, создавать рабочие пространства сможет только администратор инстанса.
@@ -119,25 +122,30 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
- All workspaces on this instance • {workspaceIds.length} + Все воркспейсы инстанса • {workspaceIds.length} {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( )}
- You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a - Member. + Удаление пока недоступно. Открыть воркспейс можно только при наличии роли администратора или + участника.
- Create workspace + Создать воркспейс
{workspaceIds.map((workspaceId) => ( - + ))}
{hasNextPage && ( @@ -148,7 +156,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props onClick={() => fetchNextWorkspaces()} disabled={workspaceLoader === "pagination"} > - Load more + Загрузить еще {workspaceLoader === "pagination" && }
@@ -162,11 +170,21 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props )} + setMembersWorkspaceId(null)} + /> + setFeaturesWorkspaceId(null)} + />
); }); -export const meta: Route.MetaFunction = () => [{ title: "Workspace Management - God Mode" }]; +export const meta: Route.MetaFunction = () => [{ title: "Воркспейсы - NODE.DC" }]; export default WorkspaceManagementPage; diff --git a/plane-src/apps/admin/components/workspace/admin-modals.tsx b/plane-src/apps/admin/components/workspace/admin-modals.tsx new file mode 100644 index 0000000..3800e54 --- /dev/null +++ b/plane-src/apps/admin/components/workspace/admin-modals.tsx @@ -0,0 +1,318 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Fragment, useState } from "react"; +import type { ReactNode } from "react"; +import { Dialog, Transition } from "@headlessui/react"; +import useSWR from "swr"; +import { Loader, ShieldCheck, Trash2, UsersRound, X } from "lucide-react"; +// plane imports +import { Button } from "@plane/propel/button"; +import { setToast, TOAST_TYPE } from "@plane/propel/toast"; +import { InstanceWorkspaceService } from "@plane/services"; +import type { TInstanceWorkspaceFeature, TInstanceWorkspaceMember } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useWorkspace } from "@/hooks/store"; + +type TWorkspaceAdminModalProps = { + isOpen: boolean; + onClose: () => void; + workspaceId: string | null; +}; + +const instanceWorkspaceService = new InstanceWorkspaceService(); + +const ROLE_OPTIONS = [ + { label: "Гость", value: 5 }, + { label: "Участник", value: 15 }, + { label: "Администратор", value: 20 }, +]; + +const ROLE_LABELS: Record = { + 5: "Гость", + 15: "Участник", + 20: "Администратор", +}; + +const ACCESS_MODE_LABELS: Record = { + all_workspace_members: "Весь воркспейс", + admins_only: "Только админы", + selected_projects: "По контурам", + selected_members: "По людям", +}; + +function getMemberName(member: TInstanceWorkspaceMember) { + return member.member.display_name || member.member.email || "Пользователь"; +} + +function getErrorMessage(error: unknown, fallback: string) { + if (error && typeof error === "object" && "error" in error && typeof error.error === "string") return error.error; + return fallback; +} + +function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: ReactNode; title: string; icon: ReactNode }) { + const { children, icon, isOpen, onClose, title, workspaceId } = props; + const { getWorkspaceById } = useWorkspace(); + const workspace = workspaceId ? getWorkspaceById(workspaceId) : undefined; + + return ( + + + +
+ + +
+
+ + +
+
+ + {icon} + +
+ + {title} + +
+ {workspace ? `${workspace.name} / [${workspace.slug}]` : "Воркспейс"} +
+
+
+ +
+
{children}
+
+
+
+
+
+
+ ); +} + +export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) { + const { isOpen, onClose, workspaceId } = props; + const [mutatingMemberId, setMutatingMemberId] = useState(null); + const { data, isLoading, mutate } = useSWR( + isOpen && workspaceId ? ["INSTANCE_WORKSPACE_MEMBERS", workspaceId] : null, + () => instanceWorkspaceService.listMembers(workspaceId as string) + ); + + const handleRoleChange = async (workspaceMemberId: string, role: number) => { + if (!workspaceId) return; + setMutatingMemberId(workspaceMemberId); + try { + await instanceWorkspaceService.updateMemberRole(workspaceId, workspaceMemberId, role); + await mutate(); + setToast({ type: TOAST_TYPE.SUCCESS, title: "Роль участника обновлена" }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Не удалось обновить роль", + message: getErrorMessage(error, "Проверьте ограничения по администраторам воркспейса и проектов."), + }); + } finally { + setMutatingMemberId(null); + } + }; + + const handleRemove = async (workspaceMember: TInstanceWorkspaceMember) => { + if (!workspaceId) return; + const confirmed = window.confirm(`Отключить ${getMemberName(workspaceMember)} от этого воркспейса и его проектов?`); + if (!confirmed) return; + + setMutatingMemberId(workspaceMember.id); + try { + await instanceWorkspaceService.removeMember(workspaceId, workspaceMember.id); + await mutate(); + setToast({ type: TOAST_TYPE.SUCCESS, title: "Доступ к воркспейсу отключен" }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Не удалось отключить участника", + message: getErrorMessage(error, "Пользователь может быть единственным администратором."), + }); + } finally { + setMutatingMemberId(null); + } + }; + + return ( + }> + {isLoading ? ( +
+ +
+ ) : ( +
+
+
Пользователь
+
Роль
+
Проекты
+
Админ проектов
+
Доступ
+
+
+ {(data ?? []).map((workspaceMember) => ( +
+
+
{getMemberName(workspaceMember)}
+
{workspaceMember.member.email}
+
+ +
{workspaceMember.active_project_count}
+
{workspaceMember.admin_project_count}
+
+ +
+
+ ))} +
+ {(data ?? []).length === 0 &&
Активных участников нет
} +
+ )} +
+ ); +} + +export function WorkspaceFeaturesModal(props: TWorkspaceAdminModalProps) { + const { isOpen, workspaceId } = props; + const [mutatingFeatureKey, setMutatingFeatureKey] = useState(null); + const { data, isLoading, mutate } = useSWR( + isOpen && workspaceId ? ["INSTANCE_WORKSPACE_FEATURES", workspaceId] : null, + () => instanceWorkspaceService.retrieveFeatures(workspaceId as string) + ); + + const handleToggle = async (feature: TInstanceWorkspaceFeature) => { + if (!workspaceId) return; + setMutatingFeatureKey(feature.key); + try { + await instanceWorkspaceService.updateFeature(workspaceId, feature.key, !feature.is_enabled); + await mutate(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: feature.is_enabled ? "Фича отключена для воркспейса" : "Фича выдана воркспейсу", + }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Не удалось обновить доступ", + message: getErrorMessage(error, "Попробуйте еще раз."), + }); + } finally { + setMutatingFeatureKey(null); + } + }; + + return ( + }> + {isLoading ? ( +
+ +
+ ) : ( +
+ {(data?.features ?? []).map((feature) => ( +
+
+
+
{feature.title}
+ + {feature.is_enabled ? "Доступ выдан" : "Не выдано"} + +
+
{feature.description}
+
+ + Workspace: {feature.workspace_setting_enabled ? "включено" : "выключено"} + + Доступ: {ACCESS_MODE_LABELS[feature.access_mode]} + + OpenAI key: {feature.has_workspace_key ? "есть" : "нет"} + +
+
+
+ handleToggle(feature)} + size="sm" + disabled={mutatingFeatureKey === feature.key} + /> +
+
+ ))} + {(data?.features ?? []).length === 0 &&
Функции не найдены
} +
+ )} +
+ +
+
+ ); +} diff --git a/plane-src/apps/admin/components/workspace/list-item.tsx b/plane-src/apps/admin/components/workspace/list-item.tsx index 9594d7f..039e366 100644 --- a/plane-src/apps/admin/components/workspace/list-item.tsx +++ b/plane-src/apps/admin/components/workspace/list-item.tsx @@ -5,20 +5,26 @@ */ import { observer } from "mobx-react"; +import { ExternalLink, Sparkles, UsersRound } from "lucide-react"; // plane internal packages import { WEB_BASE_URL } from "@plane/constants"; -import { NewTabIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { getFileURL } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store"; type TWorkspaceListItemProps = { + onFeaturesClick: (workspaceId: string) => void; + onMembersClick: (workspaceId: string) => void; workspaceId: string; }; -export const WorkspaceListItem = observer(function WorkspaceListItem({ workspaceId }: TWorkspaceListItemProps) { +export const WorkspaceListItem = observer(function WorkspaceListItem({ + onFeaturesClick, + onMembersClick, + workspaceId, +}: TWorkspaceListItemProps) { // store hooks const { getWorkspaceById } = useWorkspace(); // derived values @@ -26,14 +32,11 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace if (!workspace) return null; return ( - -
+
) : ( (workspace?.name?.[0] ?? "...") )} -
+
-

{workspace.name}

/ - +

{workspace.name}

/ +

[{workspace.slug}]

{workspace.owner.email && (
-

Owned by:

+

Владелец:

{workspace.owner.email}

)}
{workspace.total_projects !== null && ( -

Total projects:

+

Проектов:

{workspace.total_projects}

)} @@ -73,7 +76,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace <> • -

Total members:

+

Участников:

{workspace.total_members}

@@ -81,9 +84,39 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
-
); }); diff --git a/plane-src/apps/api/plane/app/views/voice_tasker.py b/plane-src/apps/api/plane/app/views/voice_tasker.py index 78221ff..0a93691 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -51,6 +51,7 @@ from plane.db.models import ( Workspace, WorkspaceAICredential, WorkspaceAISettings, + WorkspaceFeatureEntitlement, WorkspaceMember, ) from plane.license.utils.encryption import decrypt_data @@ -288,10 +289,12 @@ def get_request_project_id(request): def get_voice_task_preflight(workspace, user, project_id=None): ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first() workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first() + entitlement_enabled = is_voice_tasker_entitled(workspace) response = { "available": False, "reason": "not_configured", + "entitlement_enabled": entitlement_enabled, "max_audio_duration_seconds": 120, "accepted_mime_types": VOICE_TASK_ACCEPTED_AUDIO_TYPES, "access_mode": "all_workspace_members", @@ -299,6 +302,10 @@ def get_voice_task_preflight(workspace, user, project_id=None): "enabled_member_ids": [], } + if not entitlement_enabled: + response["reason"] = "disabled" + return response + if not ai_settings: return response @@ -343,6 +350,14 @@ def get_voice_task_preflight(workspace, user, project_id=None): return response +def is_voice_tasker_entitled(workspace): + return WorkspaceFeatureEntitlement.objects.filter( + workspace=workspace, + feature_key=WorkspaceFeatureEntitlement.FeatureKey.VOICE_TASKER, + is_enabled=True, + ).exists() + + def get_voice_task_quota_project(workspace, project_id=None, ai_settings=None): if project_id: project = Project.objects.filter( @@ -3390,11 +3405,23 @@ class WorkspaceAISettingsEndpoint(BaseAPIView): def get(self, request, slug): workspace, ai_settings = self.get_settings(slug) serializer = WorkspaceAISettingsSerializer(ai_settings, context={"workspace": workspace}) - return Response(serializer.data, status=status.HTTP_200_OK) + data = serializer.data + data["feature_entitlement_enabled"] = is_voice_tasker_entitled(workspace) + return Response(data, status=status.HTTP_200_OK) @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def patch(self, request, slug): workspace, ai_settings = self.get_settings(slug) + requested_enabled = request.data.get("voice_tasker_enabled") + if requested_enabled in [True, "true", "True", "1", 1] and not is_voice_tasker_entitled(workspace): + return Response( + { + "error": "Voice Tasker is not enabled for this workspace by the instance administrator.", + "code": "feature_not_entitled", + }, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = WorkspaceAISettingsSerializer( ai_settings, data=request.data, diff --git a/plane-src/apps/api/plane/db/migrations/0135_workspace_feature_entitlements.py b/plane-src/apps/api/plane/db/migrations/0135_workspace_feature_entitlements.py new file mode 100644 index 0000000..485132c --- /dev/null +++ b/plane-src/apps/api/plane/db/migrations/0135_workspace_feature_entitlements.py @@ -0,0 +1,93 @@ +# Generated by Codex on 2026-04-28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("db", "0134_voice_tasker_retention"), + ] + + operations = [ + migrations.CreateModel( + name="WorkspaceFeatureEntitlement", + 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, editable=False, null=True)), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "feature_key", + models.CharField( + choices=[("voice_tasker", "AI / Voice Tasker")], + max_length=80, + ), + ), + ("is_enabled", models.BooleanField(default=False)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "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", + ), + ), + ( + "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="feature_entitlements", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Feature Entitlement", + "verbose_name_plural": "Workspace Feature Entitlements", + "db_table": "workspace_feature_entitlements", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="workspacefeatureentitlement", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "feature_key"), + name="workspace_feature_entitlement_unique_active_feature", + ), + ), + ] diff --git a/plane-src/apps/api/plane/db/models/__init__.py b/plane-src/apps/api/plane/db/models/__init__.py index 68c9566..b048cf0 100644 --- a/plane-src/apps/api/plane/db/models/__init__.py +++ b/plane-src/apps/api/plane/db/models/__init__.py @@ -69,6 +69,7 @@ from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISe from .workspace import ( Workspace, WorkspaceBaseModel, + WorkspaceFeatureEntitlement, WorkspaceMember, WorkspaceMemberInvite, WorkspaceTheme, diff --git a/plane-src/apps/api/plane/db/models/workspace.py b/plane-src/apps/api/plane/db/models/workspace.py index c7e367a..447a66f 100644 --- a/plane-src/apps/api/plane/db/models/workspace.py +++ b/plane-src/apps/api/plane/db/models/workspace.py @@ -260,6 +260,36 @@ class WorkspaceMemberInvite(BaseModel): return f"{self.workspace.name} {self.email} {self.accepted}" +class WorkspaceFeatureEntitlement(BaseModel): + class FeatureKey(models.TextChoices): + VOICE_TASKER = "voice_tasker", "AI / Voice Tasker" + + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="feature_entitlements", + ) + feature_key = models.CharField(max_length=80, choices=FeatureKey.choices) + is_enabled = models.BooleanField(default=False) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["workspace", "feature_key"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_feature_entitlement_unique_active_feature", + ) + ] + verbose_name = "Workspace Feature Entitlement" + verbose_name_plural = "Workspace Feature Entitlements" + db_table = "workspace_feature_entitlements" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.slug} <{self.feature_key}>" + + class Team(BaseModel): name = models.CharField(max_length=255, verbose_name="Team Name") description = models.TextField(verbose_name="Team Description", blank=True) diff --git a/plane-src/apps/api/plane/license/api/views/__init__.py b/plane-src/apps/api/plane/license/api/views/__init__.py index e252764..a28a37b 100644 --- a/plane-src/apps/api/plane/license/api/views/__init__.py +++ b/plane-src/apps/api/plane/license/api/views/__init__.py @@ -25,4 +25,6 @@ from .admin import ( from .workspace import ( InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint, + InstanceWorkSpaceFeatureEndpoint, + InstanceWorkSpaceMemberEndpoint, ) diff --git a/plane-src/apps/api/plane/license/api/views/workspace.py b/plane-src/apps/api/plane/license/api/views/workspace.py index 966b3b3..72b93f7 100644 --- a/plane-src/apps/api/plane/license/api/views/workspace.py +++ b/plane-src/apps/api/plane/license/api/views/workspace.py @@ -6,16 +6,109 @@ from rest_framework.response import Response from rest_framework import status from django.db import IntegrityError -from django.db.models import OuterRef, Func, F +from django.db.models import Count, OuterRef, Func, F, Q +from django.utils import timezone # Module imports from plane.app.views.base import BaseAPIView from plane.license.api.permissions import InstanceAdminPermission -from plane.db.models import Workspace, WorkspaceMember, Project +from plane.db.models import ( + Project, + ProjectMember, + Workspace, + WorkspaceAICredential, + WorkspaceAISettings, + WorkspaceFeatureEntitlement, + WorkspaceMember, +) from plane.license.api.serializers import WorkspaceSerializer from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +WORKSPACE_ROLES = {5, 15, 20} +VOICE_TASKER_FEATURE = WorkspaceFeatureEntitlement.FeatureKey.VOICE_TASKER + + +def serialize_instance_workspace_member(workspace_member): + member = workspace_member.member + return { + "id": str(workspace_member.id), + "workspace": str(workspace_member.workspace_id), + "member": { + "id": str(member.id), + "email": member.email, + "display_name": member.display_name, + "first_name": member.first_name, + "last_name": member.last_name, + "avatar_url": member.avatar_url, + "is_bot": member.is_bot, + "is_active": member.is_active, + }, + "role": workspace_member.role, + "is_active": workspace_member.is_active, + "created_at": workspace_member.created_at, + "active_project_count": getattr(workspace_member, "active_project_count", 0), + "admin_project_count": getattr(workspace_member, "admin_project_count", 0), + } + + +def has_other_workspace_admin(workspace_member): + return WorkspaceMember.objects.filter( + workspace=workspace_member.workspace, + role=20, + is_active=True, + member__is_bot=False, + ).exclude(pk=workspace_member.pk).exists() + + +def is_only_project_admin(workspace_member): + return ( + Project.objects.filter( + workspace=workspace_member.workspace, + archived_at__isnull=True, + project_projectmember__member_id=workspace_member.member_id, + project_projectmember__role=20, + project_projectmember__is_active=True, + ) + .annotate( + other_admin_count=Count( + "project_projectmember", + filter=( + Q(project_projectmember__role=20) + & Q(project_projectmember__is_active=True) + & ~Q(project_projectmember__member_id=workspace_member.member_id) + ), + distinct=True, + ) + ) + .filter(other_admin_count=0) + .exists() + ) + + +def get_workspace_member_queryset(workspace_id): + return ( + WorkspaceMember.objects.filter(workspace_id=workspace_id, member__is_bot=False, is_active=True) + .select_related("workspace", "member", "member__avatar_asset") + .annotate( + active_project_count=Count( + "member__member_project", + filter=Q(member__member_project__workspace_id=workspace_id) + & Q(member__member_project__is_active=True), + distinct=True, + ), + admin_project_count=Count( + "member__member_project", + filter=Q(member__member_project__workspace_id=workspace_id) + & Q(member__member_project__is_active=True) + & Q(member__member_project__role=20), + distinct=True, + ), + ) + .order_by("member__display_name", "member__email") + ) + + class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView): permission_classes = [InstanceAdminPermission] @@ -108,3 +201,131 @@ class InstanceWorkSpaceEndpoint(BaseAPIView): {"slug": "The workspace with the slug already exists"}, status=status.HTTP_409_CONFLICT, ) + + +class InstanceWorkSpaceMemberEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + def get(self, request, workspace_id): + if not Workspace.objects.filter(id=workspace_id).exists(): + return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND) + + workspace_members = get_workspace_member_queryset(workspace_id) + return Response( + [serialize_instance_workspace_member(workspace_member) for workspace_member in workspace_members], + status=status.HTTP_200_OK, + ) + + def patch(self, request, workspace_id, member_id): + workspace_member = get_workspace_member_queryset(workspace_id).filter(pk=member_id).first() + if not workspace_member: + return Response({"error": "Workspace member not found"}, status=status.HTTP_404_NOT_FOUND) + + try: + role = int(request.data.get("role")) + except (TypeError, ValueError): + return Response({"error": "Role must be one of 5, 15, 20"}, status=status.HTTP_400_BAD_REQUEST) + + if role not in WORKSPACE_ROLES: + return Response({"error": "Role must be one of 5, 15, 20"}, status=status.HTTP_400_BAD_REQUEST) + + if workspace_member.role == 20 and role != 20 and not has_other_workspace_admin(workspace_member): + return Response( + {"error": "Cannot demote the only workspace admin"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace_member.role = role + workspace_member.save() + + if role == 5: + ProjectMember.objects.filter( + workspace_id=workspace_id, + member_id=workspace_member.member_id, + is_active=True, + ).update(role=5, updated_at=timezone.now()) + + workspace_member = get_workspace_member_queryset(workspace_id).get(pk=member_id) + return Response(serialize_instance_workspace_member(workspace_member), status=status.HTTP_200_OK) + + def delete(self, request, workspace_id, member_id): + workspace_member = get_workspace_member_queryset(workspace_id).filter(pk=member_id).first() + if not workspace_member: + return Response({"error": "Workspace member not found"}, status=status.HTTP_404_NOT_FOUND) + + if workspace_member.role == 20 and not has_other_workspace_admin(workspace_member): + return Response( + {"error": "Cannot remove the only workspace admin"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if is_only_project_admin(workspace_member): + return Response( + {"error": "Cannot remove a member who is the only admin in one or more projects"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectMember.objects.filter( + workspace_id=workspace_id, + member_id=workspace_member.member_id, + is_active=True, + ).update(is_active=False, updated_at=timezone.now()) + + workspace_member.is_active = False + workspace_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InstanceWorkSpaceFeatureEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + def serialize_voice_tasker_feature(self, workspace): + entitlement = WorkspaceFeatureEntitlement.objects.filter( + workspace=workspace, + feature_key=VOICE_TASKER_FEATURE, + ).first() + ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first() + credential = WorkspaceAICredential.objects.filter( + workspace=workspace, + provider=WorkspaceAICredential.Provider.OPENAI, + is_active=True, + ).first() + + return { + "key": VOICE_TASKER_FEATURE, + "title": "AI / Voice Tasker", + "description": "Голосовая постановка задач и встроенные AI-сценарии воркспейса.", + "is_enabled": bool(entitlement and entitlement.is_enabled), + "workspace_setting_enabled": bool(ai_settings and ai_settings.voice_tasker_enabled), + "access_mode": ai_settings.access_mode if ai_settings else WorkspaceAISettings.AccessMode.ALL_WORKSPACE_MEMBERS, + "has_workspace_key": bool(credential and credential.encrypted_api_key), + } + + def get(self, request, workspace_id): + workspace = Workspace.objects.filter(id=workspace_id).first() + if not workspace: + return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND) + + return Response({"features": [self.serialize_voice_tasker_feature(workspace)]}, status=status.HTTP_200_OK) + + def patch(self, request, workspace_id): + workspace = Workspace.objects.filter(id=workspace_id).first() + if not workspace: + return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND) + + feature_key = request.data.get("feature_key") + if feature_key != VOICE_TASKER_FEATURE: + return Response({"error": "Unsupported feature key"}, status=status.HTTP_400_BAD_REQUEST) + + is_enabled = request.data.get("is_enabled") + if not isinstance(is_enabled, bool): + return Response({"error": "is_enabled must be a boolean"}, status=status.HTTP_400_BAD_REQUEST) + + entitlement, _ = WorkspaceFeatureEntitlement.objects.get_or_create( + workspace=workspace, + feature_key=feature_key, + ) + entitlement.is_enabled = is_enabled + entitlement.save() + + return Response({"features": [self.serialize_voice_tasker_feature(workspace)]}, status=status.HTTP_200_OK) diff --git a/plane-src/apps/api/plane/license/urls.py b/plane-src/apps/api/plane/license/urls.py index 844a9e1..0a43353 100644 --- a/plane-src/apps/api/plane/license/urls.py +++ b/plane-src/apps/api/plane/license/urls.py @@ -18,6 +18,8 @@ from plane.license.api.views import ( InstanceAdminUserSessionEndpoint, InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint, + InstanceWorkSpaceFeatureEndpoint, + InstanceWorkSpaceMemberEndpoint, ) urlpatterns = [ @@ -71,4 +73,19 @@ urlpatterns = [ name="instance-workspace-availability", ), path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"), + path( + "workspaces//members/", + InstanceWorkSpaceMemberEndpoint.as_view(), + name="instance-workspace-members", + ), + path( + "workspaces//members//", + InstanceWorkSpaceMemberEndpoint.as_view(), + name="instance-workspace-member", + ), + path( + "workspaces//features/", + InstanceWorkSpaceFeatureEndpoint.as_view(), + name="instance-workspace-features", + ), ] diff --git a/plane-src/apps/web/core/components/settings/workspace/sidebar/item-categories.tsx b/plane-src/apps/web/core/components/settings/workspace/sidebar/item-categories.tsx index bc6d467..382b253 100644 --- a/plane-src/apps/web/core/components/settings/workspace/sidebar/item-categories.tsx +++ b/plane-src/apps/web/core/components/settings/workspace/sidebar/item-categories.tsx @@ -7,18 +7,24 @@ import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; import { useParams } from "react-router"; +import useSWR from "swr"; // plane imports -import { EUserPermissionsLevel, GROUPED_WORKSPACE_SETTINGS, WORKSPACE_SETTINGS_CATEGORIES } from "@plane/constants"; +import { EUserPermissionsLevel, GROUPED_WORKSPACE_SETTINGS, WORKSPACE_SETTINGS, WORKSPACE_SETTINGS_CATEGORIES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import type { TWorkspaceSettingsTabs } from "@plane/types"; import { joinUrlPath } from "@plane/utils"; // components import { SettingsSidebarItem } from "@/components/settings/sidebar/item"; // hooks import { useUserPermissions } from "@/hooks/store/user"; +// services +import { WorkspaceAIService } from "@/services/workspace-ai.service"; // local imports import { WORKSPACE_SETTINGS_ICONS } from "./item-icon"; const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set(["billing-and-plans"]); +const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set(["ai-voice-tasker"]); +const workspaceAIService = new WorkspaceAIService(); export const WorkspaceSettingsSidebarItemCategories = observer(function WorkspaceSettingsSidebarItemCategories() { // params @@ -28,6 +34,15 @@ export const WorkspaceSettingsSidebarItemCategories = observer(function Workspac const { allowPermissions } = useUserPermissions(); // translation const { t } = useTranslation(); + // feature gates + const canLoadVoiceTaskerEntitlement = + !!workspaceSlug && + allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, workspaceSlug); + const { data: aiSettings } = useSWR( + canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null, + () => workspaceAIService.retrieveSettings(workspaceSlug as string) + ); + const isVoiceTaskerEntitled = aiSettings?.feature_entitlement_enabled === true; return (
@@ -36,6 +51,7 @@ export const WorkspaceSettingsSidebarItemCategories = observer(function Workspac const accessibleItems = categoryItems.filter( (item) => !HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) && + (!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) || isVoiceTaskerEntitled) && allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug) ); diff --git a/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx b/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx index 937c1a1..9d155b1 100644 --- a/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; +import useSWR from "swr"; // plane imports import { EUserPermissionsLevel, @@ -32,6 +33,8 @@ import { WorkspaceDetails } from "@/components/workspace/settings/workspace-deta // hooks import { useUserPermissions } from "@/hooks/store/user"; import { useWorkspace } from "@/hooks/store/use-workspace"; +// services +import { WorkspaceAIService } from "@/services/workspace-ai.service"; // local imports import { closeWorkspaceSettingsModal, @@ -43,7 +46,9 @@ import { } from "./workspace-settings-modal.utils"; const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set(["billing-and-plans"]); +const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set(["ai-voice-tasker"]); const MODAL_TABS = new Set(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]); +const workspaceAIService = new WorkspaceAIService(); const getInitialTab = (): TWorkspaceSettingsModalTab => { if (typeof window === "undefined") return "general"; @@ -67,6 +72,14 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() const { currentWorkspace } = useWorkspace(); const { allowPermissions } = useUserPermissions(); const { t } = useTranslation(); + const canLoadVoiceTaskerEntitlement = + !!currentWorkspace?.slug && + allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, currentWorkspace.slug); + const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR( + canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null, + () => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string) + ); + const isVoiceTaskerEntitled = aiSettings?.feature_entitlement_enabled === true; useEffect(() => { const syncFromLocation = () => { @@ -97,6 +110,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() }; }, []); + useEffect(() => { + if (!isOpen || activeTab !== "ai-voice-tasker" || isVoiceTaskerEntitlementLoading) return; + if (!isVoiceTaskerEntitled) openWorkspaceSettingsModal("general", true); + }, [activeTab, isOpen, isVoiceTaskerEntitlementLoading, isVoiceTaskerEntitled]); + const handleClose = () => { closeWorkspaceSettingsModal(); }; @@ -114,6 +132,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() const renderContent = () => { if (activeTab === "ai-voice-tasker" && currentWorkspace?.slug) { + if (!isVoiceTaskerEntitled) return ; return ; } @@ -155,6 +174,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() activeTab={activeTab} onSelectItem={handleSelectItem} allowPermissions={allowPermissions} + isVoiceTaskerEntitled={isVoiceTaskerEntitled} workspaceSlug={currentWorkspace?.slug} />
@@ -189,11 +209,18 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() type TWorkspaceModalSidebarProps = { activeTab: TWorkspaceSettingsModalTab; allowPermissions: ReturnType["allowPermissions"]; + isVoiceTaskerEntitled: boolean; onSelectItem: (itemKey: TWorkspaceSettingsTabs, itemHref: string) => void; workspaceSlug?: string; }; -function WorkspaceModalSidebar({ activeTab, allowPermissions, onSelectItem, workspaceSlug }: TWorkspaceModalSidebarProps) { +function WorkspaceModalSidebar({ + activeTab, + allowPermissions, + isVoiceTaskerEntitled, + onSelectItem, + workspaceSlug, +}: TWorkspaceModalSidebarProps) { const { t } = useTranslation(); return ( @@ -208,6 +235,7 @@ function WorkspaceModalSidebar({ activeTab, allowPermissions, onSelectItem, work const accessibleItems = GROUPED_WORKSPACE_SETTINGS[category].filter( (item) => !HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) && + (!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) || isVoiceTaskerEntitled) && allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug) ); diff --git a/plane-src/packages/services/src/workspace/instance-workspace.service.ts b/plane-src/packages/services/src/workspace/instance-workspace.service.ts index c38fb66..7077429 100644 --- a/plane-src/packages/services/src/workspace/instance-workspace.service.ts +++ b/plane-src/packages/services/src/workspace/instance-workspace.service.ts @@ -5,7 +5,13 @@ */ import { API_BASE_URL } from "@plane/constants"; -import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types"; +import type { + IWorkspace, + TInstanceWorkspaceFeatureKey, + TInstanceWorkspaceFeaturesResponse, + TInstanceWorkspaceMember, + TWorkspacePaginationInfo, +} from "@plane/types"; import { APIService } from "../api.service"; /** @@ -68,4 +74,55 @@ export class InstanceWorkspaceService extends APIService { throw error?.response?.data; }); } + + async listMembers(workspaceId: string): Promise { + return this.get(`/api/instances/workspaces/${workspaceId}/members/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateMemberRole( + workspaceId: string, + workspaceMemberId: string, + role: TInstanceWorkspaceMember["role"] + ): Promise { + return this.patch(`/api/instances/workspaces/${workspaceId}/members/${workspaceMemberId}/`, { role }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async removeMember(workspaceId: string, workspaceMemberId: string): Promise { + return this.delete(`/api/instances/workspaces/${workspaceId}/members/${workspaceMemberId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async retrieveFeatures(workspaceId: string): Promise { + return this.get(`/api/instances/workspaces/${workspaceId}/features/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateFeature( + workspaceId: string, + featureKey: TInstanceWorkspaceFeatureKey, + isEnabled: boolean + ): Promise { + return this.patch(`/api/instances/workspaces/${workspaceId}/features/`, { + feature_key: featureKey, + is_enabled: isEnabled, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/plane-src/packages/types/src/ai.ts b/plane-src/packages/types/src/ai.ts index 443f282..84f3c5c 100644 --- a/plane-src/packages/types/src/ai.ts +++ b/plane-src/packages/types/src/ai.ts @@ -32,6 +32,7 @@ export type TWorkspaceAICredentialStatus = { export type TWorkspaceAISettings = { id: string; workspace_id: string; + feature_entitlement_enabled: boolean; voice_tasker_enabled: boolean; provider: TWorkspaceAIProvider; transcription_model: string; diff --git a/plane-src/packages/types/src/workspace.ts b/plane-src/packages/types/src/workspace.ts index 2f9c4e5..8dc5561 100644 --- a/plane-src/packages/types/src/workspace.ts +++ b/plane-src/packages/types/src/workspace.ts @@ -96,6 +96,27 @@ export interface IWorkspaceMember { is_active?: boolean; } +export type TInstanceWorkspaceMember = IWorkspaceMember & { + active_project_count: number; + admin_project_count: number; +}; + +export type TInstanceWorkspaceFeatureKey = "voice_tasker"; + +export type TInstanceWorkspaceFeature = { + key: TInstanceWorkspaceFeatureKey; + title: string; + description: string; + is_enabled: boolean; + workspace_setting_enabled: boolean; + access_mode: "all_workspace_members" | "admins_only" | "selected_projects" | "selected_members"; + has_workspace_key: boolean; +}; + +export type TInstanceWorkspaceFeaturesResponse = { + features: TInstanceWorkspaceFeature[]; +}; + export interface IWorkspaceMemberMe { company_role: string | null; created_at: Date;