АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: managedBy split Operational Core
This commit is contained in:
parent
ca9fd34e91
commit
11c8c6fb1b
|
|
@ -9,6 +9,10 @@ from django.db.models import Min
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseViewSet, BaseAPIView
|
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 (
|
from plane.app.serializers import (
|
||||||
ProjectMemberSerializer,
|
ProjectMemberSerializer,
|
||||||
ProjectMemberAdminSerializer,
|
ProjectMemberAdminSerializer,
|
||||||
|
|
@ -45,6 +49,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN])
|
@allow_permission([ROLE.ADMIN])
|
||||||
def create(self, request, slug, project_id):
|
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
|
# 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", [])
|
members = request.data.get("members", [])
|
||||||
|
|
||||||
|
|
@ -204,6 +211,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
def partial_update(self, request, slug, project_id, pk):
|
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)
|
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
|
# Fetch the workspace role of the project member
|
||||||
|
|
@ -266,6 +276,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN])
|
@allow_permission([ROLE.ADMIN])
|
||||||
def destroy(self, request, slug, project_id, pk):
|
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(
|
project_member = ProjectMember.objects.get(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
@ -300,6 +313,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
def leave(self, request, slug, project_id):
|
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(
|
project_member = ProjectMember.objects.get(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,8 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||||
|
|
||||||
class NodeDCWorkspaceCreationPolicyEndpoint(BaseAPIView):
|
class NodeDCWorkspaceCreationPolicyEndpoint(BaseAPIView):
|
||||||
def get(self, request):
|
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):
|
class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.app.permissions import WorkSpaceAdminPermission
|
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 (
|
from plane.app.serializers import (
|
||||||
WorkSpaceMemberInviteSerializer,
|
WorkSpaceMemberInviteSerializer,
|
||||||
WorkSpaceMemberSerializer,
|
WorkSpaceMemberSerializer,
|
||||||
|
|
@ -52,6 +56,9 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
)
|
)
|
||||||
|
|
||||||
def create(self, request, slug):
|
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", [])
|
emails = request.data.get("emails", [])
|
||||||
# Check if email is provided
|
# Check if email is provided
|
||||||
if not emails:
|
if not emails:
|
||||||
|
|
@ -154,6 +161,9 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
|
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, slug, pk):
|
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 = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug)
|
||||||
workspace_member_invite.delete()
|
workspace_member_invite.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
|
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
|
# Module imports
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
|
|
@ -75,6 +79,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||||
|
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||||
def partial_update(self, request, slug, pk):
|
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(
|
workspace_member = WorkspaceMember.objects.get(
|
||||||
pk=pk, workspace__slug=slug, member__is_bot=False, is_active=True
|
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")
|
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||||
def destroy(self, request, slug, pk):
|
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
|
# Check the user role who is deleting the user
|
||||||
workspace_member = WorkspaceMember.objects.get(
|
workspace_member = WorkspaceMember.objects.get(
|
||||||
workspace__slug=slug, pk=pk, member__is_bot=False, is_active=True
|
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)
|
@invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True)
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||||
def leave(self, request, slug):
|
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)
|
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
|
# 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"
|
OIDC_PROVIDER = "authentik"
|
||||||
|
|
||||||
|
|
||||||
def get_nodedc_workspace_creation_policy(user):
|
def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
|
||||||
check_url = (
|
check_url = (
|
||||||
os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_URL", "").strip()
|
os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_URL", "").strip()
|
||||||
or os.environ.get("PLANE_NODEDC_ACCESS_CHECK_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,
|
"enabled": False,
|
||||||
"can_create_workspace": True,
|
"can_create_workspace": True,
|
||||||
"mode": "standalone",
|
"mode": "standalone",
|
||||||
|
"managed_by": "tasker",
|
||||||
|
"default_managed_by": "tasker",
|
||||||
|
"workspaces": [],
|
||||||
"reason": "NODE.DC workspace policy is not configured.",
|
"reason": "NODE.DC workspace policy is not configured.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +39,9 @@ def get_nodedc_workspace_creation_policy(user):
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"can_create_workspace": not enforce_unlinked,
|
"can_create_workspace": not enforce_unlinked,
|
||||||
"mode": "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.",
|
"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,
|
"enabled": True,
|
||||||
"can_create_workspace": False,
|
"can_create_workspace": False,
|
||||||
"mode": "unavailable",
|
"mode": "unavailable",
|
||||||
|
"managed_by": "tasker",
|
||||||
|
"default_managed_by": "tasker",
|
||||||
|
"workspaces": [],
|
||||||
"reason": "NODE.DC workspace policy is unavailable.",
|
"reason": "NODE.DC workspace policy is unavailable.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,18 +80,84 @@ def get_nodedc_workspace_creation_policy(user):
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"can_create_workspace": access_allowed,
|
"can_create_workspace": access_allowed,
|
||||||
"mode": "legacy_access_check",
|
"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.",
|
"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"))
|
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 {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"can_create_workspace": can_create_workspace,
|
"can_create_workspace": can_create_workspace,
|
||||||
"mode": workspace_policy.get("mode") or "unknown",
|
"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.",
|
"reason": workspace_policy.get("reason") or payload.get("reason") or "NODE.DC workspace policy decision.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_truthy(value):
|
def is_truthy(value):
|
||||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
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 { observer } from "mobx-react";
|
||||||
|
import useSWR from "swr";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
|
@ -20,10 +21,14 @@ import { useProject } from "@/hooks/store/use-project";
|
||||||
import { useUserPermissions } from "@/hooks/store/user";
|
import { useUserPermissions } from "@/hooks/store/user";
|
||||||
// plane web imports
|
// plane web imports
|
||||||
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list";
|
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list";
|
||||||
|
// services
|
||||||
|
import { WorkspaceService } from "@/services/workspace.service";
|
||||||
// local imports
|
// local imports
|
||||||
import type { Route } from "./+types/page";
|
import type { Route } from "./+types/page";
|
||||||
import { MembersProjectSettingsHeader } from "./header";
|
import { MembersProjectSettingsHeader } from "./header";
|
||||||
|
|
||||||
|
const workspaceService = new WorkspaceService();
|
||||||
|
|
||||||
function MembersSettingsPage({ params }: Route.ComponentProps) {
|
function MembersSettingsPage({ params }: Route.ComponentProps) {
|
||||||
// router
|
// router
|
||||||
const { workspaceSlug, projectId } = params;
|
const { workspaceSlug, projectId } = params;
|
||||||
|
|
@ -32,6 +37,9 @@ function MembersSettingsPage({ params }: Route.ComponentProps) {
|
||||||
// store hooks
|
// store hooks
|
||||||
const { currentProjectDetails } = useProject();
|
const { currentProjectDetails } = useProject();
|
||||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||||
|
const { data: nodedcWorkspacePolicy } = useSWR(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`, () =>
|
||||||
|
workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
|
||||||
|
);
|
||||||
// derived values
|
// derived values
|
||||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
|
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
|
||||||
const isProjectMemberOrAdmin = allowPermissions(
|
const isProjectMemberOrAdmin = allowPermissions(
|
||||||
|
|
@ -45,6 +53,25 @@ function MembersSettingsPage({ params }: Route.ComponentProps) {
|
||||||
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
|
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 (
|
return (
|
||||||
<SettingsContentWrapper header={<MembersProjectSettingsHeader />} hugging>
|
<SettingsContentWrapper header={<MembersProjectSettingsHeader />} hugging>
|
||||||
<PageHead title={pageTitle} />
|
<PageHead title={pageTitle} />
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
can_create_workspace: true,
|
can_create_workspace: true,
|
||||||
mode: "unavailable",
|
mode: "unavailable",
|
||||||
|
managed_by: "tasker",
|
||||||
|
default_managed_by: "tasker",
|
||||||
|
workspaces: [],
|
||||||
reason: "NODE.DC workspace policy is unavailable.",
|
reason: "NODE.DC workspace policy is unavailable.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
|
@ -24,11 +25,15 @@ import { useUserPermissions } from "@/hooks/store/user";
|
||||||
// plane web components
|
// plane web components
|
||||||
import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button";
|
import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button";
|
||||||
import { MembersActivityButton, SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members";
|
import { MembersActivityButton, SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members";
|
||||||
|
// services
|
||||||
|
import { WorkspaceService } from "@/services/workspace.service";
|
||||||
|
|
||||||
type TWorkspaceMembersSettingsContentProps = {
|
type TWorkspaceMembersSettingsContentProps = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const workspaceService = new WorkspaceService();
|
||||||
|
|
||||||
export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMembersSettingsContentProps) {
|
export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMembersSettingsContentProps) {
|
||||||
const [inviteModal, setInviteModal] = useState(false);
|
const [inviteModal, setInviteModal] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
|
@ -37,6 +42,9 @@ export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMem
|
||||||
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
|
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
|
||||||
} = useMember();
|
} = useMember();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: nodedcWorkspacePolicy } = useSWR(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`, () =>
|
||||||
|
workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
|
||||||
|
);
|
||||||
|
|
||||||
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||||
const canPerformWorkspaceMemberActions = allowPermissions(
|
const canPerformWorkspaceMemberActions = allowPermissions(
|
||||||
|
|
@ -79,6 +87,21 @@ export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMem
|
||||||
return <NotAuthorizedView section="settings" className="h-auto" />;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SendWorkspaceInvitationModal
|
<SendWorkspaceInvitationModal
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { useUserPermissions } from "@/hooks/store/user";
|
||||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
// services
|
// services
|
||||||
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
||||||
|
import { WorkspaceService } from "@/services/workspace.service";
|
||||||
// local imports
|
// local imports
|
||||||
import {
|
import {
|
||||||
closeWorkspaceSettingsModal,
|
closeWorkspaceSettingsModal,
|
||||||
|
|
@ -46,9 +47,11 @@ import {
|
||||||
} from "./workspace-settings-modal.utils";
|
} from "./workspace-settings-modal.utils";
|
||||||
|
|
||||||
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
|
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 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 MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]);
|
||||||
const workspaceAIService = new WorkspaceAIService();
|
const workspaceAIService = new WorkspaceAIService();
|
||||||
|
const workspaceService = new WorkspaceService();
|
||||||
|
|
||||||
const getInitialTab = (): TWorkspaceSettingsModalTab => {
|
const getInitialTab = (): TWorkspaceSettingsModalTab => {
|
||||||
if (typeof window === "undefined") return "general";
|
if (typeof window === "undefined") return "general";
|
||||||
|
|
@ -79,7 +82,12 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
|
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
|
||||||
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
|
() => 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 isVoiceTaskerEntitled = aiSettings?.feature_entitlement_enabled === true;
|
||||||
|
const isLauncherManagedWorkspace = nodedcWorkspacePolicy?.managed_by === "launcher";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncFromLocation = () => {
|
const syncFromLocation = () => {
|
||||||
|
|
@ -115,6 +123,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
if (!isVoiceTaskerEntitled) openWorkspaceSettingsModal("general", true);
|
if (!isVoiceTaskerEntitled) openWorkspaceSettingsModal("general", true);
|
||||||
}, [activeTab, isOpen, isVoiceTaskerEntitlementLoading, isVoiceTaskerEntitled]);
|
}, [activeTab, isOpen, isVoiceTaskerEntitlementLoading, isVoiceTaskerEntitled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || activeTab !== "members" || !isLauncherManagedWorkspace) return;
|
||||||
|
openWorkspaceSettingsModal("general", true);
|
||||||
|
}, [activeTab, isLauncherManagedWorkspace, isOpen]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
closeWorkspaceSettingsModal();
|
closeWorkspaceSettingsModal();
|
||||||
};
|
};
|
||||||
|
|
@ -175,6 +188,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
onSelectItem={handleSelectItem}
|
onSelectItem={handleSelectItem}
|
||||||
allowPermissions={allowPermissions}
|
allowPermissions={allowPermissions}
|
||||||
isVoiceTaskerEntitled={isVoiceTaskerEntitled}
|
isVoiceTaskerEntitled={isVoiceTaskerEntitled}
|
||||||
|
isLauncherManagedWorkspace={isLauncherManagedWorkspace}
|
||||||
workspaceSlug={currentWorkspace?.slug}
|
workspaceSlug={currentWorkspace?.slug}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -209,6 +223,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
type TWorkspaceModalSidebarProps = {
|
type TWorkspaceModalSidebarProps = {
|
||||||
activeTab: TWorkspaceSettingsModalTab;
|
activeTab: TWorkspaceSettingsModalTab;
|
||||||
allowPermissions: ReturnType<typeof useUserPermissions>["allowPermissions"];
|
allowPermissions: ReturnType<typeof useUserPermissions>["allowPermissions"];
|
||||||
|
isLauncherManagedWorkspace: boolean;
|
||||||
isVoiceTaskerEntitled: boolean;
|
isVoiceTaskerEntitled: boolean;
|
||||||
onSelectItem: (itemKey: TWorkspaceSettingsTabs, itemHref: string) => void;
|
onSelectItem: (itemKey: TWorkspaceSettingsTabs, itemHref: string) => void;
|
||||||
workspaceSlug?: string;
|
workspaceSlug?: string;
|
||||||
|
|
@ -217,6 +232,7 @@ type TWorkspaceModalSidebarProps = {
|
||||||
function WorkspaceModalSidebar({
|
function WorkspaceModalSidebar({
|
||||||
activeTab,
|
activeTab,
|
||||||
allowPermissions,
|
allowPermissions,
|
||||||
|
isLauncherManagedWorkspace,
|
||||||
isVoiceTaskerEntitled,
|
isVoiceTaskerEntitled,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
|
|
@ -235,6 +251,7 @@ function WorkspaceModalSidebar({
|
||||||
const accessibleItems = GROUPED_WORKSPACE_SETTINGS[category].filter(
|
const accessibleItems = GROUPED_WORKSPACE_SETTINGS[category].filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
!HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) &&
|
!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) &&
|
(!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) || isVoiceTaskerEntitled) &&
|
||||||
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug)
|
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,16 @@ export interface NodeDCWorkspacePolicy {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
can_create_workspace: boolean;
|
can_create_workspace: boolean;
|
||||||
mode: string;
|
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;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,8 +112,9 @@ export class WorkspaceService extends APIService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNodeDCWorkspacePolicy(): Promise<NodeDCWorkspacePolicy> {
|
async getNodeDCWorkspacePolicy(workspaceSlug?: string): Promise<NodeDCWorkspacePolicy> {
|
||||||
return this.get("/api/nodedc/workspace-policy/")
|
const params = workspaceSlug ? `?workspace_slug=${encodeURIComponent(workspaceSlug)}` : "";
|
||||||
|
return this.get(`/api/nodedc/workspace-policy/${params}`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
|
|
|
||||||
|
|
@ -899,7 +899,9 @@ Plane должен оставаться самостоятельным прод
|
||||||
|
|
||||||
Практически проверено: корпоративные назначения из Launcher доходят до Operational Core, public-пользователь может создать workspace после выдачи доступа, а прямой доступ к сервису проходит через NODE.DC SSO. Safari-only падение workspace зафиксировано как отдельный deferred debug, потому что Chrome/Chromium flow работает и проблема не должна блокировать платформенную обвязку.
|
Практически проверено: корпоративные назначения из 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(
|
checker(
|
||||||
|
|
@ -909,8 +911,8 @@ Plane должен оставаться самостоятельным прод
|
||||||
{"text": "Сохранить автономность Task Manager как standalone-продукта.", "checked": True},
|
{"text": "Сохранить автономность Task Manager как standalone-продукта.", "checked": True},
|
||||||
{"text": "Зафиксировать managedBy=launcher для enterprise workspace.", "checked": True},
|
{"text": "Зафиксировать managedBy=launcher для enterprise workspace.", "checked": True},
|
||||||
{"text": "Зафиксировать managedBy=tasker для standalone/public workspace.", "checked": True},
|
{"text": "Зафиксировать managedBy=tasker для standalone/public workspace.", "checked": True},
|
||||||
"Скрыть или readonly-заблокировать Task Manager users/invites для managedBy=launcher.",
|
{"text": "Скрыть или readonly-заблокировать Task Manager users/invites для managedBy=launcher.", "checked": True},
|
||||||
"Оставить Task Manager users/invites включенными для managedBy=tasker.",
|
{"text": "Оставить Task Manager users/invites включенными для managedBy=tasker.", "checked": True},
|
||||||
"Очистить оставшиеся demo users/seed data без удаления живых связей.",
|
"Очистить оставшиеся demo users/seed data без удаления живых связей.",
|
||||||
"Оформить Safari-only workspace crash как отдельный deferred debug.",
|
"Оформить Safari-only workspace crash как отдельный deferred debug.",
|
||||||
],
|
],
|
||||||
|
|
@ -1139,7 +1141,7 @@ Launcher: добавлены admin routes для project memberships, control-pl
|
||||||
"tasker-provisioning",
|
"tasker-provisioning",
|
||||||
"Этап 5. Stale assignees cleanup после снятия пользователей",
|
"Этап 5. Stale assignees cleanup после снятия пользователей",
|
||||||
"""
|
"""
|
||||||
Статус: реализовано в рабочем дереве, ожидает финальную проверку и коммит после подтверждения.
|
Статус: реализовано и закоммичено.
|
||||||
|
|
||||||
После удаления, блокировки или снятия пользователя из workspace/project Tasker не должен продолжать показывать его исполнителем в карточках и группировках. Последняя локальная правка удаляет IssueAssignee на membership remove и фильтрует assignee arrays только по активным workspace/project membership.
|
После удаления, блокировки или снятия пользователя из 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 после последней очистки.
|
Проверка БД 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(
|
text_block(
|
||||||
"tasker-provisioning",
|
"tasker-provisioning",
|
||||||
"Этап 6. Source-of-truth split managedBy",
|
"Этап 6. Source-of-truth split managedBy",
|
||||||
"""
|
"""
|
||||||
Статус: следующий критический этап.
|
Статус: реализовано локально, проверено на policy path, готово к browser acceptance.
|
||||||
|
|
||||||
Нужно формально закрепить источник управления для workspace. managedBy=launcher означает enterprise workspace: пользователи, инвайты и базовые роли идут из Launcher, а Tasker не должен давать конфликтующее управление. managedBy=tasker означает standalone/public workspace: штатные Tasker механизмы пользователей, инвайтов и ролей остаются включенными.
|
Нужно формально закрепить источник управления для 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",
|
"tasker-provisioning6",
|
||||||
"Чекер этапа 6. Source-of-truth split managedBy",
|
"Чекер этапа 6. Source-of-truth split managedBy",
|
||||||
[
|
[
|
||||||
"Добавить managedBy в Launcher Tasker workspace binding.",
|
{"text": "Добавить managedBy в Launcher Tasker workspace binding.", "checked": True},
|
||||||
"Возвращать managedBy/workspacePolicy из Launcher internal access-check.",
|
{"text": "Возвращать managedBy/workspacePolicy из Launcher internal access-check.", "checked": True},
|
||||||
"Передавать managedBy в Tasker adapter responses или workspace policy resolver.",
|
{"text": "Передавать managedBy в Tasker adapter responses или workspace policy resolver.", "checked": True},
|
||||||
"Скрыть или readonly-заблокировать Tasker users/invites для managedBy=launcher.",
|
{"text": "Скрыть или readonly-заблокировать Tasker users/invites для managedBy=launcher.", "checked": True},
|
||||||
"Оставить Tasker users/invites включенными для managedBy=tasker.",
|
{"text": "Оставить Tasker users/invites включенными для managedBy=tasker.", "checked": True},
|
||||||
"Проверить enterprise client admin и public self-service user flows отдельно.",
|
"Проверить 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, новых ошибок в измененных файлах не выявлено.
|
||||||
|
""",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue