АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: managedBy split Operational Core

This commit is contained in:
DCCONSTRUCTIONS
2026-05-09 12:49:09 +03:00
parent ca9fd34e91
commit 11c8c6fb1b
11 changed files with 227 additions and 16 deletions
@@ -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,
@@ -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):
@@ -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)
@@ -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
@@ -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.",
}
@@ -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 <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
if (nodedcWorkspacePolicy?.managed_by === "launcher") {
return (
<SettingsContentWrapper header={<MembersProjectSettingsHeader />} hugging>
<PageHead title={pageTitle} />
<SettingsHeading title={t("common.members")} />
<section className="rounded-2xl border border-custom-border-200 bg-custom-background-90 p-8">
<p className="text-sm font-semibold uppercase tracking-[0.22em] text-custom-text-300">NODE.DC managed project</p>
<div className="mt-3 max-w-2xl space-y-3">
<h4 className="text-h3-medium">Участники проекта управляются в Launcher.</h4>
<p className="text-body-sm-regular text-custom-text-300">
Этот workspace подключен к enterprise-контуру NODE.DC. Project-level доступы назначаются в Launcher, поэтому
локальное управление участниками проекта в Task Manager заблокировано.
</p>
</div>
</section>
</SettingsContentWrapper>
);
}
return (
<SettingsContentWrapper header={<MembersProjectSettingsHeader />} hugging>
<PageHead title={pageTitle} />
@@ -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.",
});
}
@@ -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<string>("");
@@ -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 <NotAuthorizedView section="settings" className="h-auto" />;
}
if (nodedcWorkspacePolicy?.managed_by === "launcher") {
return (
<section className="flex size-full flex-col items-start justify-center gap-4 rounded-2xl border border-custom-border-200 bg-custom-background-90 p-8 text-left">
<p className="text-sm font-semibold uppercase tracking-[0.22em] text-custom-text-300">NODE.DC managed workspace</p>
<div className="max-w-2xl space-y-3">
<h4 className="text-h3-medium">Участники управляются в Launcher.</h4>
<p className="text-body-sm-regular text-custom-text-300">
Этот workspace подключен к enterprise-контуру NODE.DC. Добавление пользователей, инвайты, роли workspace и
проектные назначения выполняются через Launcher, чтобы Task Manager не стал вторым источником прав.
</p>
</div>
</section>
);
}
return (
<>
<SendWorkspaceInvitationModal
@@ -35,6 +35,7 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspace } from "@/hooks/store/use-workspace";
// services
import { WorkspaceAIService } from "@/services/workspace-ai.service";
import { WorkspaceService } from "@/services/workspace.service";
// local imports
import {
closeWorkspaceSettingsModal,
@@ -46,9 +47,11 @@ import {
} from "./workspace-settings-modal.utils";
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]);
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["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}
/>
</div>
@@ -209,6 +223,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
type TWorkspaceModalSidebarProps = {
activeTab: TWorkspaceSettingsModalTab;
allowPermissions: ReturnType<typeof useUserPermissions>["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)
);
@@ -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<NodeDCWorkspacePolicy> {
return this.get("/api/nodedc/workspace-policy/")
async getNodeDCWorkspacePolicy(workspaceSlug?: string): Promise<NodeDCWorkspacePolicy> {
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;