diff --git a/plane-src/apps/api/plane/app/views/project/member.py b/plane-src/apps/api/plane/app/views/project/member.py index faafb4e..f30c79e 100644 --- a/plane-src/apps/api/plane/app/views/project/member.py +++ b/plane-src/apps/api/plane/app/views/project/member.py @@ -9,6 +9,10 @@ from django.db.models import Min # Module imports from .base import BaseViewSet, BaseAPIView +from plane.authentication.nodedc_workspace_policy import ( + is_nodedc_launcher_managed_workspace, + nodedc_launcher_managed_workspace_response, +) from plane.app.serializers import ( ProjectMemberSerializer, ProjectMemberAdminSerializer, @@ -45,6 +49,9 @@ class ProjectMemberViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + # Get the list of members to be added to the project and their roles i.e. the user_id and the role members = request.data.get("members", []) @@ -204,6 +211,9 @@ class ProjectMemberViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True) # Fetch the workspace role of the project member @@ -266,6 +276,9 @@ class ProjectMemberViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN]) def destroy(self, request, slug, project_id, pk): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + project_member = ProjectMember.objects.get( workspace__slug=slug, project_id=project_id, @@ -300,6 +313,9 @@ class ProjectMemberViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def leave(self, request, slug, project_id): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + project_member = ProjectMember.objects.get( workspace__slug=slug, project_id=project_id, diff --git a/plane-src/apps/api/plane/app/views/workspace/base.py b/plane-src/apps/api/plane/app/views/workspace/base.py index e106c0b..d19e89f 100644 --- a/plane-src/apps/api/plane/app/views/workspace/base.py +++ b/plane-src/apps/api/plane/app/views/workspace/base.py @@ -257,7 +257,8 @@ class UserWorkSpacesEndpoint(BaseAPIView): class NodeDCWorkspaceCreationPolicyEndpoint(BaseAPIView): def get(self, request): - return Response(get_nodedc_workspace_creation_policy(request.user), status=status.HTTP_200_OK) + workspace_slug = request.query_params.get("workspace_slug") or request.query_params.get("workspaceSlug") + return Response(get_nodedc_workspace_creation_policy(request.user, workspace_slug=workspace_slug), status=status.HTTP_200_OK) class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): diff --git a/plane-src/apps/api/plane/app/views/workspace/invite.py b/plane-src/apps/api/plane/app/views/workspace/invite.py index a1babc7..11fc184 100644 --- a/plane-src/apps/api/plane/app/views/workspace/invite.py +++ b/plane-src/apps/api/plane/app/views/workspace/invite.py @@ -20,6 +20,10 @@ from rest_framework.response import Response # Module imports from plane.app.permissions import WorkSpaceAdminPermission +from plane.authentication.nodedc_workspace_policy import ( + is_nodedc_launcher_managed_workspace, + nodedc_launcher_managed_workspace_response, +) from plane.app.serializers import ( WorkSpaceMemberInviteSerializer, WorkSpaceMemberSerializer, @@ -52,6 +56,9 @@ class WorkspaceInvitationsViewset(BaseViewSet): ) def create(self, request, slug): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + emails = request.data.get("emails", []) # Check if email is provided if not emails: @@ -154,6 +161,9 @@ class WorkspaceInvitationsViewset(BaseViewSet): return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK) def destroy(self, request, slug, pk): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + workspace_member_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug) workspace_member_invite.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/plane-src/apps/api/plane/app/views/workspace/member.py b/plane-src/apps/api/plane/app/views/workspace/member.py index 96f3423..0009516 100644 --- a/plane-src/apps/api/plane/app/views/workspace/member.py +++ b/plane-src/apps/api/plane/app/views/workspace/member.py @@ -12,6 +12,10 @@ from rest_framework import status from rest_framework.response import Response from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE +from plane.authentication.nodedc_workspace_policy import ( + is_nodedc_launcher_managed_workspace, + nodedc_launcher_managed_workspace_response, +) # Module imports from plane.app.serializers import ( @@ -75,6 +79,9 @@ class WorkSpaceMemberViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def partial_update(self, request, slug, pk): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + workspace_member = WorkspaceMember.objects.get( pk=pk, workspace__slug=slug, member__is_bot=False, is_active=True ) @@ -97,6 +104,9 @@ class WorkSpaceMemberViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def destroy(self, request, slug, pk): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + # 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 @@ -160,6 +170,9 @@ class WorkSpaceMemberViewSet(BaseViewSet): @invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True) @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def leave(self, request, slug): + if is_nodedc_launcher_managed_workspace(request.user, slug): + return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN) + workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user, is_active=True) # Check if the leaving user is the only admin of the workspace diff --git a/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py b/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py index 9fdd3b0..392104f 100644 --- a/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py +++ b/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py @@ -9,7 +9,7 @@ from plane.db.models import ExternalIdentityLink OIDC_PROVIDER = "authentik" -def get_nodedc_workspace_creation_policy(user): +def get_nodedc_workspace_creation_policy(user, workspace_slug=None): check_url = ( os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_URL", "").strip() or os.environ.get("PLANE_NODEDC_ACCESS_CHECK_URL", "").strip() @@ -21,6 +21,9 @@ def get_nodedc_workspace_creation_policy(user): "enabled": False, "can_create_workspace": True, "mode": "standalone", + "managed_by": "tasker", + "default_managed_by": "tasker", + "workspaces": [], "reason": "NODE.DC workspace policy is not configured.", } @@ -36,6 +39,9 @@ def get_nodedc_workspace_creation_policy(user): "enabled": True, "can_create_workspace": not enforce_unlinked, "mode": "unlinked", + "managed_by": "tasker", + "default_managed_by": "tasker", + "workspaces": [], "reason": "NODE.DC identity is not linked." if enforce_unlinked else "Standalone user without NODE.DC identity.", } @@ -61,6 +67,9 @@ def get_nodedc_workspace_creation_policy(user): "enabled": True, "can_create_workspace": False, "mode": "unavailable", + "managed_by": "tasker", + "default_managed_by": "tasker", + "workspaces": [], "reason": "NODE.DC workspace policy is unavailable.", } @@ -71,18 +80,84 @@ def get_nodedc_workspace_creation_policy(user): "enabled": True, "can_create_workspace": access_allowed, "mode": "legacy_access_check", + "managed_by": "tasker", + "default_managed_by": "tasker", + "workspaces": [], "reason": payload.get("reason") or "NODE.DC access check does not expose workspace policy.", } can_create_workspace = access_allowed and bool(workspace_policy.get("canCreateWorkspace")) + workspaces = normalize_workspace_management_list(workspace_policy.get("workspaces")) + managed_by = resolve_workspace_managed_by( + workspace_slug=workspace_slug, + workspaces=workspaces, + fallback=workspace_policy.get("managedBy") or workspace_policy.get("defaultManagedBy"), + ) return { "enabled": True, "can_create_workspace": can_create_workspace, "mode": workspace_policy.get("mode") or "unknown", + "managed_by": managed_by, + "default_managed_by": normalize_managed_by(workspace_policy.get("defaultManagedBy") or workspace_policy.get("managedBy")), + "workspaces": workspaces, "reason": workspace_policy.get("reason") or payload.get("reason") or "NODE.DC workspace policy decision.", } def is_truthy(value): return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def normalize_managed_by(value): + return "launcher" if value == "launcher" else "tasker" + + +def normalize_workspace_management_list(value): + if not isinstance(value, list): + return [] + + workspaces = [] + for item in value: + if not isinstance(item, dict): + continue + slug = item.get("slug") + if not isinstance(slug, str) or not slug.strip(): + continue + workspaces.append( + { + "slug": slug.strip(), + "name": item.get("name") if isinstance(item.get("name"), str) and item.get("name").strip() else None, + "managed_by": normalize_managed_by(item.get("managedBy") or item.get("managed_by")), + "client_id": item.get("clientId") if isinstance(item.get("clientId"), str) else None, + "client_name": item.get("clientName") if isinstance(item.get("clientName"), str) else None, + "role": item.get("role") if item.get("role") in {"guest", "member", "admin"} else "member", + } + ) + + return workspaces + + +def resolve_workspace_managed_by(workspace_slug, workspaces, fallback): + if isinstance(workspace_slug, str) and workspace_slug.strip(): + normalized_slug = workspace_slug.strip() + for workspace in workspaces: + if workspace["slug"] == normalized_slug: + return workspace["managed_by"] + return "tasker" + + return normalize_managed_by(fallback) + + +def is_nodedc_launcher_managed_workspace(user, workspace_slug): + policy = get_nodedc_workspace_creation_policy(user, workspace_slug=workspace_slug) + return bool(policy.get("enabled")) and ( + policy.get("managed_by") == "launcher" or policy.get("mode") == "unavailable" + ) + + +def nodedc_launcher_managed_workspace_response(): + return { + "error": "nodedc_launcher_managed_workspace", + "reason": "Участниками и ролями этого workspace управляет Launcher.", + } diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx index 61abee5..9efbc2e 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -5,6 +5,7 @@ */ import { observer } from "mobx-react"; +import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; @@ -20,10 +21,14 @@ import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; // plane web imports import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list"; +// services +import { WorkspaceService } from "@/services/workspace.service"; // local imports import type { Route } from "./+types/page"; import { MembersProjectSettingsHeader } from "./header"; +const workspaceService = new WorkspaceService(); + function MembersSettingsPage({ params }: Route.ComponentProps) { // router const { workspaceSlug, projectId } = params; @@ -32,6 +37,9 @@ function MembersSettingsPage({ params }: Route.ComponentProps) { // store hooks const { currentProjectDetails } = useProject(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { data: nodedcWorkspacePolicy } = useSWR(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`, () => + workspaceService.getNodeDCWorkspacePolicy(workspaceSlug) + ); // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; const isProjectMemberOrAdmin = allowPermissions( @@ -45,6 +53,25 @@ function MembersSettingsPage({ params }: Route.ComponentProps) { return ; } + if (nodedcWorkspacePolicy?.managed_by === "launcher") { + return ( + } hugging> + + +
+

NODE.DC managed project

+
+

Участники проекта управляются в Launcher.

+

+ Этот workspace подключен к enterprise-контуру NODE.DC. Project-level доступы назначаются в Launcher, поэтому + локальное управление участниками проекта в Task Manager заблокировано. +

+
+
+
+ ); + } + return ( } hugging> diff --git a/plane-src/apps/web/app/(all)/create-workspace/page.tsx b/plane-src/apps/web/app/(all)/create-workspace/page.tsx index a7c3648..1c1391a 100644 --- a/plane-src/apps/web/app/(all)/create-workspace/page.tsx +++ b/plane-src/apps/web/app/(all)/create-workspace/page.tsx @@ -60,6 +60,9 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() { enabled: false, can_create_workspace: true, mode: "unavailable", + managed_by: "tasker", + default_managed_by: "tasker", + workspaces: [], reason: "NODE.DC workspace policy is unavailable.", }); } diff --git a/plane-src/apps/web/core/components/workspace/settings/members-settings.tsx b/plane-src/apps/web/core/components/workspace/settings/members-settings.tsx index 0459f04..ed9eeb0 100644 --- a/plane-src/apps/web/core/components/workspace/settings/members-settings.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/members-settings.tsx @@ -5,6 +5,7 @@ */ import { useState } from "react"; +import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; @@ -24,11 +25,15 @@ import { useUserPermissions } from "@/hooks/store/user"; // plane web components import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button"; import { MembersActivityButton, SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members"; +// services +import { WorkspaceService } from "@/services/workspace.service"; type TWorkspaceMembersSettingsContentProps = { workspaceSlug: string; }; +const workspaceService = new WorkspaceService(); + export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMembersSettingsContentProps) { const [inviteModal, setInviteModal] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -37,6 +42,9 @@ export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMem workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore }, } = useMember(); const { t } = useTranslation(); + const { data: nodedcWorkspacePolicy } = useSWR(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`, () => + workspaceService.getNodeDCWorkspacePolicy(workspaceSlug) + ); const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const canPerformWorkspaceMemberActions = allowPermissions( @@ -79,6 +87,21 @@ export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMem return ; } + if (nodedcWorkspacePolicy?.managed_by === "launcher") { + return ( +
+

NODE.DC managed workspace

+
+

Участники управляются в Launcher.

+

+ Этот workspace подключен к enterprise-контуру NODE.DC. Добавление пользователей, инвайты, роли workspace и + проектные назначения выполняются через Launcher, чтобы Task Manager не стал вторым источником прав. +

+
+
+ ); + } + return ( <> (["billing-and-plans"]); +const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set(["members"]); 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 workspaceService = new WorkspaceService(); const getInitialTab = (): TWorkspaceSettingsModalTab => { if (typeof window === "undefined") return "general"; @@ -79,7 +82,12 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null, () => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string) ); + const { data: nodedcWorkspacePolicy } = useSWR( + currentWorkspace?.slug ? `NODEDC_WORKSPACE_POLICY_${currentWorkspace.slug}` : null, + () => workspaceService.getNodeDCWorkspacePolicy(currentWorkspace?.slug as string) + ); const isVoiceTaskerEntitled = aiSettings?.feature_entitlement_enabled === true; + const isLauncherManagedWorkspace = nodedcWorkspacePolicy?.managed_by === "launcher"; useEffect(() => { const syncFromLocation = () => { @@ -115,6 +123,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() if (!isVoiceTaskerEntitled) openWorkspaceSettingsModal("general", true); }, [activeTab, isOpen, isVoiceTaskerEntitlementLoading, isVoiceTaskerEntitled]); + useEffect(() => { + if (!isOpen || activeTab !== "members" || !isLauncherManagedWorkspace) return; + openWorkspaceSettingsModal("general", true); + }, [activeTab, isLauncherManagedWorkspace, isOpen]); + const handleClose = () => { closeWorkspaceSettingsModal(); }; @@ -175,6 +188,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() onSelectItem={handleSelectItem} allowPermissions={allowPermissions} isVoiceTaskerEntitled={isVoiceTaskerEntitled} + isLauncherManagedWorkspace={isLauncherManagedWorkspace} workspaceSlug={currentWorkspace?.slug} /> @@ -209,6 +223,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() type TWorkspaceModalSidebarProps = { activeTab: TWorkspaceSettingsModalTab; allowPermissions: ReturnType["allowPermissions"]; + isLauncherManagedWorkspace: boolean; isVoiceTaskerEntitled: boolean; onSelectItem: (itemKey: TWorkspaceSettingsTabs, itemHref: string) => void; workspaceSlug?: string; @@ -217,6 +232,7 @@ type TWorkspaceModalSidebarProps = { function WorkspaceModalSidebar({ activeTab, allowPermissions, + isLauncherManagedWorkspace, isVoiceTaskerEntitled, onSelectItem, workspaceSlug, @@ -235,6 +251,7 @@ function WorkspaceModalSidebar({ const accessibleItems = GROUPED_WORKSPACE_SETTINGS[category].filter( (item) => !HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) && + (!isLauncherManagedWorkspace || !LAUNCHER_MANAGED_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/services/workspace.service.ts b/plane-src/apps/web/core/services/workspace.service.ts index d1dc875..6f1697f 100644 --- a/plane-src/apps/web/core/services/workspace.service.ts +++ b/plane-src/apps/web/core/services/workspace.service.ts @@ -39,6 +39,16 @@ export interface NodeDCWorkspacePolicy { enabled: boolean; can_create_workspace: boolean; mode: string; + managed_by: "launcher" | "tasker"; + default_managed_by: "launcher" | "tasker"; + workspaces: Array<{ + slug: string; + name: string | null; + managed_by: "launcher" | "tasker"; + client_id: string | null; + client_name: string | null; + role: "guest" | "member" | "admin"; + }>; reason: string; } @@ -102,8 +112,9 @@ export class WorkspaceService extends APIService { }); } - async getNodeDCWorkspacePolicy(): Promise { - return this.get("/api/nodedc/workspace-policy/") + async getNodeDCWorkspacePolicy(workspaceSlug?: string): Promise { + const params = workspaceSlug ? `?workspace_slug=${encodeURIComponent(workspaceSlug)}` : ""; + return this.get(`/api/nodedc/workspace-policy/${params}`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/scripts/bootstrap_nodedc_platform_plan.py b/scripts/bootstrap_nodedc_platform_plan.py index c5056c4..c98f9ce 100644 --- a/scripts/bootstrap_nodedc_platform_plan.py +++ b/scripts/bootstrap_nodedc_platform_plan.py @@ -899,7 +899,9 @@ Plane должен оставаться самостоятельным прод Практически проверено: корпоративные назначения из Launcher доходят до Operational Core, public-пользователь может создать workspace после выдачи доступа, а прямой доступ к сервису проходит через NODE.DC SSO. Safari-only падение workspace зафиксировано как отдельный deferred debug, потому что Chrome/Chromium flow работает и проблема не должна блокировать платформенную обвязку. -Открытая развилка: нужно формально добавить managedBy=launcher/managedBy=tasker или эквивалентный флаг в mapping workspace, чтобы интерфейс Task Manager понимал, когда скрывать собственное управление пользователями, а когда оставлять автономный SaaS-режим. +Развилка managedBy закрыта в NDCPLATFORM-8: Launcher хранит managedBy в Tasker workspace binding, отдает workspacePolicy/workspaces через internal access-check, а Tasker резолвит policy по workspace_slug. Для managedBy=launcher интерфейс и backend Tasker блокируют конфликтующее управление участниками/инвайтами; для managedBy=tasker штатные Tasker users/invites остаются частью standalone/public режима. + +Важная runtime-оговорка: в standalone или неверно поднятом local runtime без PLANE_NODEDC_* env Tasker продолжает работать в безопасном standalone-режиме managedBy=tasker. Для NODE.DC enforcement контейнеры должны запускаться с plane.env или эквивалентными env. """, ), checker( @@ -909,8 +911,8 @@ Plane должен оставаться самостоятельным прод {"text": "Сохранить автономность Task Manager как standalone-продукта.", "checked": True}, {"text": "Зафиксировать managedBy=launcher для enterprise workspace.", "checked": True}, {"text": "Зафиксировать managedBy=tasker для standalone/public workspace.", "checked": True}, - "Скрыть или readonly-заблокировать Task Manager users/invites для managedBy=launcher.", - "Оставить Task Manager users/invites включенными для managedBy=tasker.", + {"text": "Скрыть или readonly-заблокировать Task Manager users/invites для managedBy=launcher.", "checked": True}, + {"text": "Оставить Task Manager users/invites включенными для managedBy=tasker.", "checked": True}, "Очистить оставшиеся demo users/seed data без удаления живых связей.", "Оформить Safari-only workspace crash как отдельный deferred debug.", ], @@ -1139,7 +1141,7 @@ Launcher: добавлены admin routes для project memberships, control-pl "tasker-provisioning", "Этап 5. Stale assignees cleanup после снятия пользователей", """ -Статус: реализовано в рабочем дереве, ожидает финальную проверку и коммит после подтверждения. +Статус: реализовано и закоммичено. После удаления, блокировки или снятия пользователя из workspace/project Tasker не должен продолжать показывать его исполнителем в карточках и группировках. Последняя локальная правка удаляет IssueAssignee на membership remove и фильтрует assignee arrays только по активным workspace/project membership. """, @@ -1165,14 +1167,14 @@ Launcher: добавлены admin routes для project memberships, control-pl Проверка БД 2026-05-09: запрос по активным IssueAssignee без active WorkspaceMember/ProjectMember вернул 0 записей. Значит текущий runtime не содержит реально stale assignee links после последней очистки. -Рабочее дерево Task Manager остается dirty: этот этап еще не закоммичен и требует финальной проверки перед переводом карточного пункта в полностью закрытое состояние. +Этап закоммичен отдельным изменением Operational Core. Глобальный frontend typecheck остается не полностью чистым из-за ранее существующих unrelated ошибок Plane fork, поэтому следующий регресс лучше проверять точечно по issue list/kanban после пересборки web runtime. """, ), text_block( "tasker-provisioning", "Этап 6. Source-of-truth split managedBy", """ -Статус: следующий критический этап. +Статус: реализовано локально, проверено на policy path, готово к browser acceptance. Нужно формально закрепить источник управления для workspace. managedBy=launcher означает enterprise workspace: пользователи, инвайты и базовые роли идут из Launcher, а Tasker не должен давать конфликтующее управление. managedBy=tasker означает standalone/public workspace: штатные Tasker механизмы пользователей, инвайтов и ролей остаются включенными. """, @@ -1181,15 +1183,28 @@ Launcher: добавлены admin routes для project memberships, control-pl "tasker-provisioning6", "Чекер этапа 6. Source-of-truth split managedBy", [ - "Добавить managedBy в Launcher Tasker workspace binding.", - "Возвращать managedBy/workspacePolicy из Launcher internal access-check.", - "Передавать managedBy в Tasker adapter responses или workspace policy resolver.", - "Скрыть или readonly-заблокировать Tasker users/invites для managedBy=launcher.", - "Оставить Tasker users/invites включенными для managedBy=tasker.", + {"text": "Добавить managedBy в Launcher Tasker workspace binding.", "checked": True}, + {"text": "Возвращать managedBy/workspacePolicy из Launcher internal access-check.", "checked": True}, + {"text": "Передавать managedBy в Tasker adapter responses или workspace policy resolver.", "checked": True}, + {"text": "Скрыть или readonly-заблокировать Tasker users/invites для managedBy=launcher.", "checked": True}, + {"text": "Оставить Tasker users/invites включенными для managedBy=tasker.", "checked": True}, "Проверить enterprise client admin и public self-service user flows отдельно.", - "Зафиксировать правила в NDCPLATFORM-4 и NDCPLATFORM-10 после реализации.", + {"text": "Зафиксировать правила в NDCPLATFORM-4 и NDCPLATFORM-10 после реализации.", "checked": True}, ], ), + text_block( + "tasker-provisioning", + "Реализация этапа 6", + """ +Launcher: Tasker workspace binding получил managedBy=launcher|tasker. Legacy workspace binding по умолчанию нормализуется как launcher-managed, а internal access-check возвращает managedBy/defaultManagedBy/workspaces вместе с canCreateWorkspace. Для обычного enterprise-пользователя с launcher-managed workspace создание workspace запрещается, root/superadmin сохраняет право создавать новые workspace. + +Tasker backend: workspace policy resolver принимает workspace_slug и возвращает managed_by/default_managed_by/workspaces. Workspace members, project members и workspace invites блокируют create/update/delete/leave операции для launcher-managed workspace с ошибкой nodedc_launcher_managed_workspace. Без NODE.DC env Operational Core остается standalone и считает workspace tasker-managed. + +Tasker frontend: create-workspace flow понимает новые поля policy, settings modal скрывает members tab для launcher-managed workspace, а страницы workspace/project members показывают readonly-сообщение о том, что участниками управляет Launcher. Для managedBy=tasker штатные Tasker users/invites UI не отключаются. + +Проверки 2026-05-09: Launcher node --check прошел для server/dev-server.mjs и server/control-plane-store.mjs; Tasker python compile прошел для policy/member/invite views; Launcher access-check для support@dctouch.ru вернул managedBy=launcher и canCreateWorkspace=false; Tasker policy resolver в API container с NODE.DC env вернул managed_by=launcher и is_launcher_managed=True для workspace nodedc. pnpm --filter web check:types все еще падает на ранее существующих unrelated TypeScript ошибках Plane fork, новых ошибок в измененных файлах не выявлено. +""", + ), ], }, {