Compare commits

...

11 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 2224faa8f7 UI - NODEDC TASKMANAGER: сброс выделения kanban-карточки 2026-05-11 12:57:10 +03:00
DCCONSTRUCTIONS d7ef3ab050 fix(tasker): keep operational ordering and web build stable 2026-05-10 19:59:34 +03:00
DCCONSTRUCTIONS d55d182648 feat(tasker): localize NODE.DC workspace seed 2026-05-10 19:59:03 +03:00
DCCONSTRUCTIONS e49840341b feat(tasker): open project settings in modal 2026-05-10 19:58:30 +03:00
DCCONSTRUCTIONS bf75ce84eb UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стандартизация таблицы участников workspace 2026-05-10 11:39:45 +03:00
DCCONSTRUCTIONS 3dd99491a4 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: исправление слоя превью вложений 2026-05-10 09:57:40 +03:00
DCCONSTRUCTIONS 0be8f01283 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: self-service workspace invite approval 2026-05-09 22:57:07 +03:00
DCCONSTRUCTIONS 42bc0fb0e6 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: скрытие launcher-managed invite controls 2026-05-09 13:17:54 +03:00
DCCONSTRUCTIONS 11c8c6fb1b АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: managedBy split Operational Core 2026-05-09 12:49:09 +03:00
DCCONSTRUCTIONS ca9fd34e91 АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: актуализация roadmap NDC platform 2026-05-09 12:33:54 +03:00
DCCONSTRUCTIONS 78deab1a23 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: очистка stale assignees Operational Core 2026-05-09 12:33:49 +03:00
93 changed files with 2845 additions and 393 deletions

View File

@ -14,7 +14,7 @@ from drf_spectacular.utils import (
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
from plane.db.models import IssueAssignee, User, Workspace, WorkspaceMember, ProjectMember
from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission
from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER,
@ -205,6 +205,8 @@ class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True, context={"slug": slug})
serializer.is_valid(raise_exception=True)
serializer.save()
if serializer.instance.is_active is False:
IssueAssignee.objects.filter(project_id=project_id, assignee_id=serializer.instance.member_id).delete()
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(
@ -219,4 +221,5 @@ class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk)
project_member.is_active = False
project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -116,6 +116,15 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
invite_link = serializers.SerializerMethodField()
def get_invite_link(self, obj):
if obj.nodedc_approval_status in {
WorkspaceMemberInvite.NODEDC_APPROVAL_PENDING,
WorkspaceMemberInvite.NODEDC_APPROVAL_REJECTED,
}:
return None
if obj.nodedc_platform_invite_link:
return obj.nodedc_platform_invite_link
return f"/workspace-invitations/?invitation_id={obj.id}&slug={obj.workspace.slug}&token={obj.token}"
class Meta:

View File

@ -570,7 +570,15 @@ class IssueViewSet(BaseViewSet):
Subquery(
IssueAssignee.objects.filter(
issue_id=OuterRef("pk"),
deleted_at__isnull=True,
assignee__is_active=True,
assignee__member_workspace__workspace_id=OuterRef("workspace_id"),
assignee__member_workspace__is_active=True,
assignee__member_workspace__is_banned=False,
assignee__member_workspace__deleted_at__isnull=True,
assignee__member_project__project_id=OuterRef("project_id"),
assignee__member_project__is_active=True,
assignee__member_project__deleted_at__isnull=True,
)
.values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
@ -676,7 +684,14 @@ class IssueViewSet(BaseViewSet):
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
@ -980,7 +995,15 @@ class IssuePaginatedViewSet(BaseViewSet):
Subquery(
IssueAssignee.objects.filter(
issue_id=OuterRef("pk"),
deleted_at__isnull=True,
assignee__is_active=True,
assignee__member_workspace__workspace_id=OuterRef("workspace_id"),
assignee__member_workspace__is_active=True,
assignee__member_workspace__is_banned=False,
assignee__member_workspace__deleted_at__isnull=True,
assignee__member_project__project_id=OuterRef("project_id"),
assignee__member_project__is_active=True,
assignee__member_project__deleted_at__isnull=True,
)
.values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
@ -1315,7 +1338,14 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),

View File

@ -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,
@ -18,7 +22,7 @@ from plane.app.serializers import (
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Project, ProjectMember, ProjectUserProperty, WorkspaceMember
from plane.db.models import IssueAssignee, Project, ProjectMember, ProjectUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@ -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,
@ -295,10 +308,14 @@ class ProjectMemberViewSet(BaseViewSet):
project_member.is_active = False
project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@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,
@ -323,6 +340,7 @@ class ProjectMemberViewSet(BaseViewSet):
# Deactivate the user
project_member.is_active = False
project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -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):

View File

@ -20,6 +20,16 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import WorkSpaceAdminPermission
from plane.authentication.nodedc_workspace_policy import (
get_nodedc_workspace_creation_policy,
is_nodedc_launcher_managed_workspace,
is_nodedc_workspace_invite_approval_required,
nodedc_launcher_managed_workspace_response,
)
from plane.authentication.nodedc_workspace_invites import (
cancel_nodedc_workspace_invite_approval,
request_nodedc_workspace_invite_approval,
)
from plane.app.serializers import (
WorkSpaceMemberInviteSerializer,
WorkSpaceMemberSerializer,
@ -52,6 +62,11 @@ class WorkspaceInvitationsViewset(BaseViewSet):
)
def create(self, request, slug):
workspace_policy = get_nodedc_workspace_creation_policy(request.user, workspace_slug=slug)
if is_nodedc_launcher_managed_workspace(request.user, slug):
return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN)
requires_nodedc_approval = is_nodedc_workspace_invite_approval_required(workspace_policy)
emails = request.data.get("emails", [])
# Check if email is provided
if not emails:
@ -112,6 +127,11 @@ class WorkspaceInvitationsViewset(BaseViewSet):
),
role=email.get("role", 5),
created_by=request.user,
nodedc_approval_status=(
WorkspaceMemberInvite.NODEDC_APPROVAL_PENDING
if requires_nodedc_approval
else WorkspaceMemberInvite.NODEDC_APPROVAL_NOT_REQUIRED
),
)
)
except ValidationError:
@ -128,6 +148,33 @@ class WorkspaceInvitationsViewset(BaseViewSet):
current_site = base_host(request=request, is_app=True)
if requires_nodedc_approval:
approved_requests = []
try:
for invitation in workspace_invitations:
approval_response = request_nodedc_workspace_invite_approval(request, workspace, invitation)
approval_request = approval_response.get("taskerInviteRequest") if isinstance(approval_response, dict) else None
approval_request_id = approval_request.get("id") if isinstance(approval_request, dict) else None
if approval_request_id:
invitation.nodedc_approval_request_id = approval_request_id
invitation.save(update_fields=["nodedc_approval_request_id", "updated_at"])
approved_requests.append(approval_request_id)
except Exception:
WorkspaceMemberInvite.objects.filter(id__in=[invitation.id for invitation in workspace_invitations]).delete()
return Response(
{"error": "NODE.DC approval request failed"},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(
{
"message": "NODE.DC approval requested",
"approval": "nodedc",
"requests": approved_requests,
},
status=status.HTTP_200_OK,
)
# Send invitations
for invitation in workspace_invitations:
workspace_invitation.delay(
@ -154,7 +201,19 @@ 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)
if workspace_member_invite.nodedc_approval_status != WorkspaceMemberInvite.NODEDC_APPROVAL_NOT_REQUIRED:
try:
cancel_nodedc_workspace_invite_approval(request, workspace_member_invite)
except Exception:
return Response(
{"error": "NODE.DC invite cancellation failed"},
status=status.HTTP_502_BAD_GATEWAY,
)
workspace_member_invite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -177,6 +236,18 @@ class WorkspaceJoinEndpoint(BaseAPIView):
token = request.data.get("token", "")
if workspace_invite.nodedc_approval_status == WorkspaceMemberInvite.NODEDC_APPROVAL_PENDING:
return Response(
{"error": "NODE.DC has not approved this workspace invitation yet"},
status=status.HTTP_403_FORBIDDEN,
)
if workspace_invite.nodedc_approval_status == WorkspaceMemberInvite.NODEDC_APPROVAL_REJECTED:
return Response(
{"error": "NODE.DC rejected this workspace invitation"},
status=status.HTTP_403_FORBIDDEN,
)
# Validate the token to verify the user received the invitation email
if not token or workspace_invite.token != token:
return Response(

View File

@ -12,6 +12,13 @@ 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 (
get_nodedc_workspace_creation_policy,
is_nodedc_launcher_managed_workspace,
is_nodedc_workspace_invite_approval_required,
nodedc_launcher_managed_workspace_response,
)
from plane.authentication.nodedc_workspace_invites import cancel_nodedc_workspace_member_access
# Module imports
from plane.app.serializers import (
@ -21,7 +28,7 @@ from plane.app.serializers import (
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.db.models import IssueAssignee, Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.utils.cache import invalidate_cache
from .. import BaseViewSet
@ -75,6 +82,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 +107,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
@ -140,10 +153,24 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
workspace_policy = get_nodedc_workspace_creation_policy(request.user, workspace_slug=slug)
if is_nodedc_workspace_invite_approval_required(workspace_policy):
try:
cancel_nodedc_workspace_member_access(request, workspace_member)
except Exception as exc:
return Response(
{
"error": "nodedc_workspace_member_cancel_failed",
"detail": str(exc),
},
status=status.HTTP_502_BAD_GATEWAY,
)
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
IssueAssignee.objects.filter(workspace__slug=slug, assignee_id=workspace_member.member_id).delete()
workspace_member.is_active = False
workspace_member.save()
@ -159,6 +186,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
@ -198,6 +228,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
IssueAssignee.objects.filter(workspace__slug=slug, assignee_id=workspace_member.member_id).delete()
# # Deactivate the user
workspace_member.is_active = False

View File

@ -0,0 +1,140 @@
import os
import requests
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
ROLE_SLUGS = {
5: "guest",
15: "member",
20: "admin",
}
def get_nodedc_workspace_invite_request_url():
launcher_base_url = os.environ.get("PLANE_NODEDC_LAUNCHER_URL", "http://launcher.local.nodedc").rstrip("/")
return (
os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_REQUEST_URL", "").strip()
or f"{launcher_base_url}/api/internal/tasker/invite-requests"
)
def get_nodedc_workspace_invite_cancel_url():
launcher_base_url = os.environ.get("PLANE_NODEDC_LAUNCHER_URL", "http://launcher.local.nodedc").rstrip("/")
return (
os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_CANCEL_URL", "").strip()
or f"{launcher_base_url}/api/internal/tasker/invite-requests/cancel"
)
def request_nodedc_workspace_invite_approval(request, workspace, invitation):
request_url = get_nodedc_workspace_invite_request_url()
token = get_nodedc_internal_token()
if not request_url or not token:
raise RuntimeError("NODE.DC invite approval is not configured")
response = requests.post(
request_url,
json={
"taskerInviteId": str(invitation.id),
"workspace": {
"id": str(workspace.id),
"slug": workspace.slug,
"name": workspace.name,
},
"invitee": {
"email": invitation.email,
"role": ROLE_SLUGS.get(invitation.role, "member"),
},
"inviter": {
"planeUserId": str(request.user.id),
"subject": get_nodedc_subject(request.user),
"email": request.user.email,
"name": get_user_display_name(request.user),
},
},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
timeout=float(os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_REQUEST_TIMEOUT_SECONDS", "3") or "3"),
)
response.raise_for_status()
return response.json()
def cancel_nodedc_workspace_invite_approval(request, invitation):
request_url = get_nodedc_workspace_invite_cancel_url()
token = get_nodedc_internal_token()
if not request_url or not token:
raise RuntimeError("NODE.DC invite approval cancellation is not configured")
response = requests.post(
request_url,
json={
"taskerInviteId": str(invitation.id),
"requestId": invitation.nodedc_approval_request_id,
"workspaceSlug": invitation.workspace.slug,
"inviteeEmail": invitation.email,
"cancelledBy": {
"planeUserId": str(request.user.id),
"subject": get_nodedc_subject(request.user),
"email": request.user.email,
"name": get_user_display_name(request.user),
},
},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
timeout=float(os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_REQUEST_TIMEOUT_SECONDS", "3") or "3"),
)
response.raise_for_status()
return response.json()
def cancel_nodedc_workspace_member_access(request, workspace_member):
request_url = get_nodedc_workspace_invite_cancel_url()
token = get_nodedc_internal_token()
if not request_url or not token:
raise RuntimeError("NODE.DC workspace member cancellation is not configured")
response = requests.post(
request_url,
json={
"workspaceSlug": workspace_member.workspace.slug,
"inviteeEmail": workspace_member.member.email,
"comment": "Пользователь удалён из workspace Operational Core.",
"cancelledBy": {
"planeUserId": str(request.user.id),
"subject": get_nodedc_subject(request.user),
"email": request.user.email,
"name": get_user_display_name(request.user),
},
},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
timeout=float(os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_REQUEST_TIMEOUT_SECONDS", "3") or "3"),
)
response.raise_for_status()
return response.json()
def get_user_display_name(user):
display_name = getattr(user, "display_name", None)
if display_name:
return display_name
name = " ".join([value for value in [getattr(user, "first_name", ""), getattr(user, "last_name", "")] if value]).strip()
return name or user.email
def get_nodedc_subject(user):
link = user.external_identity_links.filter(provider="authentik", status="active").first()
return link.subject if link else None

View File

@ -0,0 +1,126 @@
from django.db import transaction
from django.utils import timezone
from plane.bgtasks.event_tracking_task import track_event
from plane.db.models import Profile, WorkspaceMember, WorkspaceMemberInvite
from plane.utils.analytics_events import USER_JOINED_WORKSPACE
from plane.utils.cache import invalidate_cache_directly
from plane.utils.workspace_bans import is_workspace_member_currently_banned, release_workspace_member_ban
class NodeDCWorkspaceInviteJoinError(Exception):
def __init__(self, code, status_code=400):
super().__init__(code)
self.code = code
self.status_code = status_code
def accept_nodedc_workspace_invite_request_for_user(request_id, user):
if not request_id:
raise NodeDCWorkspaceInviteJoinError("workspace_invite_request_missing", 400)
if not user or user.is_anonymous:
raise NodeDCWorkspaceInviteJoinError("workspace_invite_user_not_authenticated", 401)
with transaction.atomic():
invitation = (
WorkspaceMemberInvite.objects.select_for_update(of=("self",))
.select_related("workspace", "created_by")
.filter(nodedc_approval_request_id=request_id, deleted_at__isnull=True)
.first()
)
if invitation is None:
raise NodeDCWorkspaceInviteJoinError("workspace_invite_not_found", 404)
if invitation.nodedc_approval_status != WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED:
raise NodeDCWorkspaceInviteJoinError("workspace_invite_not_approved", 403)
if (invitation.email or "").strip().lower() != (user.email or "").strip().lower():
raise NodeDCWorkspaceInviteJoinError("workspace_invite_email_mismatch", 403)
workspace_member = (
WorkspaceMember.all_objects.select_for_update()
.filter(workspace=invitation.workspace, member=user)
.order_by("-created_at")
.first()
)
if is_workspace_member_currently_banned(workspace_member):
raise NodeDCWorkspaceInviteJoinError("workspace_invite_user_banned", 403)
if workspace_member is not None and workspace_member.is_banned:
release_workspace_member_ban(workspace_member)
created = workspace_member is None
if workspace_member is None:
workspace_member = WorkspaceMember.objects.create(
workspace=invitation.workspace,
member=user,
role=invitation.role,
created_by=invitation.created_by,
)
else:
workspace_member.role = invitation.role
workspace_member.is_active = True
workspace_member.is_banned = False
workspace_member.banned_at = None
workspace_member.banned_until = None
workspace_member.ban_project_member_ids = []
workspace_member.deleted_at = None
workspace_member.save(
update_fields=[
"role",
"is_active",
"is_banned",
"banned_at",
"banned_until",
"ban_project_member_ids",
"deleted_at",
"updated_at",
]
)
profile, _ = Profile.objects.get_or_create(user=user)
profile.last_workspace_id = invitation.workspace.id
profile.save(update_fields=["last_workspace_id", "updated_at"])
invitation.accepted = True
invitation.responded_at = timezone.now()
invitation.save(update_fields=["accepted", "responded_at", "updated_at"])
workspace_slug = invitation.workspace.slug
workspace_id = invitation.workspace.id
role = invitation.role
invitation.delete()
invalidate_cache_directly(
path=f"/api/workspaces/{workspace_slug}/members/",
url_params=False,
user=False,
multiple=True,
)
invalidate_cache_directly(path="/api/workspaces/", user=False, multiple=True)
invalidate_cache_directly(path="/api/users/me/workspaces/", user=False, multiple=True)
track_event.delay(
user_id=user.id,
event_name=USER_JOINED_WORKSPACE,
slug=workspace_slug,
event_properties={
"user_id": user.id,
"workspace_id": workspace_id,
"workspace_slug": workspace_slug,
"role": role,
"joined_at": str(timezone.now()),
"source": "nodedc_platform_invite",
},
)
return {
"created": created,
"workspaceSlug": workspace_slug,
"workspaceId": str(workspace_id),
"role": role,
}

View File

@ -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,11 @@ def get_nodedc_workspace_creation_policy(user):
"enabled": False,
"can_create_workspace": True,
"mode": "standalone",
"managed_by": "tasker",
"default_managed_by": "tasker",
"invite_approval": "tasker",
"default_invite_approval": "tasker",
"workspaces": [],
"reason": "NODE.DC workspace policy is not configured.",
}
@ -36,6 +41,11 @@ def get_nodedc_workspace_creation_policy(user):
"enabled": True,
"can_create_workspace": not enforce_unlinked,
"mode": "unlinked",
"managed_by": "tasker",
"default_managed_by": "tasker",
"invite_approval": "tasker",
"default_invite_approval": "tasker",
"workspaces": [],
"reason": "NODE.DC identity is not linked." if enforce_unlinked else "Standalone user without NODE.DC identity.",
}
@ -61,6 +71,11 @@ def get_nodedc_workspace_creation_policy(user):
"enabled": True,
"can_create_workspace": False,
"mode": "unavailable",
"managed_by": "tasker",
"default_managed_by": "tasker",
"invite_approval": "disabled",
"default_invite_approval": "tasker",
"workspaces": [],
"reason": "NODE.DC workspace policy is unavailable.",
}
@ -71,18 +86,103 @@ 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",
"invite_approval": "tasker",
"default_invite_approval": "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"),
)
invite_approval = normalize_invite_approval(workspace_policy.get("inviteApproval") or workspace_policy.get("invite_approval"))
default_invite_approval = normalize_invite_approval(
workspace_policy.get("defaultInviteApproval")
or workspace_policy.get("default_invite_approval")
or workspace_policy.get("inviteApproval")
or workspace_policy.get("invite_approval")
)
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")),
"invite_approval": invite_approval,
"default_invite_approval": default_invite_approval,
"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_invite_approval(value):
return value if value in {"tasker", "nodedc", "launcher", "disabled"} 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.",
}
def is_nodedc_workspace_invite_approval_required(policy):
return bool(policy.get("enabled")) and policy.get("managed_by") == "tasker" and policy.get("invite_approval") == "nodedc"

View File

@ -17,6 +17,10 @@ from django.views import View
from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.nodedc_workspace_join import (
NodeDCWorkspaceInviteJoinError,
accept_nodedc_workspace_invite_request_for_user,
)
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
from plane.db.models import ExternalIdentityLink, Profile, User
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
@ -24,6 +28,7 @@ from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
OIDC_SESSION_KEY = "nodedc_oidc"
OIDC_PROVIDER = "authentik"
NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX = "/auth/nodedc/workspace-invite/accept/"
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
logger = logging.getLogger(__name__)
@ -161,10 +166,45 @@ class NodeDCHandoffEndpoint(View):
user_login(request=request, user=user, is_app=True)
workspace_invite_redirect = resolve_nodedc_workspace_invite_accept_redirect(base_url, next_path, user)
if workspace_invite_redirect is not None:
return workspace_invite_redirect
path = next_path or get_redirection_path(user=user)
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
def resolve_nodedc_workspace_invite_accept_redirect(base_url, next_path, user):
if not next_path.startswith(NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX):
return None
request_id = next_path.removeprefix(NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX).strip("/")
try:
result = accept_nodedc_workspace_invite_request_for_user(request_id, user)
except NodeDCWorkspaceInviteJoinError as error:
logger.warning(
"NODEDC managed workspace invite accept failed: code=%s user_id=%s request_id=%s",
error.code,
user.id,
request_id,
)
return oidc_error_redirect(base_url, "", error.code)
except Exception:
logger.exception(
"NODEDC managed workspace invite accept crashed: user_id=%s request_id=%s",
user.id,
request_id,
)
return oidc_error_redirect(base_url, "", "workspace_invite_accept_failed")
workspace_slug = result.get("workspaceSlug")
if not workspace_slug:
return oidc_error_redirect(base_url, "", "workspace_invite_redirect_missing")
return HttpResponseRedirect(f"{base_url.rstrip('/')}/{workspace_slug}/")
def get_oidc_config():
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()

View File

@ -4,12 +4,24 @@ from urllib.parse import urlparse
from django.db import transaction
from django.http import JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.db.models import ExternalIdentityLink, Profile, Project, ProjectMember, User, Workspace, WorkspaceMember
from plane.utils.host import base_host
from plane.db.models import (
ExternalIdentityLink,
IssueAssignee,
Profile,
Project,
ProjectMember,
User,
Workspace,
WorkspaceMember,
WorkspaceMemberInvite,
)
OIDC_PROVIDER = "authentik"
@ -217,6 +229,44 @@ def serialize_membership(membership, created):
}
def resolve_workspace_invite(payload):
invite_id = payload.get("taskerInviteId") or payload.get("tasker_invite_id") or payload.get("inviteId")
request_id = payload.get("requestId") or payload.get("request_id")
queryset = WorkspaceMemberInvite.objects.filter(deleted_at__isnull=True).select_related("workspace")
if request_id:
queryset = queryset.filter(nodedc_approval_request_id=request_id)
if invite_id:
return queryset.filter(id=invite_id).first()
return None
def build_workspace_invite_link(request, invitation):
invite_path = f"/workspace-invitations/?invitation_id={invitation.id}&slug={invitation.workspace.slug}&token={invitation.token}"
return f"{base_host(request=request, is_app=True).rstrip('/')}{invite_path}"
def serialize_workspace_invite(request, invitation):
tasker_invite_link = build_workspace_invite_link(request, invitation)
return {
"id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"workspace": serialize_workspace(invitation.workspace),
"platformInviteLink": invitation.nodedc_platform_invite_link,
"taskerInviteLink": (
tasker_invite_link
if invitation.nodedc_approval_status == WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
else None
),
"inviteLink": (
invitation.nodedc_platform_invite_link or tasker_invite_link
if invitation.nodedc_approval_status == WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
else None
),
}
def serialize_project_membership(project_member, created):
return {
"created": created,
@ -384,6 +434,10 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
member=user,
is_active=True,
).update(is_active=False)
IssueAssignee.objects.filter(
workspace=workspace,
assignee=user,
).delete()
membership.is_active = False
membership.save(update_fields=["is_active", "updated_at"])
@ -401,6 +455,68 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceInviteApproveEndpoint(View):
def post(self, request):
if not is_internal_logout_request_authorized(request):
return internal_unauthorized_response()
payload = parse_json_body(request)
if payload is None:
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
invitation = resolve_workspace_invite(payload)
if invitation is None:
return JsonResponse({"ok": False, "error": "workspace_invite_not_found"}, status=404)
invitation.nodedc_approval_status = WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
invitation.nodedc_approval_request_id = payload.get("requestId") or invitation.nodedc_approval_request_id
invitation.nodedc_approval_decided_at = timezone.now()
invitation.nodedc_platform_invite_link = payload.get("platformInviteLink") or invitation.nodedc_platform_invite_link
invitation.save(
update_fields=[
"nodedc_approval_status",
"nodedc_approval_request_id",
"nodedc_approval_decided_at",
"nodedc_platform_invite_link",
"updated_at",
]
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceInviteRejectEndpoint(View):
def post(self, request):
if not is_internal_logout_request_authorized(request):
return internal_unauthorized_response()
payload = parse_json_body(request)
if payload is None:
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
invitation = resolve_workspace_invite(payload)
if invitation is None:
return JsonResponse({"ok": False, "error": "workspace_invite_not_found"}, status=404)
invitation.nodedc_approval_status = WorkspaceMemberInvite.NODEDC_APPROVAL_REJECTED
invitation.nodedc_approval_request_id = payload.get("requestId") or invitation.nodedc_approval_request_id
invitation.nodedc_approval_decided_at = timezone.now()
invitation.message = payload.get("comment") or invitation.message
invitation.save(
update_fields=[
"nodedc_approval_status",
"nodedc_approval_request_id",
"nodedc_approval_decided_at",
"message",
"updated_at",
]
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalProjectMembershipEnsureEndpoint(View):
def post(self, request):
@ -534,6 +650,7 @@ class NodeDCInternalProjectMembershipRemoveEndpoint(View):
project_member.is_active = False
project_member.save(update_fields=["is_active", "updated_at"])
IssueAssignee.objects.filter(project=project, assignee=user).delete()
return JsonResponse(
{

View File

@ -521,12 +521,12 @@ def workspace_seed(workspace_id: uuid.UUID) -> None:
# Create a bot user for creating all the workspace data
bot_user = User.objects.create(
username=f"bot_user_{workspace.id}",
display_name="Plane",
first_name="Plane",
display_name="NODE.DC",
first_name="NODE.DC",
last_name="",
is_bot=True,
bot_type=BotTypeEnum.WORKSPACE_SEED,
email=f"bot_user_{workspace.id}@plane.so",
email=f"bot_user_{workspace.id}@nodedc.local",
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)

View File

@ -0,0 +1,36 @@
# Generated by NODE.DC platform integration.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0138_external_identity_link_unique_user"),
]
operations = [
migrations.AddField(
model_name="workspacememberinvite",
name="nodedc_approval_status",
field=models.CharField(
choices=[
("not_required", "Not required"),
("pending", "Pending NODE.DC approval"),
("approved", "Approved by NODE.DC"),
("rejected", "Rejected by NODE.DC"),
],
default="not_required",
max_length=32,
),
),
migrations.AddField(
model_name="workspacememberinvite",
name="nodedc_approval_request_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="workspacememberinvite",
name="nodedc_approval_decided_at",
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0139_workspacememberinvite_nodedc_approval"),
]
operations = [
migrations.AddField(
model_name="workspacememberinvite",
name="nodedc_platform_invite_link",
field=models.URLField(blank=True, max_length=1000, null=True),
),
]

View File

@ -238,6 +238,17 @@ class WorkspaceMember(BaseModel):
class WorkspaceMemberInvite(BaseModel):
NODEDC_APPROVAL_NOT_REQUIRED = "not_required"
NODEDC_APPROVAL_PENDING = "pending"
NODEDC_APPROVAL_APPROVED = "approved"
NODEDC_APPROVAL_REJECTED = "rejected"
NODEDC_APPROVAL_CHOICES = (
(NODEDC_APPROVAL_NOT_REQUIRED, "Not required"),
(NODEDC_APPROVAL_PENDING, "Pending NODE.DC approval"),
(NODEDC_APPROVAL_APPROVED, "Approved by NODE.DC"),
(NODEDC_APPROVAL_REJECTED, "Rejected by NODE.DC"),
)
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite")
email = models.CharField(max_length=255)
accepted = models.BooleanField(default=False)
@ -245,6 +256,14 @@ class WorkspaceMemberInvite(BaseModel):
message = models.TextField(null=True)
responded_at = models.DateTimeField(null=True)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
nodedc_approval_status = models.CharField(
max_length=32,
choices=NODEDC_APPROVAL_CHOICES,
default=NODEDC_APPROVAL_NOT_REQUIRED,
)
nodedc_approval_request_id = models.CharField(max_length=255, null=True, blank=True)
nodedc_approval_decided_at = models.DateTimeField(null=True, blank=True)
nodedc_platform_invite_link = models.URLField(max_length=1000, null=True, blank=True)
class Meta:
unique_together = ["email", "workspace", "deleted_at"]

View File

@ -14,6 +14,7 @@ from django.utils.dateparse import parse_datetime
from plane.app.views.base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.db.models import (
IssueAssignee,
Project,
ProjectMember,
Workspace,
@ -278,6 +279,7 @@ class InstanceWorkSpaceMemberEndpoint(BaseAPIView):
member_id=workspace_member.member_id,
is_active=True,
).update(is_active=False, updated_at=timezone.now())
IssueAssignee.objects.filter(workspace_id=workspace_id, assignee_id=workspace_member.member_id).delete()
workspace_member.is_active = False
workspace_member.save()
@ -332,6 +334,7 @@ class InstanceWorkSpaceMemberBanEndpoint(BaseAPIView):
member_id=workspace_member.member_id,
is_active=True,
).update(is_active=False, updated_at=timezone.now())
IssueAssignee.objects.filter(workspace_id=workspace_id, assignee_id=workspace_member.member_id).delete()
workspace_member.is_banned = True
workspace_member.banned_at = timezone.now()

View File

@ -1,18 +1,18 @@
[
{
"id": 1,
"name": "Cycle 1: Getting Started with Plane",
"project_id": 1,
"sort_order": 1,
"timezone": "UTC",
"type": "CURRENT"
},
{
"id": 2,
"name": "Cycle 2: Collaboration & Customization",
"project_id": 1,
"sort_order": 2,
"timezone": "UTC",
"type": "UPCOMING"
}
]
{
"id": 1,
"name": "Цикл 1: старт в NODE.DC",
"project_id": 1,
"sort_order": 1,
"timezone": "UTC",
"type": "CURRENT"
},
{
"id": 2,
"name": "Цикл 2: совместная работа",
"project_id": 1,
"sort_order": 2,
"timezone": "UTC",
"type": "UPCOMING"
}
]

View File

@ -1,99 +1,124 @@
[
{
"id": 1,
"name": "Welcome to Plane 👋",
"name": "Добро пожаловать в NODE.DC Operational Core",
"sequence_id": 1,
"description_html": "<p class=\"editor-paragraph-block\">Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.</p><p class=\"editor-paragraph-block\">Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.</p><p class=\"editor-paragraph-block\">First thing to try</p><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look in the <strong>Properties</strong> section below where it says <strong>State: Todo</strong>.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click on it and change it to <strong>Done</strong> from the dropdown. Alternatively, you can drag and drop the card to the Done column.</p></li></ol>",
"description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.",
"description_html": "<p class=\"editor-paragraph-block\">Это стартовый проект внутри NODE.DC Operational Core. Он создан автоматически, чтобы показать базовые сценарии: проекты, рабочие элементы, участников, представления, циклы и настройки.</p><p class=\"editor-paragraph-block\">Карточки ниже можно открывать, двигать между статусами и редактировать. Это учебный набор — его можно удалить, когда workspace будет настроен под реальную работу.</p><p class=\"editor-paragraph-block\"><strong>Первое действие:</strong></p><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Откройте свойства карточки и найдите поле <strong>Статус</strong>.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Переведите карточку в <strong>Готово</strong> через выпадающий список или перетащите её в соответствующую колонку.</p></li></ol>",
"description_stripped": "Это стартовый проект внутри NODE.DC Operational Core. Он создан автоматически, чтобы показать базовые сценарии: проекты, рабочие элементы, участников, представления, циклы и настройки. Карточки ниже можно открывать, двигать между статусами и редактировать. Это учебный набор — его можно удалить, когда workspace будет настроен под реальную работу. Первое действие: Откройте свойства карточки и найдите поле Статус. Переведите карточку в Готово через выпадающий список или перетащите её в соответствующую колонку.",
"sort_order": 1000,
"state_id": 4,
"labels": [],
"priority": "urgent",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1]
"module_ids": [
1
]
},
{
"id": 2,
"name": "1. Create Projects 🎯",
"name": "1. Создайте проект",
"sequence_id": 2,
"description_html": "<p class=\"editor-paragraph-block\"><br>A Project in Plane is where all your work comes together. Think of it as a base that organizes your work items and everything else your team needs to get things done.</p><div data-emoji-unicode=\"128204\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4cc.png\" data-logo-in-use=\"emoji\" data-background=\"light-blue\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Note: </strong>This tutorial is already set up as a Project, and these cards you're reading are work items within it!</p><p class=\"editor-paragraph-block\">We're showing you how to create a new project just so you'll know exactly what to do when you're ready to start your own real one.</p></div><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look over at the left sidebar and find where it says <strong>Projects.</strong></p><image-component src=\"https://media.docs.plane.so/seed_assets/21.png\" width=\"395px\" height=\"362px\" id=\"7cb0d276-8686-4c8e-9f00-06a18140964d\" aspectratio=\"1.0900243309002433\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Hover your mouse there and you'll see a little <strong>+</strong> icon pop up - go ahead and click it!</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">A modal opens where you can give your project a name and other details.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Notice the Access type<strong> </strong>options? <strong>Public</strong> means anyone (except Guest users) can see and join it, while <strong>Private</strong> keeps it just for those you invite.</p><div data-icon-color=\"#6d7b8a\" data-icon-name=\"Info\" data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> You can also quickly create a new project by using the keyboard shortcut <strong>P</strong> from anywhere in Plane!</p></div></li></ol>",
"description_html": "<p class=\"editor-paragraph-block\">Проект собирает рабочие элементы, участников, настройки и представления в одном контуре. В NODE.DC проект может быть отдельным направлением, процессом или командной зоной.</p><div data-logo-in-use=\"none\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Важно:</strong> этот учебный набор уже создан как проект. Ниже показано, где создать следующий реальный проект, когда он понадобится.</p></div><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Откройте раздел <strong>Проекты</strong> в верхней навигации.</p><image-component src=\"/seed-assets/nodedc/project-sidebar.png\" width=\"920px\" height=\"520px\" id=\"nodedc-seed-project-sidebar\" aspectratio=\"1.7692307692307692\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Нажмите кнопку добавления проекта и задайте название, идентификатор и тип доступа.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Публичный проект доступен участникам workspace, приватный — только приглашённым участникам проекта.</p><div data-logo-in-use=\"none\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Совет:</strong> для быстрого создания проекта используйте клавишу <strong>P</strong>, если фокус не находится в поле ввода.</p></div></li></ol>",
"description_stripped": "Проект собирает рабочие элементы, участников, настройки и представления в одном контуре. В NODE.DC проект может быть отдельным направлением, процессом или командной зоной. Важно: этот учебный набор уже создан как проект. Ниже показано, где создать следующий реальный проект, когда он понадобится. Откройте раздел Проекты в верхней навигации. Нажмите кнопку добавления проекта и задайте название, идентификатор и тип доступа. Публичный проект доступен участникам workspace, приватный — только приглашённым участникам проекта. Совет: для быстрого создания проекта используйте клавишу P, если фокус не находится в поле ввода.",
"sort_order": 2000,
"state_id": 2,
"labels": [2],
"labels": [
2
],
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1]
"module_ids": [
1
]
},
{
"id": 3,
"name": "2. Invite your team 🤜🤛",
"name": "2. Пригласите участников",
"sequence_id": 3,
"description_html": "<p class=\"editor-paragraph-block\">Let's get your teammates on board!</p><p class=\"editor-paragraph-block\">First, you'll need to invite them to your workspace before they can join specific projects:</p><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click on your workspace name in the top-left corner, then select <strong>Settings</strong> from the dropdown.<br></p><image-component src=\"https://media.docs.plane.so/seed_assets/31.png\" width=\"395px\" height=\"367px\" id=\"26b0f613-b9d8-48b8-a10d-1a75501f19e0\" aspectratio=\"1.074766355140187\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Head over to the <strong>Members</strong> tab - this is your user management hub. Click <strong>Add member</strong> on the top right.<br></p><image-component src=\"https://media.docs.plane.so/seed_assets/32.png\" width=\"1144.380859375px\" height=\"206.3244316692872px\" id=\"7c64e9b0-4f6d-4958-917d-f77119cd48bd\" aspectratio=\"5.546511627906977\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">To do this, go to your project's <strong>Settings</strong> page.</p><image-component src=\"https://media.docs.plane.so/seed_assets/33.png\" width=\"1119.380859375px\" height=\"329.9601265352615px\" id=\"3029c693-19fc-458e-9f5c-fdf3511dd2b6\" aspectratio=\"3.39247311827957\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Find the <strong>Members</strong> section, select your teammate, and assign them a project role - this controls what they can do within just this project.</p></li></ol><p class=\"editor-paragraph-block\"><br>That's it!</p><div class=\"py-4 border-strong-1\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">To learn more about user management, see <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/workspaces/members\">Manage users and roles</a>.</p>",
"description_stripped": "Let's get your teammates on board!First, you'll need to invite them to your workspace before they can join specific projects:Click on your workspace name in the top-left corner, then select Settings from the dropdown.Head over to the Members tab - this is your user management hub. Click Add member on the top right.Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.To do this, go to your project's Settings page.Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.That's it!To learn more about user management, see Manage users and roles.",
"description_html": "<p class=\"editor-paragraph-block\">Участники сначала добавляются в workspace, а затем получают роли в конкретных проектах. Это разделяет общий доступ к среде и права внутри отдельных процессов.</p><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Откройте меню workspace и перейдите в <strong>Настройки</strong>.</p><image-component src=\"/seed-assets/nodedc/workspace-menu.png\" width=\"920px\" height=\"520px\" id=\"nodedc-seed-workspace-menu\" aspectratio=\"1.7692307692307692\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">В разделе <strong>Участники</strong> нажмите <strong>Добавить участника</strong>.</p><image-component src=\"/seed-assets/nodedc/workspace-members.png\" width=\"920px\" height=\"520px\" id=\"nodedc-seed-workspace-members\" aspectratio=\"1.7692307692307692\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Укажите email и выберите роль: <strong>Админ</strong>, <strong>Участник</strong> или <strong>Гость</strong>.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">После подтверждения участника можно добавить в нужный проект через настройки проекта.</p><image-component src=\"/seed-assets/nodedc/project-settings.png\" width=\"920px\" height=\"520px\" id=\"nodedc-seed-project-settings\" aspectratio=\"1.7692307692307692\"></image-component></li></ol><div class=\"py-4 border-strong-1\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">В enterprise-контуре доступы могут управляться из Launcher. В этом случае локальные приглашения могут быть ограничены политикой NODE.DC.</p>",
"description_stripped": "Участники сначала добавляются в workspace, а затем получают роли в конкретных проектах. Это разделяет общий доступ к среде и права внутри отдельных процессов. Откройте меню workspace и перейдите в Настройки. В разделе Участники нажмите Добавить участника. Укажите email и выберите роль: Админ, Участник или Гость. После подтверждения участника можно добавить в нужный проект через настройки проекта. В enterprise-контуре доступы могут управляться из Launcher. В этом случае локальные приглашения могут быть ограничены политикой NODE.DC.",
"sort_order": 3000,
"state_id": 1,
"labels": [],
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1, 2]
"module_ids": [
1,
2
]
},
{
"id": 4,
"name": "3. Create and assign Work Items ✏️",
"name": "3. Создавайте рабочие элементы",
"sequence_id": 4,
"description_html": "<p class=\"editor-paragraph-block\">A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.</p><p class=\"editor-paragraph-block\">Ready to add something to your project's to-do list? Here's how:</p><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Add work item</strong> button in the top-right corner of the Work Items page.</p><image-component src=\"https://media.docs.plane.so/seed_assets/41.png\" width=\"1085.380859375px\" height=\"482.53758375605696px\" id=\"ba055bc3-4162-4750-9ad4-9434fc0e7121\" aspectratio=\"2.249318801089918\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Give your task a clear title and add any details in the description.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Set up the essentials:</p><ul class=\"list-disc pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Assign it to a team member (or yourself!)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Choose a priority level</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Add start and due dates if there's a timeline</p></li></ul></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Save time by using the keyboard shortcut <strong>C</strong> from anywhere in your project to quickly create a new work item!</p></div><div class=\"py-4 border-strong-1\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">Want to dive deeper into all the things you can do with work items? Check out our <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/overview\">documentation</a>.</p>",
"description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.",
"description_html": "<p class=\"editor-paragraph-block\">Рабочий элемент — базовая единица работы в проекте. Это может быть задача, запрос, поручение, дефект или любой другой управляемый объект.</p><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Нажмите кнопку <strong>Добавить рабочий элемент</strong> в проекте.</p><image-component src=\"/seed-assets/nodedc/add-work-item.png\" width=\"920px\" height=\"520px\" id=\"nodedc-seed-add-work-item\" aspectratio=\"1.7692307692307692\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Задайте понятное название и описание.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Заполните базовые свойства: исполнитель, статус, приоритет, дата начала и срок.</p></li></ol><div data-logo-in-use=\"none\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Совет:</strong> используйте клавишу <strong>C</strong>, чтобы быстро создать рабочий элемент из проекта.</p></div>",
"description_stripped": "Рабочий элемент — базовая единица работы в проекте. Это может быть задача, запрос, поручение, дефект или любой другой управляемый объект. Нажмите кнопку Добавить рабочий элемент в проекте. Задайте понятное название и описание. Заполните базовые свойства: исполнитель, статус, приоритет, дата начала и срок. Совет: используйте клавишу C, чтобы быстро создать рабочий элемент из проекта.",
"sort_order": 4000,
"state_id": 3,
"labels": [2],
"labels": [
2
],
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1, 2]
"module_ids": [
1,
2
]
},
{
"id": 5,
"name": "4. Visualize your work 🔮",
"name": "4. Переключайте представления",
"sequence_id": 5,
"description_html": "<p class=\"editor-paragraph-block\">Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!</p><image-component src=\"https://media.docs.plane.so/seed_assets/51.png\" aspectratio=\"4.489130434782608\"></image-component><h2 class=\"editor-heading-block\">Switch between layouts</h2><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look at the top toolbar in your project. You'll see several layout icons.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click any of these icons to instantly switch between layouts.</p></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/layouts\"><strong>Layouts</strong></a> for more info.</p></div><h2 class=\"editor-heading-block\">Filter and display options</h2><p class=\"editor-paragraph-block\">Need to focus on specific work?</p><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Filters</strong> dropdown in the toolbar. Select criteria and choose which items to show.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Display</strong> dropdown to tailor how the information appears in your layout</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Created the perfect setup? Save it for later by clicking the the <strong>Save View</strong> button.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Access saved views anytime from the <strong>Views</strong> section in your sidebar.</p></li></ol>",
"description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.",
"description_html": "<p class=\"editor-paragraph-block\">NODE.DC Operational Core позволяет смотреть на одну и ту же работу разными способами: списком, доской, календарём, таблицей или таймлайном.</p><image-component src=\"/seed-assets/nodedc/layouts.png\" width=\"920px\" height=\"520px\" id=\"nodedc-seed-layouts\" aspectratio=\"1.7692307692307692\"></image-component><h2 class=\"editor-heading-block\">Смена layout</h2><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Используйте нижнюю панель режимов, чтобы переключаться между видами.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Откройте фильтры, если нужно сфокусироваться на конкретных статусах, исполнителях или сроках.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Сохраните удачную конфигурацию как отдельное представление.</p></li></ol><div data-logo-in-use=\"none\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Совет:</strong> доска удобна для статусов, календарь — для сроков, список — для плотной операционной работы.</p></div>",
"description_stripped": "NODE.DC Operational Core позволяет смотреть на одну и ту же работу разными способами: списком, доской, календарём, таблицей или таймлайном. Смена layout. Используйте нижнюю панель режимов, чтобы переключаться между видами. Откройте фильтры, если нужно сфокусироваться на конкретных статусах, исполнителях или сроках. Сохраните удачную конфигурацию как отдельное представление. Совет: доска удобна для статусов, календарь — для сроков, список — для плотной операционной работы.",
"sort_order": 5000,
"state_id": 3,
"labels": [],
"priority": "none",
"project_id": 1,
"cycle_id": 2,
"module_ids": [2]
"module_ids": [
2
]
},
{
"id": 6,
"name": "5. Use Cycles to time box tasks 🗓️",
"name": "5. Используйте циклы",
"sequence_id": 6,
"description_html": "<p class=\"editor-paragraph-block\">A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.</p><h2 class=\"editor-heading-block\"><strong>Setup Cycles</strong></h2><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Go to the <strong>Cycles</strong> section in your project (you can find it in the left sidebar)</p><image-component src=\"https://media.docs.plane.so/seed_assets/61.png\" width=\"1144.380859375px\" height=\"341.8747850334119px\" id=\"9c3aea94-703a-4d4c-8c39-4201e994711d\" aspectratio=\"3.3473684210526318\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Add cycle </strong>button in the top-right corner</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Enter details and set the start and end dates for your cycle.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click <strong>Create cycle</strong> and you're ready to go!</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Add existing work items to the Cycle or create new ones.</p></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> To create a new Cycle quickly, just press <code class=\"rounded bg-layer-1 px-[6px] py-[1.5px] font-mono font-medium text-orange-500 border-[0.5px] border-subtle-1\" spellcheck=\"false\">Q</code> from anywhere in your project!</p></div><div class=\"py-4 border-strong-1\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">Want to learn more?</p><ul class=\"list-disc pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Starting and stopping cycles</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Transferring work items between cycles</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Tracking progress with charts</p></li></ul><p class=\"editor-paragraph-block\">Check out our <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/cycles\">detailed documentation</a> for everything you need to know!</p>",
"description_stripped": "A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.Setup CyclesGo to the Cycles section in your project (you can find it in the left sidebar)Click the Add cycle button in the top-right cornerEnter details and set the start and end dates for your cycle.Click Create cycle and you're ready to go!Add existing work items to the Cycle or create new ones.Tip: To create a new Cycle quickly, just press Q from anywhere in your project!Want to learn more?Starting and stopping cyclesTransferring work items between cyclesTracking progress with chartsCheck out our detailed documentation for everything you need to know!",
"description_html": "<p class=\"editor-paragraph-block\">Цикл — это ограниченный период работы. Он помогает собрать задачи в понятный временной контур и видеть, что команда должна завершить в ближайшем интервале.</p><h2 class=\"editor-heading-block\">Настройка цикла</h2><ol class=\"list-decimal pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Откройте раздел <strong>Циклы</strong> в проекте.</p><image-component src=\"/seed-assets/nodedc/cycles.png\" width=\"920px\" height=\"520px\" id=\"nodedc-seed-cycles\" aspectratio=\"1.7692307692307692\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Создайте цикл, задайте даты начала и завершения.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Добавьте существующие рабочие элементы или создайте новые прямо внутри цикла.</p></li></ol><div data-logo-in-use=\"none\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Совет:</strong> быстрый вызов создания цикла доступен через клавишу <code class=\"rounded bg-layer-1 px-[6px] py-[1.5px] font-mono font-medium text-orange-500 border-[0.5px] border-subtle-1\" spellcheck=\"false\">Q</code>.</p></div>",
"description_stripped": "Цикл — это ограниченный период работы. Он помогает собрать задачи в понятный временной контур и видеть, что команда должна завершить в ближайшем интервале. Настройка цикла. Откройте раздел Циклы в проекте. Создайте цикл, задайте даты начала и завершения. Добавьте существующие рабочие элементы или создайте новые прямо внутри цикла. Совет: быстрый вызов создания цикла доступен через клавишу Q.",
"sort_order": 6000,
"state_id": 1,
"labels": [2],
"labels": [
2
],
"priority": "low",
"project_id": 1,
"cycle_id": 2,
"module_ids": [2, 3]
"module_ids": [
2,
3
]
},
{
"id": 7,
"name": "6. Customize your settings ⚙️",
"name": "6. Настройте workspace",
"sequence_id": 7,
"description_html": "<p class=\"editor-paragraph-block\">Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!</p><h2 class=\"editor-heading-block\">Workspace settings</h2><p class=\"editor-paragraph-block\">Remember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:</p><ul class=\"list-disc pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Invite and manage workspace members</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Upgrade plans and manage billing</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Import data from other tools</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Export your data</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Manage integrations</p></li></ul><h2 class=\"editor-heading-block\">Project Settings</h2><p class=\"editor-paragraph-block\">Each project has its own settings where you can:</p><ul class=\"list-disc pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Change project details and visibility</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Invite specific members to just this project</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Customize your workflow States (like adding a \"Testing\" state)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Create and organize Labels</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Enable or disable features you need (or don't need)</p></li></ul><h2 class=\"editor-heading-block\">Your Profile Settings</h2><p class=\"editor-paragraph-block\">You can also customize your own personal experience! Click on your profile icon in the top-right corner to find:</p><ul class=\"list-disc pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Profile settings (update your name, photo, etc.)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Choose your timezone and preferred language for the interface</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Email notification preferences (what you want to be alerted about)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Appearance settings (light/dark mode)<br></p></li></ul><p class=\"editor-paragraph-block\">Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!</p><div data-emoji-unicode=\"128278\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f516.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Note:</strong> Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.</p></div><p class=\"editor-paragraph-block\"></p><div class=\"py-4 border-strong-1\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\"></p>",
"description_stripped": "Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!Workspace settingsRemember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:Invite and manage workspace membersUpgrade plans and manage billingImport data from other toolsExport your dataManage integrationsProject SettingsEach project has its own settings where you can:Change project details and visibilityInvite specific members to just this projectCustomize your workflow States (like adding a \"Testing\" state)Create and organize LabelsEnable or disable features you need (or don't need)Your Profile SettingsYou can also customize your own personal experience! Click on your profile icon in the top-right corner to find:Profile settings (update your name, photo, etc.)Choose your timezone and preferred language for the interfaceEmail notification preferences (what you want to be alerted about)Appearance settings (light/dark mode)Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.",
"description_html": "<p class=\"editor-paragraph-block\">После знакомства с базовой доской настройте workspace под рабочий процесс команды.</p><h2 class=\"editor-heading-block\">Что проверить</h2><ul class=\"list-disc pl-7 space-y-(--list-spacing-y) tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">участники workspace и их роли;</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">участники конкретных проектов;</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">статусы workflow, метки и представления;</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">уведомления, профиль и внешний вид интерфейса.</p></li></ul><div data-logo-in-use=\"none\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Важно:</strong> если workspace подключён к enterprise-контуру NODE.DC Launcher, часть локальных настроек может быть заблокирована политикой платформы.</p></div>",
"description_stripped": "После знакомства с базовой доской настройте workspace под рабочий процесс команды. Что проверить: участники workspace и их роли; участники конкретных проектов; статусы workflow, метки и представления; уведомления, профиль и внешний вид интерфейса. Важно: если workspace подключён к enterprise-контуру NODE.DC Launcher, часть локальных настроек может быть заблокирована политикой платформы.",
"sort_order": 7000,
"state_id": 1,
"labels": [],
"priority": "none",
"project_id": 1,
"cycle_id": 2,
"module_ids": [2, 3]
"module_ids": [
2,
3
]
}
]

View File

@ -1,14 +1,14 @@
[
{
"id": 1,
"name": "admin",
"name": "администрирование",
"color": "#0693e3",
"sort_order": 85535,
"project_id": 1
},
{
"id": 2,
"name": "concepts",
"name": "обучение",
"color": "#9900ef",
"sort_order": 95535,
"project_id": 1

View File

@ -1,26 +1,26 @@
[
{
"id": 1,
"name": "Core Workflow (System)",
"project_id": 1,
"sort_order": 1,
"status": "planned",
"description": "Manage, visualize, and track your work items across views."
},
{
"id": 2,
"name": "Onboarding Flow (Feature)",
"project_id": 1,
"sort_order": 2,
"status": "backlog",
"description": "Everything about getting started - creating a project, inviting teammates."
},
{
"id": 3,
"name": "Workspace Setup (Area)",
"project_id": 1,
"sort_order": 3,
"status": "in-progress",
"description": "The personalization layer - settings, labels, automations."
}
]
{
"id": 1,
"name": "Базовый workflow",
"project_id": 1,
"sort_order": 1,
"status": "planned",
"description": "Стартовый модуль NODE.DC для рабочих элементов, статусов, исполнителей и представлений."
},
{
"id": 2,
"name": "Онбординг команды",
"project_id": 1,
"sort_order": 2,
"status": "backlog",
"description": "Сценарии запуска workspace: проект, участники, роли и первые рабочие элементы."
},
{
"id": 3,
"name": "Настройка workspace",
"project_id": 1,
"sort_order": 3,
"status": "in-progress",
"description": "Персонализация рабочей среды: метки, циклы, модули, представления и настройки."
}
]

File diff suppressed because one or more lines are too long

View File

@ -1,17 +1,17 @@
[
{
"id": 1,
"name": "Plane Demo Project",
"identifier": "PDP",
"description": "Welcome to the Plane Demo Project! This project throws you into the drivers seat of Plane, work management software. Through curated work items, youll uncover key features, pick up best practices, and see how Plane can streamline your teams workflow. Whether youre a startup hungry to scale or an enterprise sharpening efficiency, this demo is your launchpad to mastering Plane. Jump in and see what it can do!",
"name": "NODE.DC стартовый проект",
"identifier": "NDC",
"description": "Стартовый проект NODE.DC Operational Core. Он создаётся автоматически вместе с новым workspace и содержит короткий учебный маршрут по базовым возможностям: проекты, рабочие элементы, участники, представления, циклы и настройки.",
"network": 2,
"cover_image": "https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"cover_image": "/seed-assets/nodedc/project-cover.jpg",
"logo_props": {
"emoji": {
"url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f447.png",
"value": "128071"
"icon": {
"name": "empty_dashboard",
"color": "#95999f"
},
"in_use": "emoji"
"in_use": "icon"
}
}
]

View File

@ -1,7 +1,7 @@
[
{
"id": 1,
"name": "Backlog",
"name": "Бэклог",
"color": "#A3A3A3",
"sequence": 15000,
"group": "backlog",
@ -10,7 +10,7 @@
},
{
"id": 2,
"name": "Todo",
"name": "К выполнению",
"color": "#3A3A3A",
"sequence": 25000,
"group": "unstarted",
@ -19,7 +19,7 @@
},
{
"id": 3,
"name": "In Progress",
"name": "В работе",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
@ -28,7 +28,7 @@
},
{
"id": 4,
"name": "Done",
"name": "Готово",
"color": "#16A34A",
"sequence": 45000,
"group": "completed",
@ -37,7 +37,7 @@
},
{
"id": 5,
"name": "Cancelled",
"name": "Отменено",
"color": "#EF4444",
"sequence": 55000,
"group": "cancelled",

View File

@ -1,8 +1,8 @@
[
{
"id": 1,
"name": "Project Urgent Tasks",
"description": "Project Urgent Tasks",
"name": "Срочные рабочие элементы",
"description": "Срочные рабочие элементы проекта",
"access": 1,
"filters": {},
"project_id": 1,
@ -11,4 +11,4 @@
"sort_order": 75535,
"rich_filters": {"priority__in": "urgent"}
}
]
]

View File

@ -33,8 +33,20 @@ def issue_queryset_grouper(
"module_ids": "issue_module__module_id",
}
valid_assignee_filter = (
Q(issue_assignee__deleted_at__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
)
GROUP_FILTER_MAPPER = {
"assignees__id": Q(issue_assignee__deleted_at__isnull=True),
"assignees__id": valid_assignee_filter,
"labels__id": Q(label_issue__deleted_at__isnull=True),
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
}
@ -46,7 +58,7 @@ def issue_queryset_grouper(
annotations_map = {
"assignee_ids": (
"assignees__id",
~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True),
~Q(assignees__id__isnull=True) & valid_assignee_filter,
),
"label_ids": (
"labels__id",

View File

@ -625,7 +625,14 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),

View File

@ -18,6 +18,8 @@ from plane.authentication.views.nodedc_logout import (
from plane.authentication.views.nodedc_workspace_adapter import (
NodeDCInternalProjectMembershipEnsureEndpoint,
NodeDCInternalProjectMembershipRemoveEndpoint,
NodeDCInternalWorkspaceInviteApproveEndpoint,
NodeDCInternalWorkspaceInviteRejectEndpoint,
NodeDCInternalWorkspaceListEndpoint,
NodeDCInternalWorkspaceMembershipEnsureEndpoint,
NodeDCInternalWorkspaceMembershipRemoveEndpoint,
@ -46,6 +48,16 @@ urlpatterns = [
NodeDCInternalWorkspaceMembershipRemoveEndpoint.as_view(),
name="nodedc-internal-workspace-membership-remove",
),
path(
"api/internal/nodedc/workspace-invite-requests/approve/",
NodeDCInternalWorkspaceInviteApproveEndpoint.as_view(),
name="nodedc-internal-workspace-invite-approve",
),
path(
"api/internal/nodedc/workspace-invite-requests/reject/",
NodeDCInternalWorkspaceInviteRejectEndpoint.as_view(),
name="nodedc-internal-workspace-invite-reject",
),
path(
"api/internal/nodedc/project-memberships/ensure/",
NodeDCInternalProjectMembershipEnsureEndpoint.as_view(),

View File

@ -5,7 +5,7 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value, QuerySet, OuterRef, Subquery
from django.db.models import F, Q, UUIDField, Value, QuerySet, OuterRef, Subquery
from django.db.models.functions import Coalesce
# Module imports
@ -37,8 +37,20 @@ def issue_queryset_grouper(
"module_ids": "issue_module__module_id",
}
valid_assignee_filter = (
Q(issue_assignee__deleted_at__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
)
GROUP_FILTER_MAPPER: Dict[str, Q] = {
"assignees__id": Q(issue_assignee__deleted_at__isnull=True),
"assignees__id": valid_assignee_filter,
"labels__id": Q(label_issue__deleted_at__isnull=True),
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
}
@ -51,6 +63,14 @@ def issue_queryset_grouper(
IssueAssignee.objects.filter(
issue_id=OuterRef("pk"),
deleted_at__isnull=True,
assignee__is_active=True,
assignee__member_workspace__workspace_id=OuterRef("workspace_id"),
assignee__member_workspace__is_active=True,
assignee__member_workspace__is_banned=False,
assignee__member_workspace__deleted_at__isnull=True,
assignee__member_project__project_id=OuterRef("project_id"),
assignee__member_project__is_active=True,
assignee__member_project__deleted_at__isnull=True,
)
.values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True))

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.db.models import Case, CharField, Min, Value, When
from django.db.models import Case, CharField, F, Min, Value, When
# Custom ordering for priority and state
PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"]
@ -20,8 +20,14 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
).order_by("priority_order", "-created_at")
order_by_param = "priority_order" if order_by_param.startswith("-") else "-priority_order"
# State Ordering
elif order_by_param in ["state__name", "-state__name"]:
issue_queryset = issue_queryset.annotate(state_sequence_order=F("state__sequence")).order_by(
"-state_sequence_order" if order_by_param.startswith("-") else "state_sequence_order",
"-created_at",
)
order_by_param = "-state_sequence_order" if order_by_param.startswith("-") else "state_sequence_order"
elif order_by_param in ["state__group", "-state__group"]:
state_order = STATE_ORDER if order_by_param in ["state__name", "state__group"] else STATE_ORDER[::-1]
state_order = STATE_ORDER if order_by_param == "state__group" else STATE_ORDER[::-1]
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order)],

View File

@ -3,7 +3,7 @@ FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
RUN corepack enable
# *****************************************************************************

View File

@ -25,13 +25,13 @@ import { CycleAppliedFiltersList } from "@/components/cycles/applied-filters";
import { CyclesView } from "@/components/cycles/cycles-view";
import { CycleCreateUpdateModal } from "@/components/cycles/modal";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils";
import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectCyclesPage({ params }: Route.ComponentProps) {
@ -40,8 +40,6 @@ function ProjectCyclesPage({ params }: Route.ComponentProps) {
// store hooks
const { currentProjectCycleIds, loader } = useCycle();
const { getProjectById, currentProjectDetails } = useProject();
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
// theme hook
const { resolvedTheme } = useTheme();
@ -81,7 +79,7 @@ function ProjectCyclesPage({ params }: Route.ComponentProps) {
primaryButton={{
text: t("disabled_project.empty_state.cycle.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
openProjectSettingsModal(projectId, "features_cycles");
},
disabled: !hasAdminLevelPermission,
}}

View File

@ -18,15 +18,13 @@ import lightIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-l
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { InboxIssueRoot } from "@/components/inbox";
import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectInboxPage({ params }: Route.ComponentProps) {
/// router
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
const searchParams = useSearchParams();
const navigationTab = searchParams.get("currentTab");
@ -53,7 +51,7 @@ function ProjectInboxPage({ params }: Route.ComponentProps) {
primaryButton={{
text: t("disabled_project.empty_state.inbox.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
openProjectSettingsModal(projectId, "features_intake");
},
disabled: !canPerformEmptyStateActions,
}}

View File

@ -20,17 +20,15 @@ import lightModulesAsset from "@/app/assets/empty-state/disabled-feature/modules
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules";
import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils";
// hooks
import { useModuleFilter } from "@/hooks/store/use-module-filter";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectModulesPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
const { projectId } = params;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
@ -74,7 +72,7 @@ function ProjectModulesPage({ params }: Route.ComponentProps) {
primaryButton={{
text: t("disabled_project.empty_state.module.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
openProjectSettingsModal(projectId, "features_modules");
},
disabled: !canPerformEmptyStateActions,
}}

View File

@ -20,10 +20,10 @@ import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { PagesListRoot } from "@/components/pages/list/root";
import { PagesListView } from "@/components/pages/pages-list-view";
import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
import type { Route } from "./+types/page";
@ -36,7 +36,6 @@ const getPageType = (pageType?: string | null): TPageNavigationTabs => {
function ProjectPagesPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const type = searchParams.get("type");
const { workspaceSlug, projectId } = params;
@ -65,7 +64,7 @@ function ProjectPagesPage({ params }: Route.ComponentProps) {
primaryButton={{
text: t("disabled_project.empty_state.page.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
openProjectSettingsModal(projectId, "features_pages");
},
disabled: !canPerformEmptyStateActions,
}}

View File

@ -20,19 +20,17 @@ import lightViewsAsset from "@/app/assets/empty-state/disabled-feature/views-lig
// components
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils";
import { ViewAppliedFiltersList } from "@/components/views/applied-filters";
import { ProjectViewsList } from "@/components/views/views-list";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectViewsPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
const { projectId } = params;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
@ -77,7 +75,7 @@ function ProjectViewsPage({ params }: Route.ComponentProps) {
primaryButton={{
text: t("disabled_project.empty_state.view.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
openProjectSettingsModal(projectId, "features_views");
},
disabled: !canPerformEmptyStateActions,
}}

View File

@ -6,8 +6,12 @@
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Outlet } from "react-router";
import { Outlet, redirect } from "react-router";
// components
import {
buildProjectSettingsModalUrl,
getProjectSettingsModalTabFromPath,
} from "@/components/project/settings/project-settings-modal.utils";
import { getProjectActivePath } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile/nav";
// layouts
@ -16,6 +20,13 @@ import { ProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper";
import type { Route } from "./+types/layout";
import { ProjectSettingsSidebarRoot } from "@/components/settings/project/sidebar";
export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => {
const { workspaceSlug, projectId } = params;
const tab = getProjectSettingsModalTabFromPath(new URL(request.url).pathname);
throw redirect(buildProjectSettingsModalUrl(workspaceSlug, projectId, tab));
};
function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// router

View File

@ -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} />

View File

@ -7,6 +7,7 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Outlet } from "react-router";
import { buildProjectSettingsModalUrl } from "@/components/project/settings/project-settings-modal.utils";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
@ -23,7 +24,7 @@ function ProjectSettingsLayout({ params }: Route.ComponentProps) {
useEffect(() => {
if (projectId) return;
if (joinedProjectIds.length > 0) {
router.push(`/${workspaceSlug}/settings/projects/${joinedProjectIds[0]}`);
router.push(buildProjectSettingsModalUrl(workspaceSlug, joinedProjectIds[0]));
}
}, [joinedProjectIds, router, workspaceSlug, projectId]);

View File

@ -10,6 +10,7 @@ import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/conten
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
import { GlobalModals } from "@/plane-web/components/common/modal/global";
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper";
import { ProjectSettingsModal } from "@/components/project/settings/project-settings-modal";
import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal";
import { WorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal";
import type { Route } from "./+types/layout";
@ -24,6 +25,7 @@ export default function WorkspaceLayout(props: Route.ComponentProps) {
<WorkspaceContentWrapper workspaceSlug={workspaceSlug}>
<GlobalModals workspaceSlug={workspaceSlug} />
<WorkspaceSettingsModal />
<ProjectSettingsModal />
<WorkspaceNotificationsModal />
<Outlet />
</WorkspaceContentWrapper>

View File

@ -60,6 +60,11 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
enabled: false,
can_create_workspace: true,
mode: "unavailable",
managed_by: "tasker",
default_managed_by: "tasker",
invite_approval: "tasker",
default_invite_approval: "tasker",
workspaces: [],
reason: "NODE.DC workspace policy is unavailable.",
});
}

View File

@ -5,13 +5,18 @@
*/
import { redirect } from "react-router";
import {
buildProjectSettingsModalUrl,
getProjectSettingsModalTabFromPath,
} from "@/components/project/settings/project-settings-modal.utils";
import type { Route } from "./+types/project-settings";
export const clientLoader = ({ params }: Route.ClientLoaderArgs) => {
const { workspaceSlug, projectId } = params;
const splat = params["*"] || "";
const destination = `/${workspaceSlug}/settings/projects/${projectId}${splat ? `/${splat}` : ""}/`;
throw redirect(destination);
const tab = getProjectSettingsModalTabFromPath(splat);
throw redirect(buildProjectSettingsModalUrl(workspaceSlug, projectId, tab));
};
export default function ProjectSettings() {

View File

@ -148,10 +148,10 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
const peekOverviewClassName = cn(
!embedIssue
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
? "absolute z-[80] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: "h-full w-full",
!embedIssue && {
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[780px] md:max-w-[calc(100vw-1.5rem)]":
"top-[5.35rem] right-3 bottom-[5.85rem] w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[780px] md:max-w-[calc(100vw-1.5rem)]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
@ -173,7 +173,7 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
>
{!embedIssue && peekMode === "side-peek" && (
<div
className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
onMouseDown={startPeekResizing}
role="separator"
aria-label="Resize external contour panel"

View File

@ -6,16 +6,36 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel, LOGIN_MEDIUM_LABELS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { renderFormattedDate } from "@plane/utils";
import { MemberHeaderColumn } from "@/components/project/member-header-column";
import type { RowData } from "@/components/workspace/settings/member-columns";
import { AccountTypeColumn, NameColumn } from "@/components/workspace/settings/member-columns";
import {
AccountTypeColumn,
NameColumn,
WorkspaceMemberActionsColumn,
} from "@/components/workspace/settings/member-columns";
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import type { IMemberFilters } from "@/store/member/utils";
const NODEDC_LOGIN_MEDIUM_LABELS: Record<string, string> = {
email: "Почта",
"magic-code": "Код",
github: "GitHub",
gitlab: "GitLab",
google: "Google",
gitea: "Gitea",
};
const getLoginMediumLabel = (loginMedium: string) => NODEDC_LOGIN_MEDIUM_LABELS[loginMedium] ?? loginMedium;
const isSuspended = (rowData: RowData) => rowData.is_active === false;
const stickyNameHeaderClassName = "nodedc-settings-table-sticky left-0 z-20 min-w-max";
const stickyNameCellClassName = "nodedc-settings-table-sticky left-0 z-10 min-w-max";
export const useMemberColumns = () => {
// states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
@ -34,8 +54,6 @@ export const useMemberColumns = () => {
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const isSuspended = (rowData: RowData) => rowData.is_active === false;
// handlers
const handleDisplayFilterUpdate = (filterUpdates: Partial<IMemberFilters>) => {
updateFilters(filterUpdates);
@ -45,13 +63,16 @@ export const useMemberColumns = () => {
{
key: "Full name",
content: t("workspace_settings.settings.members.details.full_name"),
thClassName: "text-left",
thClassName: stickyNameHeaderClassName,
tdClassName: stickyNameCellClassName,
thRender: () => (
<MemberHeaderColumn
property="full_name"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
<div className="w-max min-w-[8.5rem] pr-3">
<MemberHeaderColumn
property="full_name"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
</div>
),
tdRender: (rowData: RowData) => (
<NameColumn
@ -68,7 +89,9 @@ export const useMemberColumns = () => {
key: "Display name",
content: t("workspace_settings.settings.members.details.display_name"),
tdRender: (rowData: RowData) => (
<div className={`w-32 ${isSuspended(rowData) ? "text-placeholder" : ""}`}>{rowData.member.display_name}</div>
<div className={`min-w-[7.5rem] pr-3 ${isSuspended(rowData) ? "text-placeholder" : ""}`}>
{rowData.member.display_name}
</div>
),
thRender: () => (
<MemberHeaderColumn
@ -83,7 +106,9 @@ export const useMemberColumns = () => {
key: "Email address",
content: t("workspace_settings.settings.members.details.email_address"),
tdRender: (rowData: RowData) => (
<div className={`w-48 truncate ${isSuspended(rowData) ? "text-placeholder" : ""}`}>{rowData.member.email}</div>
<div className={`min-w-[10.5rem] pr-3 ${isSuspended(rowData) ? "text-placeholder" : ""}`}>
{rowData.member.email}
</div>
),
thRender: () => (
<MemberHeaderColumn
@ -109,12 +134,12 @@ export const useMemberColumns = () => {
{
key: "Authentication",
content: t("workspace_settings.settings.members.details.authentication"),
content: <div className="min-w-[5.5rem] pr-3">Вход</div>,
tdRender: (rowData: RowData) => {
if (isSuspended(rowData)) return null;
const loginMedium = rowData.member.last_login_medium;
if (!loginMedium) return null;
return <div>{LOGIN_MEDIUM_LABELS[loginMedium]}</div>;
return <div className="min-w-[5.5rem] pr-3">{getLoginMediumLabel(loginMedium)}</div>;
},
},
@ -122,7 +147,9 @@ export const useMemberColumns = () => {
key: "Joining date",
content: t("workspace_settings.settings.members.details.joining_date"),
tdRender: (rowData: RowData) =>
isSuspended(rowData) ? null : <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
isSuspended(rowData) ? null : (
<div className="min-w-[7rem] pr-3">{renderFormattedDate(rowData?.member?.joining_date)}</div>
),
thRender: () => (
<MemberHeaderColumn
property="joining_date"
@ -131,6 +158,19 @@ export const useMemberColumns = () => {
/>
),
},
{
key: "Actions",
content: <span className="sr-only">Действия</span>,
tdRender: (rowData: RowData) => (
<WorkspaceMemberActionsColumn
rowData={rowData}
workspaceSlug={workspaceSlug}
isAdmin={isAdmin}
currentUser={currentUser}
setRemoveMemberModal={setRemoveMemberModal}
/>
),
},
];
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };
};

View File

@ -11,16 +11,13 @@ import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useAppRouter } from "@/hooks/use-app-router";
export const ProjectArchivedEmptyState = observer(function ProjectArchivedEmptyState() {
// router
const router = useAppRouter();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const { projectId: routerProjectId } = useParams();
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// plane hooks
const { t } = useTranslation();
@ -57,7 +54,9 @@ export const ProjectArchivedEmptyState = observer(function ProjectArchivedEmptyS
actions={[
{
label: t("workspace_empty_state.archive_work_items.cta_primary"),
onClick: () => router.push(`/${workspaceSlug}/settings/projects/${projectId}/automations`),
onClick: () => {
if (projectId) openProjectSettingsModal(projectId, "automations");
},
disabled: !canPerformEmptyStateActions,
variant: "primary",
},

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import type { FC, PointerEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
@ -150,6 +150,20 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
setSelectedIssueId(undefined);
}, []);
const handleKanbanPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!selectedIssueId) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest("[data-nodedc-kanban-issue-card='true']")) return;
handleClearSelectedIssue();
},
[handleClearSelectedIssue, selectedIssueId]
);
const canEditProperties = useCallback(
(projectId: string | undefined) => {
const isEditingAllowedBasedOnProject =
@ -302,6 +316,7 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
<div
className={`nodedc-kanban-scroll-container horizontal-scrollbar relative flex scrollbar-lg h-full w-full bg-transparent ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
ref={scrollableContainerRef}
onPointerDownCapture={handleKanbanPointerDown}
>
<div className="relative h-full w-max min-w-full bg-transparent">
<div className="h-full w-max">

View File

@ -330,6 +330,7 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
{ "z-[100]": isCurrentBlockDragging && cardVariant === "internal-contour" }
)}
data-card-variant={cardVariant}
data-nodedc-kanban-issue-card="true"
onClick={handleIssueBlockClick}
disabled={!!issue?.tempId}
>

View File

@ -46,7 +46,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const { t } = useTranslation();
const { isMobile } = usePlatformOS();
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const { getUserDetails } = useMember();
const {
getUserDetails,
project: { getProjectMemberIds },
} = useMember();
const { getProjectById } = useProject();
const { getStateById, getProjectStateIds } = useProjectState();
@ -86,7 +89,11 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const checkerItemsTotal = issue.checker_items_count ?? 0;
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
const hasCheckerProgress = checkerBlocksTotal > 0;
const assigneeIds = issue.assignee_ids ?? [];
const rawAssigneeIds = issue.assignee_ids ?? [];
const projectMemberIds = issue.project_id ? getProjectMemberIds(issue.project_id, true) : null;
const assigneeIds = projectMemberIds
? rawAssigneeIds.filter((assigneeId) => projectMemberIds.includes(assigneeId))
: rawAssigneeIds;
const visibleAssigneeIds = assigneeIds.slice(0, 3);
const assigneeCount = assigneeIds.length;
const assigneeStackWidthClass =

View File

@ -238,9 +238,9 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
shouldRenderPeekSurface
? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: "h-full w-full",
!embedIssue && "absolute z-[25]",
!embedIssue && "absolute z-[80]",
!embedIssue && {
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
"top-[5.35rem] right-3 bottom-[5.85rem] w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
@ -273,7 +273,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
>
{shouldAllowPeekResize && peekMode === "side-peek" && (
<div
className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
onMouseDown={startPeekResizing}
role="separator"
aria-label="Resize issue panel"

View File

@ -13,6 +13,7 @@ import { useTranslation } from "@plane/i18n";
import { LinkIcon } from "@plane/propel/icons";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils";
type Props = {
workspaceSlug: string;
@ -75,7 +76,7 @@ export function ProjectActionsMenu({
title: t("settings"),
icon: Settings,
action: () => {
navigate(`/${workspaceSlug}/settings/projects/${project?.id}`);
openProjectSettingsModal(project.id);
},
},
...(!isAuthorized

View File

@ -26,10 +26,13 @@ import { copyUrlToClipboard, cn, getFileURL, renderFormattedDate } from "@plane/
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { CoverImage } from "@/components/common/cover-image";
import {
buildProjectSettingsModalUrl,
openProjectSettingsModal,
} from "@/components/project/settings/project-settings-modal.utils";
import { DeleteProjectModal } from "./delete-project-modal";
import { JoinProjectModal } from "./join-project-modal";
import { ArchiveRestoreProjectModal } from "./archive-restore-modal";
@ -47,7 +50,6 @@ export const ProjectCard = observer(function ProjectCard(props: Props) {
// refs
const projectCardRef = useRef(null);
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { getUserDetails } = useMember();
@ -125,7 +127,7 @@ export const ProjectCard = observer(function ProjectCard(props: Props) {
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "settings",
action: () => router.push(`/${workspaceSlug}/settings/projects/${project.id}`),
action: () => openProjectSettingsModal(project.id),
title: "Settings",
icon: Settings,
shouldRender: !isArchived && (hasAdminRole || hasMemberRole),
@ -339,9 +341,11 @@ export const ProjectCard = observer(function ProjectCard(props: Props) {
<Link
className="flex items-center justify-center rounded-sm p-1 text-placeholder hover:bg-layer-1 hover:text-secondary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openProjectSettingsModal(project.id);
}}
href={`/${workspaceSlug}/settings/projects/${project.id}`}
href={workspaceSlug ? buildProjectSettingsModalUrl(workspaceSlug.toString(), project.id) : "#"}
>
<Settings className="h-3.5 w-3.5" />
</Link>

View File

@ -0,0 +1,539 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
import { X } from "lucide-react";
import useSWR from "swr";
// plane imports
import {
EUserPermissions,
EUserPermissionsLevel,
GROUPED_PROJECT_SETTINGS,
PROJECT_SETTINGS,
PROJECT_SETTINGS_CATEGORIES,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ScrollArea } from "@plane/propel/scrollarea";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { IProject, TProjectSettingsTabs } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { EstimateRoot } from "@/components/estimates";
import { ProjectSettingsLabelList } from "@/components/labels";
import { ProjectDetailsForm } from "@/components/project/form";
import { ProjectDetailsFormLoader } from "@/components/project/form-loader";
import { ProjectMemberList } from "@/components/project/member-list";
import { ProjectSettingsMemberDefaults } from "@/components/project/project-settings-member-defaults";
import { GeneralProjectSettingsControlSection } from "@/components/project/settings/control-section";
import { ProjectStateRoot } from "@/components/project-states";
import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
import { SettingsHeading } from "@/components/settings/heading";
import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item";
import { ProjectSettingsSidebarHeader } from "@/components/settings/project/sidebar/header";
import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspace } from "@/hooks/store/use-workspace";
// layouts
import { ProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper";
// plane web imports
import { CustomAutomationsRoot } from "@/plane-web/components/automations/root";
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list";
// services
import { WorkspaceService } from "@/services/workspace.service";
// local imports
import {
closeProjectSettingsModal,
getProjectSettingsModalProjectIdFromSearch,
getProjectSettingsModalTabFromSearch,
openProjectSettingsModal,
PROJECT_SETTINGS_MODAL_EVENT,
type TProjectSettingsModalTab,
} from "./project-settings-modal.utils";
const PROJECT_CATEGORY_I18N_KEYS = {
general: "project_settings_categories.general",
features: "project_settings_categories.features",
"work-structure": "project_settings_categories.work_structure",
execution: "project_settings_categories.execution",
} as const;
const PROJECT_FEATURE_SETTINGS: Record<
Extract<
TProjectSettingsModalTab,
"features_cycles" | "features_modules" | "features_views" | "features_pages" | "features_intake"
>,
{
descriptionKey: string;
featureProperty: keyof IProject;
titleKey: string;
toggleDescriptionKey: string;
toggleTitleKey: string;
}
> = {
features_cycles: {
descriptionKey: "project_settings.features.cycles.description",
featureProperty: "cycle_view",
titleKey: "project_settings.features.cycles.title",
toggleDescriptionKey: "project_settings.features.cycles.toggle_description",
toggleTitleKey: "project_settings.features.cycles.toggle_title",
},
features_modules: {
descriptionKey: "project_settings.features.modules.description",
featureProperty: "module_view",
titleKey: "project_settings.features.modules.title",
toggleDescriptionKey: "project_settings.features.modules.toggle_description",
toggleTitleKey: "project_settings.features.modules.toggle_title",
},
features_views: {
descriptionKey: "project_settings.features.views.description",
featureProperty: "issue_views_view",
titleKey: "project_settings.features.views.title",
toggleDescriptionKey: "project_settings.features.views.toggle_description",
toggleTitleKey: "project_settings.features.views.toggle_title",
},
features_pages: {
descriptionKey: "project_settings.features.pages.description",
featureProperty: "page_view",
titleKey: "project_settings.features.pages.title",
toggleDescriptionKey: "project_settings.features.pages.toggle_description",
toggleTitleKey: "project_settings.features.pages.toggle_title",
},
features_intake: {
descriptionKey: "project_settings.features.intake.description",
featureProperty: "inbox_view",
titleKey: "project_settings.features.intake.title",
toggleDescriptionKey: "project_settings.features.intake.toggle_description",
toggleTitleKey: "project_settings.features.intake.toggle_title",
},
};
const workspaceService = new WorkspaceService();
const getInitialTab = (): TProjectSettingsModalTab => {
if (typeof window === "undefined") return "general";
return getProjectSettingsModalTabFromSearch(window.location.search) ?? "general";
};
const getInitialProjectId = () => {
if (typeof window === "undefined") return undefined;
return getProjectSettingsModalProjectIdFromSearch(window.location.search);
};
const getInitialOpenState = () => {
if (typeof window === "undefined") return false;
return Boolean(getProjectSettingsModalTabFromSearch(window.location.search) && getInitialProjectId());
};
export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
const [activeTab, setActiveTab] = useState<TProjectSettingsModalTab>(getInitialTab);
const [activeProjectId, setActiveProjectId] = useState<string | undefined>(getInitialProjectId);
const [isOpen, setIsOpen] = useState(getInitialOpenState);
// store hooks
const { getProjectById } = useProject();
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
// derived values
const projectDetails = getProjectById(activeProjectId);
const workspaceSlug = currentWorkspace?.slug;
const activeTabLabel = PROJECT_SETTINGS[activeTab]?.i18n_label
? t(PROJECT_SETTINGS[activeTab].i18n_label)
: "основные параметры";
useEffect(() => {
const syncFromLocation = () => {
const tab = getProjectSettingsModalTabFromSearch(window.location.search);
const projectId = getProjectSettingsModalProjectIdFromSearch(window.location.search);
setIsOpen(Boolean(tab && projectId));
if (tab) setActiveTab(tab);
setActiveProjectId(projectId);
};
const handleModalEvent = (event: Event) => {
const detail = (event as CustomEvent<{ isOpen: boolean; projectId?: string; tab?: TProjectSettingsModalTab }>)
.detail;
setIsOpen(detail.isOpen);
if (detail.tab) setActiveTab(detail.tab);
if (detail.projectId) setActiveProjectId(detail.projectId);
};
window.addEventListener(PROJECT_SETTINGS_MODAL_EVENT, handleModalEvent);
window.addEventListener("popstate", syncFromLocation);
syncFromLocation();
return () => {
window.removeEventListener(PROJECT_SETTINGS_MODAL_EVENT, handleModalEvent);
window.removeEventListener("popstate", syncFromLocation);
};
}, []);
const handleClose = () => {
closeProjectSettingsModal();
};
const handleSelectItem = (itemKey: TProjectSettingsTabs) => {
if (!activeProjectId) return;
openProjectSettingsModal(activeProjectId, itemKey, true);
};
return (
<ModalCore
isOpen={isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
>
{workspaceSlug && activeProjectId ? (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}>
<div className="flex h-full min-h-0">
<div className="hidden h-full w-[296px] shrink-0 md:block">
<ProjectSettingsSidebarHeader projectId={activeProjectId} onBack={handleClose} />
<ProjectModalSidebar
activeTab={activeTab}
onSelectItem={handleSelectItem}
projectId={activeProjectId}
workspaceSlug={workspaceSlug}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-center justify-between gap-4 px-6 py-5">
<div className="min-w-0">
<div className="text-18 font-semibold text-primary">Настройки проекта</div>
<div className="mt-1 truncate text-12 text-tertiary">
{projectDetails?.name ?? "Project"} / {activeTabLabel}
</div>
</div>
<button
type="button"
onClick={handleClose}
className="grid size-10 flex-shrink-0 place-items-center rounded-full bg-white/6 text-secondary transition hover:bg-white/10 hover:text-primary"
aria-label="Закрыть настройки проекта"
>
<X className="size-5" />
</button>
</div>
<ScrollArea
scrollType="hover"
orientation="vertical"
size="sm"
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">
<ProjectSettingsModalContent
activeTab={activeTab}
projectId={activeProjectId}
workspaceSlug={workspaceSlug}
/>
</div>
</ScrollArea>
</div>
</div>
</ProjectAuthWrapper>
) : null}
</ModalCore>
);
});
type TProjectModalSidebarProps = {
activeTab: TProjectSettingsModalTab;
onSelectItem: (itemKey: TProjectSettingsTabs) => void;
projectId: string;
workspaceSlug: string;
};
const ProjectModalSidebar = observer(function ProjectModalSidebar(props: TProjectModalSidebarProps) {
const { activeTab, onSelectItem, projectId, workspaceSlug } = props;
// store hooks
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const { data: nodedcWorkspacePolicy } = useSWR(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`, () =>
workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
);
const shouldHideMemberSettings = !nodedcWorkspacePolicy || nodedcWorkspacePolicy.managed_by === "launcher";
return (
<ScrollArea
scrollType="hover"
orientation="vertical"
size="sm"
rootClassName="nodedc-settings-sidebar-shell h-[calc(100%-6.75rem)] w-full overflow-y-scroll px-3 py-4"
>
<div className="flex flex-col divide-y divide-white/6">
{PROJECT_SETTINGS_CATEGORIES.map((category) => {
const accessibleItems = GROUPED_PROJECT_SETTINGS[category].filter(
(item) =>
(!shouldHideMemberSettings || item.key !== "members") &&
allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, projectId)
);
if (accessibleItems.length === 0) return null;
return (
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">
{t(PROJECT_CATEGORY_I18N_KEYS[category])}
</div>
<div className="flex flex-col">
{accessibleItems.map((item) => (
<SettingsSidebarItem
key={item.key}
as="button"
onClick={() => onSelectItem(item.key)}
isActive={item.key === activeTab}
icon={PROJECT_SETTINGS_ICONS[item.key]}
label={t(item.i18n_label)}
/>
))}
</div>
</div>
);
})}
</div>
</ScrollArea>
);
});
type TProjectSettingsModalContentProps = {
activeTab: TProjectSettingsModalTab;
projectId: string;
workspaceSlug: string;
};
const ProjectSettingsModalContent = observer(function ProjectSettingsModalContent(
props: TProjectSettingsModalContentProps
) {
const { activeTab, projectId, workspaceSlug } = props;
// store hooks
const { getProjectById, updateProject } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const { data: nodedcWorkspacePolicy } = useSWR(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`, () =>
workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
);
// derived values
const projectDetails = getProjectById(projectId);
const pageTitle = projectDetails?.name ? `${projectDetails.name} - Settings` : undefined;
const canPerformProjectAdminActions = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const canPerformProjectMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE, workspaceSlug);
const canPerformProjectMemberListActions = canPerformProjectMemberActions || isWorkspaceAdmin;
if (activeTab === "general") {
return (
<>
<PageHead title={pageTitle} />
<div className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
{projectDetails ? (
<ProjectDetailsForm
project={projectDetails}
workspaceSlug={workspaceSlug}
projectId={projectId}
isAdmin={canPerformProjectAdminActions}
/>
) : (
<ProjectDetailsFormLoader />
)}
{canPerformProjectAdminActions && <GeneralProjectSettingsControlSection projectId={projectId} />}
</div>
</>
);
}
if (activeTab === "members") {
if (workspaceUserInfo && !canPerformProjectMemberListActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
if (nodedcWorkspacePolicy?.managed_by === "launcher") {
return (
<>
<PageHead title={pageTitle} />
<SettingsHeading title={t("common.members")} />
<section className="border-custom-border-200 bg-custom-background-90 rounded-2xl border p-8">
<p className="text-sm text-custom-text-300 font-semibold tracking-[0.22em] uppercase">
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-custom-text-300 text-body-sm-regular">
Этот workspace подключен к enterprise-контуру NODE.DC. Project-level доступы назначаются в Launcher,
поэтому локальное управление участниками проекта в Task Manager заблокировано.
</p>
</div>
</section>
</>
);
}
return (
<>
<PageHead title={pageTitle} />
<SettingsHeading title={t("common.members")} />
<ProjectSettingsMemberDefaults projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectTeamspaceList projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectMemberList projectId={projectId} workspaceSlug={workspaceSlug} />
</>
);
}
if (activeTab in PROJECT_FEATURE_SETTINGS) {
const featureSettings = PROJECT_FEATURE_SETTINGS[activeTab as keyof typeof PROJECT_FEATURE_SETTINGS];
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<>
<PageHead title={pageTitle} />
<section className="w-full">
<SettingsHeading title={t(featureSettings.titleKey)} description={t(featureSettings.descriptionKey)} />
<div className="mt-7">
<ProjectSettingsFeatureControlItem
title={t(featureSettings.toggleTitleKey)}
description={t(featureSettings.toggleDescriptionKey)}
featureProperty={featureSettings.featureProperty}
projectId={projectId}
value={!!projectDetails?.[featureSettings.featureProperty]}
workspaceSlug={workspaceSlug}
/>
</div>
</section>
</>
);
}
if (activeTab === "states") {
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<>
<PageHead title={pageTitle} />
<div className="w-full">
<SettingsHeading
title={t("project_settings.states.heading")}
description={t("project_settings.states.description")}
/>
<div className="mt-6">
<ProjectStateRoot workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
</div>
</>
);
}
if (activeTab === "labels") {
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return <ProjectLabelsSettingsContent pageTitle={pageTitle} />;
}
if (activeTab === "estimates") {
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
return (
<>
<PageHead title={pageTitle} />
<div className={`w-full ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}>
<EstimateRoot workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={canPerformProjectAdminActions} />
</div>
</>
);
}
if (activeTab === "automations") {
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
const handleChange = async (formData: Partial<IProject>) => {
if (!projectDetails) return;
try {
await updateProject(workspaceSlug, projectId, formData);
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
}
};
return (
<>
<PageHead title={pageTitle} />
<section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<SettingsHeading
title={t("project_settings.automations.heading")}
description={t("project_settings.automations.description")}
/>
<div className="mt-6">
<AutoArchiveAutomation handleChange={handleChange} />
<AutoCloseAutomation handleChange={handleChange} />
</div>
</section>
<CustomAutomationsRoot projectId={projectId} workspaceSlug={workspaceSlug} />
</>
);
}
return null;
});
function ProjectLabelsSettingsContent(props: { pageTitle?: string }) {
const { pageTitle } = props;
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = scrollableContainerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
})
);
}, []);
return (
<>
<PageHead title={pageTitle} />
<div ref={scrollableContainerRef} className="size-full">
<ProjectSettingsLabelList />
</div>
</>
);
}

View File

@ -0,0 +1,121 @@
import type { TProjectSettingsTabs } from "@plane/types";
export const PROJECT_SETTINGS_MODAL_QUERY_KEY = "projectSettings";
export const PROJECT_SETTINGS_PROJECT_QUERY_KEY = "projectId";
export const PROJECT_SETTINGS_MODAL_EVENT = "nodedc:project-settings-modal";
export type TProjectSettingsModalTab = TProjectSettingsTabs;
type TProjectSettingsModalEventDetail = {
isOpen: boolean;
projectId?: string;
tab?: TProjectSettingsModalTab;
};
const PROJECT_SETTINGS_MODAL_TABS = new Set<TProjectSettingsModalTab>([
"general",
"members",
"features_cycles",
"features_modules",
"features_views",
"features_pages",
"features_intake",
"states",
"labels",
"estimates",
"automations",
]);
const PROJECT_SETTINGS_PATH_TO_TAB: Array<[string, TProjectSettingsModalTab]> = [
["features/cycles", "features_cycles"],
["features/modules", "features_modules"],
["features/views", "features_views"],
["features/pages", "features_pages"],
["features/intake", "features_intake"],
["features", "features_cycles"],
["members", "members"],
["states", "states"],
["labels", "labels"],
["estimates", "estimates"],
["automations", "automations"],
];
const dispatchProjectSettingsModalEvent = (detail: TProjectSettingsModalEventDetail) => {
window.dispatchEvent(new CustomEvent<TProjectSettingsModalEventDetail>(PROJECT_SETTINGS_MODAL_EVENT, { detail }));
};
export const getProjectSettingsModalTabFromSearch = (search: string): TProjectSettingsModalTab | undefined => {
const value = new URLSearchParams(search).get(PROJECT_SETTINGS_MODAL_QUERY_KEY) as TProjectSettingsModalTab | null;
return value && PROJECT_SETTINGS_MODAL_TABS.has(value) ? value : undefined;
};
export const getProjectSettingsModalProjectIdFromSearch = (search: string): string | undefined => {
const value = new URLSearchParams(search).get(PROJECT_SETTINGS_PROJECT_QUERY_KEY);
return value || undefined;
};
export const getProjectSettingsModalTabFromPath = (path: string | undefined): TProjectSettingsModalTab => {
const normalizedPath = (path ?? "").replace(/^\/+|\/+$/g, "");
if (!normalizedPath) return "general";
const matchedTab = PROJECT_SETTINGS_PATH_TO_TAB.find(([suffix]) => normalizedPath.endsWith(suffix));
return matchedTab?.[1] ?? "general";
};
export const buildProjectSettingsModalSearch = (projectId: string, tab: TProjectSettingsModalTab = "general") => {
const searchParams = new URLSearchParams();
searchParams.set(PROJECT_SETTINGS_MODAL_QUERY_KEY, tab);
searchParams.set(PROJECT_SETTINGS_PROJECT_QUERY_KEY, projectId);
return searchParams.toString();
};
export const buildProjectSettingsModalUrl = (
workspaceSlug: string,
projectId: string,
tab: TProjectSettingsModalTab = "general"
) => `/${workspaceSlug}/?${buildProjectSettingsModalSearch(projectId, tab)}`;
export const setProjectSettingsModalSearch = (
projectId: string,
tab: TProjectSettingsModalTab = "general",
replace = false
) => {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(PROJECT_SETTINGS_MODAL_QUERY_KEY, tab);
url.searchParams.set(PROJECT_SETTINGS_PROJECT_QUERY_KEY, projectId);
window.history[replace ? "replaceState" : "pushState"](window.history.state, "", url);
};
export const clearProjectSettingsModalSearch = () => {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.delete(PROJECT_SETTINGS_MODAL_QUERY_KEY);
url.searchParams.delete(PROJECT_SETTINGS_PROJECT_QUERY_KEY);
window.history.replaceState(window.history.state, "", url);
};
export const openProjectSettingsModal = (
projectId: string,
tab: TProjectSettingsModalTab = "general",
replace = false
) => {
if (typeof window === "undefined") return;
setProjectSettingsModalSearch(projectId, tab, replace);
dispatchProjectSettingsModalEvent({ isOpen: true, projectId, tab });
};
export const closeProjectSettingsModal = () => {
if (typeof window === "undefined") return;
clearProjectSettingsModalSearch();
dispatchProjectSettingsModalEvent({ isOpen: false });
};

View File

@ -18,11 +18,12 @@ import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
type Props = {
onBack?: () => void;
projectId: string;
};
export const ProjectSettingsSidebarHeader = observer(function ProjectSettingsSidebarHeader(props: Props) {
const { projectId } = props;
const { onBack, projectId } = props;
// router
const router = useAppRouter();
// store hooks
@ -47,7 +48,14 @@ export const ProjectSettingsSidebarHeader = observer(function ProjectSettingsSid
size="base"
icon={ArrowLeft}
className="nodedc-toolbar-icon-button"
onClick={() => router.push(`/${currentWorkspace?.slug}/projects/${projectId}/issues/`)}
onClick={() => {
if (onBack) {
onBack();
return;
}
router.push(`/${currentWorkspace?.slug}/projects/${projectId}/issues/`);
}}
/>
<p>{t("project_settings_label")}</p>
</div>
@ -57,7 +65,9 @@ export const ProjectSettingsSidebarHeader = observer(function ProjectSettingsSid
</div>
<div className="truncate">
<p className="truncate text-body-sm-medium text-primary">{projectDetails?.name}</p>
<p className="truncate text-caption-md-medium text-tertiary">{t(ROLE_DETAILS[currentProjectRole].i18n_title)}</p>
<p className="truncate text-caption-md-medium text-tertiary">
{t(ROLE_DETAILS[currentProjectRole].i18n_title)}
</p>
</div>
</div>
</div>

View File

@ -7,11 +7,13 @@
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { useParams } from "react-router";
import useSWR from "swr";
// plane imports
import { EUserPermissionsLevel, GROUPED_PROJECT_SETTINGS, PROJECT_SETTINGS_CATEGORIES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
import { WorkspaceService } from "@/services/workspace.service";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local imports
@ -24,6 +26,8 @@ const PROJECT_CATEGORY_I18N_KEYS = {
execution: "project_settings_categories.execution",
} as const;
const workspaceService = new WorkspaceService();
type Props = {
projectId: string;
};
@ -37,15 +41,22 @@ export const ProjectSettingsSidebarItemCategories = observer(function ProjectSet
const pathname = usePathname();
// store hooks
const { allowPermissions } = useUserPermissions();
const { data: nodedcWorkspacePolicy } = useSWR(
workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null,
() => workspaceService.getNodeDCWorkspacePolicy(workspaceSlug?.toString())
);
// translation
const { t } = useTranslation();
const shouldHideMemberSettings = !nodedcWorkspacePolicy || nodedcWorkspacePolicy.managed_by === "launcher";
return (
<div className="mt-4 flex flex-col divide-y divide-white/6">
{PROJECT_SETTINGS_CATEGORIES.map((category) => {
const categoryItems = GROUPED_PROJECT_SETTINGS[category];
const accessibleItems = categoryItems.filter((item) =>
allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, projectId)
const accessibleItems = categoryItems.filter(
(item) =>
(!shouldHideMemberSettings || item.key !== "members") &&
allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, projectId)
);
if (accessibleItems.length === 0) return null;

View File

@ -61,26 +61,24 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[40rem]">
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-[rgba(var(--nodedc-accent-rgb),0.16)] sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-[rgb(var(--nodedc-accent-rgb))]" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-16 leading-6 font-medium text-primary">
{currentUser?.id === userDetails.id
? "Leave workspace?"
: `Remove ${userDetails?.display_name}?`}
? "Покинуть рабочее пространство?"
: `Удалить ${userDetails?.display_name}?`}
</Dialog.Title>
<div className="mt-2">
{currentUser?.id === userDetails.id ? (
<p className="text-13 text-secondary">
Are you sure you want to leave the workspace? You will no longer have access to this
workspace. This action cannot be undone.
Вы потеряете доступ к этому рабочему пространству. Действие нельзя отменить.
</p>
) : (
<p className="text-13 text-secondary">
Are you sure you want to remove member-{" "}
<span className="font-bold">{userDetails?.display_name}</span>? They will no longer have
access to this workspace. This action cannot be undone.
Вы уверены, что хотите удалить <span className="font-bold">{userDetails?.display_name}</span>?
Доступ к этому рабочему пространству будет закрыт. Действие нельзя отменить.
</p>
)}
</div>
@ -89,16 +87,16 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" onClick={handleClose}>
Cancel
Отменить
</Button>
<Button variant="error-fill" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
<Button variant="primary" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
{currentUser?.id === userDetails.id
? isRemoving
? "Leaving"
: "Leave"
? "Выход..."
: "Покинуть"
: isRemoving
? "Removing"
: "Remove"}
? "Удаление..."
: "Удалить"}
</Button>
</div>
</Dialog.Panel>

View File

@ -7,9 +7,8 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { AlertTriangle } from "lucide-react";
// ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useUser } from "@/hooks/store/user";
@ -30,7 +29,6 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
const [isRemoving, setIsRemoving] = useState(false);
// store hooks
const { data: currentUser } = useUser();
const { t } = useTranslation();
const handleClose = () => {
onClose();
@ -49,23 +47,24 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[rgba(var(--nodedc-accent-rgb),0.16)] sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-[rgb(var(--nodedc-accent-rgb))]" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-h5-medium leading-6 text-primary">
{currentUser?.id === userDetails.id ? "Leave workspace?" : `Remove ${userDetails?.display_name}?`}
{currentUser?.id === userDetails.id
? "Покинуть рабочее пространство?"
: `Удалить ${userDetails?.display_name}?`}
</h3>
<div className="mt-2">
{currentUser?.id === userDetails.id ? (
<p className="text-body-xs-regular text-secondary">
{t("workspace_settings.settings.members.leave_confirmation")}
Вы потеряете доступ к этому рабочему пространству. Действие нельзя отменить.
</p>
) : (
<p className="text-body-xs-regular text-secondary">
{/* TODO: Add translation here */}
Are you sure you want to remove member- <span className="font-bold">{userDetails?.display_name}</span>
? They will no longer have access to this workspace. This action cannot be undone.
Вы уверены, что хотите удалить <span className="font-bold">{userDetails?.display_name}</span>? Доступ
к этому рабочему пространству будет закрыт. Действие нельзя отменить.
</p>
)}
</div>
@ -74,16 +73,16 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
{t("cancel")}
Отменить
</Button>
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
<Button variant="primary" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
{currentUser?.id === userDetails.id
? isRemoving
? t("leaving")
: t("leave")
? "Выход..."
: "Покинуть"
: isRemoving
? t("removing")
: t("remove")}
? "Удаление..."
: "Удалить"}
</Button>
</div>
</ModalCore>

View File

@ -10,11 +10,9 @@ import { useParams } from "next/navigation";
// plane imports
import { ROLE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { LinkIcon, TrashIcon, ChevronDownIcon } from "@plane/propel/icons";
import { ChevronDownIcon, CopyLinkIcon, TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { copyTextToClipboard } from "@plane/utils";
import { cn, copyTextToClipboard } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// components
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
@ -26,6 +24,8 @@ type Props = {
invitationId: string;
};
type NodeDCInvitationApprovalStatus = "not_required" | "pending" | "approved" | "rejected";
export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitationsListItem(props: Props) {
const { invitationId } = props;
// router
@ -43,6 +43,10 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
const invitationDetails = getWorkspaceInvitationDetails(invitationId);
const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString());
const currentWorkspaceRole = currentWorkspaceMemberInfo?.role;
const nodedcApprovalStatus =
(invitationDetails as { nodedc_approval_status?: NodeDCInvitationApprovalStatus } | undefined)
?.nodedc_approval_status ?? "not_required";
const isNodeDCApprovalLocked = nodedcApprovalStatus === "pending" || nodedcApprovalStatus === "rejected";
// is the current logged in user admin
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
// role change access-
@ -61,15 +65,15 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
await deleteMemberInvitation(workspaceSlug.toString(), invitationDetails.id);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Invitation removed successfully.",
title: "Инвайт удалён",
message: "Пользователь больше не увидит это приглашение.",
});
} catch (err: unknown) {
const error = err as { error?: string };
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error || "Something went wrong. Please try again.",
title: "Ошибка",
message: error?.error || "Не удалось удалить инвайт. Попробуйте ещё раз.",
});
}
};
@ -78,39 +82,19 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
const handleCopyText = async () => {
try {
if (!invitationDetails.invite_link) return;
const inviteLink = new URL(invitationDetails.invite_link, window.location.origin).href;
await copyTextToClipboard(inviteLink);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("entity.link_copied_to_clipboard", { entity: t("common.invite") }),
title: "Инвайт скопирован",
message: "Передайте ссылку приглашённому пользователю.",
});
} catch (error) {
console.error("Error generating invite link:", error);
}
};
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "copy-link",
action: () => void handleCopyText(),
title: t("common.actions.copy_link"),
icon: LinkIcon,
shouldRender: !!invitationDetails.invite_link,
},
{
key: "remove",
action: () => {
setRemoveMemberModal(true);
},
title: t("common.remove"),
icon: TrashIcon,
shouldRender: isAdmin,
className: "text-danger-primary",
iconClassName: "text-danger-primary",
},
];
return (
<>
<ConfirmWorkspaceMemberRemove
@ -132,8 +116,8 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
</div>
</div>
<div className="flex items-center gap-2 text-11">
<div className="flex items-center justify-center rounded-full bg-label-yellow-bg-strong/20 px-2.5 py-1 text-center text-caption-sm-medium text-label-yellow-text">
<p>{t("common.pending")}</p>
<div className={cn("nodedc-settings-chip flex min-h-10 min-w-[9.25rem] items-center justify-center px-4 py-2 text-center text-caption-sm-medium", nodedcInviteStatusClassName(nodedcApprovalStatus))}>
<p>{nodedcInviteStatusLabel(nodedcApprovalStatus, t("common.pending"))}</p>
</div>
<SelectionDropdown
options={Object.keys(ROLE)
@ -167,7 +151,7 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
},
}))}
menuButton={
<div className="nodedc-settings-chip item-center flex gap-1 px-3 py-1">
<div className="nodedc-settings-chip flex min-h-10 min-w-[6.5rem] items-center justify-center gap-1 px-4 py-2">
<span
className={`flex items-center rounded-sm text-caption-sm-medium ${
hasRoleChangeAccess ? "" : "text-placeholder"
@ -182,14 +166,48 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
)}
</div>
}
disabled={!hasRoleChangeAccess}
disabled={!hasRoleChangeAccess || isNodeDCApprovalLocked}
placement="bottom-end"
/>
{isAdmin && (
<ActionDropdown placement="bottom-end" items={MENU_ITEMS} />
<div className="flex items-center gap-1.5">
{invitationDetails.invite_link && (
<button
aria-label={`Копировать инвайт ${invitationDetails.email}`}
className="grid h-10 w-10 place-items-center rounded-full border-0 bg-white/6 text-secondary transition-colors hover:bg-white/10 hover:text-primary"
title="Копировать инвайт"
type="button"
onClick={() => void handleCopyText()}
>
<CopyLinkIcon className="h-3.5 w-3.5" />
</button>
)}
<button
aria-label={`Удалить инвайт ${invitationDetails.email}`}
className="grid h-10 w-10 place-items-center rounded-full border-0 bg-white/6 text-secondary transition-colors hover:bg-red-500/15 hover:text-red-300"
title="Удалить"
type="button"
onClick={() => setRemoveMemberModal(true)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
</>
);
});
function nodedcInviteStatusLabel(status: string, fallback: string) {
if (status === "pending") return "Ожидает";
if (status === "approved") return "Подтверждено NDC";
if (status === "rejected") return "Отклонено";
return fallback;
}
function nodedcInviteStatusClassName(status: string) {
if (status === "approved") return "bg-lime-400/15 text-lime-300";
if (status === "rejected") return "bg-red-500/15 text-red-300";
return "bg-label-yellow-bg-strong/20 text-label-yellow-text";
}

View File

@ -6,19 +6,15 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// plane imports
import { ROLE, EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { TrashIcon, SuspendedUserIcon } from "@plane/propel/icons";
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { ChevronDownIcon, TrashIcon, SuspendedUserIcon } from "@plane/propel/icons";
import { Pill, EPillVariant, EPillSize } from "@plane/propel/pill";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser, IWorkspaceMember } from "@plane/types";
// plane ui
import { PopoverMenu } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
import { cn, getFileURL } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// hooks
import { useMember } from "@/hooks/store/use-member";
@ -43,83 +39,80 @@ type AccountTypeProps = {
workspaceSlug: string;
};
const WORKSPACE_ROLE_LABELS: Record<EUserPermissions, string> = {
[EUserPermissions.GUEST]: "Гость",
[EUserPermissions.MEMBER]: "Участник",
[EUserPermissions.ADMIN]: "Админ",
};
const WORKSPACE_ROLE_OPTIONS = [EUserPermissions.GUEST, EUserPermissions.MEMBER, EUserPermissions.ADMIN];
export const getWorkspaceRoleLabel = (role: EUserPermissions | undefined) =>
role ? (WORKSPACE_ROLE_LABELS[role] ?? "Не назначено") : "Не назначено";
export function NameColumn(props: NameProps) {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
const { rowData, workspaceSlug } = props;
// derived values
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
const isSuspended = rowData.is_active === false;
const fullName = [first_name, last_name].filter(Boolean).join(" ") || display_name || email;
return (
<Disclosure>
{() => (
<div className="group relative">
<div className="flex w-72 items-center justify-between gap-x-4 gap-y-2">
<div className="flex flex-1 items-center gap-x-2 gap-y-2">
{isSuspended ? (
<div className="rounded-full bg-layer-1">
<SuspendedUserIcon className="size-6 text-placeholder" />
</div>
) : avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full text-on-color capitalize">
<img
src={getFileURL(avatar_url)}
className="absolute top-0 left-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full bg-layer-3 text-11 text-tertiary capitalize">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
<span className={isSuspended ? "text-placeholder" : ""}>
{first_name} {last_name}
</span>
</div>
{!isSuspended && (isAdmin || id === currentUser?.id) && (
<PopoverMenu
data={[""]}
keyExtractor={(item) => item}
popoverClassName="justify-end"
buttonClassName="outline-none origin-center rotate-90 size-8 aspect-square flex-shrink-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity"
render={() => (
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-x-3"
onClick={() => setRemoveMemberModal(rowData)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setRemoveMemberModal(rowData);
}
}}
data-ph-element={MEMBER_TRACKER_ELEMENTS.WORKSPACE_MEMBER_TABLE_CONTEXT_MENU}
>
<TrashIcon className="size-3.5 align-middle" /> {id === currentUser?.id ? "Leave " : "Remove "}
</div>
)}
/>
)}
</div>
<div className="flex w-max min-w-[8.5rem] items-center gap-x-2 gap-y-2 pr-3">
{isSuspended ? (
<div className="rounded-full bg-layer-1">
<SuspendedUserIcon className="size-6 text-placeholder" />
</div>
) : avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full text-on-color capitalize">
<img
src={getFileURL(avatar_url)}
className="absolute top-0 left-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full bg-layer-3 text-11 text-tertiary capitalize">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
</Disclosure>
<span className={cn(isSuspended ? "text-placeholder" : "")}>{fullName}</span>
</div>
);
}
export function WorkspaceMemberActionsColumn(props: NameProps) {
const { rowData, isAdmin, currentUser, setRemoveMemberModal } = props;
const { display_name, email, id } = rowData.member;
const isSuspended = rowData.is_active === false;
const canRemoveMember = !isSuspended && (isAdmin || id === currentUser?.id);
const isCurrentUser = id === currentUser?.id;
const label = isCurrentUser ? "Покинуть workspace" : `Удалить ${display_name || email}`;
return (
<div className="flex w-12 justify-end">
{canRemoveMember && (
<button
aria-label={label}
className="nodedc-external-icon-button"
data-ph-element={MEMBER_TRACKER_ELEMENTS.WORKSPACE_MEMBER_TABLE_CONTEXT_MENU}
title={label}
type="button"
onClick={() => setRemoveMemberModal(rowData)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
export const AccountTypeColumn = observer(function AccountTypeColumn(props: AccountTypeProps) {
const { rowData, workspaceSlug } = props;
// form info
const {
control,
formState: { errors },
} = useForm();
// store hooks
const { allowPermissions } = useUserPermissions();
@ -133,56 +126,52 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
const isAdminRole = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const isRoleNonEditable = isCurrentUser || !isAdminRole;
const isSuspended = rowData.is_active === false;
const roleLabel = getWorkspaceRoleLabel(rowData.role);
return (
<>
{isSuspended ? (
<div className="flex w-32">
<div className="flex min-w-[9rem]">
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.SM} className="border-none">
Suspended
Заблокирован
</Pill>
</div>
) : isRoleNonEditable ? (
<div className="flex w-32">
<span>{ROLE[rowData.role]}</span>
<div className="nodedc-settings-chip flex min-h-10 min-w-[9rem] items-center px-4 py-2 text-caption-sm-medium">
<span className="truncate">{roleLabel}</span>
</div>
) : (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value } }) => (
<SelectionDropdown
options={Object.keys(ROLE).map((item) => ({
key: item,
title: ROLE[item as unknown as keyof typeof ROLE],
isChecked: String(value) === item,
onClick: async () => {
if (!workspaceSlug) return;
try {
await updateMember(workspaceSlug.toString(), rowData.member.id, {
role: item as unknown as EUserPermissions,
});
} catch (err: unknown) {
const error = err as { error?: string | string[] };
const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error;
<SelectionDropdown
dropdownClassName="!p-2"
dropdownContentClassName="!w-44"
options={WORKSPACE_ROLE_OPTIONS.map((role) => ({
key: String(role),
title: getWorkspaceRoleLabel(role),
isChecked: rowData.role === role,
onClick: async () => {
if (!workspaceSlug || rowData.role === role) return;
try {
await updateMember(workspaceSlug.toString(), rowData.member.id, { role });
} catch (err: unknown) {
const error = err as { error?: string | string[] };
const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
}
},
}))}
menuButton={
<div className="flex">
<span>{ROLE[rowData.role]}</span>
</div>
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: errorString ?? "Не удалось обновить роль участника. Попробуйте ещё раз.",
});
}
menuButtonWrapperClassName={`w-32 rounded-md p-0 !justify-start !px-0 hover:bg-surface-1 ${errors.role ? "border-danger-strong" : "border-none"}`}
/>
},
}))}
menuButton={({ open }) => (
<div className="nodedc-settings-chip flex min-h-10 min-w-[9rem] items-center justify-between gap-2 px-4 py-2 text-caption-sm-medium">
<span className="truncate">{roleLabel}</span>
<ChevronDownIcon className={cn("h-3 w-3 transition-transform", open ? "rotate-180" : "")} />
</div>
)}
menuButtonWrapperClassName="flex rounded-[1.25rem] border-0 outline-none"
placement="bottom-end"
/>
)}
</>

View File

@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
if (isEmpty(columns)) return <MembersLayoutLoader />;
return (
<div className="grid overflow-hidden rounded-[1.2rem]">
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]">
{removeMemberModal && (
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal.member.id.length > 0}
@ -109,6 +109,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"

View File

@ -53,7 +53,8 @@ export const WorkspaceMembersList = observer(function WorkspaceMembersList(props
await fetchWorkspaceMemberInvitations(workspaceSlug.toString());
await fetchWorkspaceMembers(workspaceSlug.toString());
}
: null
: null,
{ refreshInterval: 5000 }
);
if (!workspaceMemberIds && !workspaceMemberInvitationIds) return <MembersSettingsLoader />;

View File

@ -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(
@ -51,8 +59,11 @@ export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMem
setInviteModal(false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: t("workspace_settings.settings.members.invitations_sent_successfully"),
title: nodedcWorkspacePolicy?.invite_approval === "nodedc" ? "Запрос отправлен" : "Success!",
message:
nodedcWorkspacePolicy?.invite_approval === "nodedc"
? "Приглашение отправлено на подтверждение NODE.DC."
: t("workspace_settings.settings.members.invitations_sent_successfully"),
});
} catch (error: unknown) {
const message = error instanceof Error ? (error as Error & { error?: string }).error : undefined;
@ -79,6 +90,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

View File

@ -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)
);

View File

@ -8,6 +8,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import type { MouseEvent } from "react";
import useSWR from "swr";
import { Archive, BarChart3, Layers3, Settings, UserPlus } from "lucide-react";
import { Menu } from "@headlessui/react";
// plane imports
@ -18,9 +19,12 @@ import type { IWorkspace } from "@plane/types";
import { cn, getFileURL, getUserRole } from "@plane/utils";
// components
import { openWorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal.utils";
import { WorkspaceService } from "@/services/workspace.service";
// plane web imports
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
const workspaceService = new WorkspaceService();
type TProps = {
workspace: IWorkspace;
activeWorkspace: IWorkspace | null;
@ -35,8 +39,13 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
const router = useRouter();
// hooks
const { t } = useTranslation();
const { data: nodedcWorkspacePolicy } = useSWR(
workspace.id === activeWorkspace?.id ? `NODEDC_WORKSPACE_POLICY_${workspace.slug}` : null,
() => workspaceService.getNodeDCWorkspacePolicy(workspace.slug)
);
const canOpenWorkspaceSettings = [EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role);
const canInviteMembers = [EUserPermissions.ADMIN].includes(workspace?.role);
const canInviteMembers =
[EUserPermissions.ADMIN].includes(workspace?.role) && nodedcWorkspacePolicy?.managed_by === "tasker";
const handleWorkspaceAction = (e: MouseEvent<HTMLButtonElement>, action: () => void) => {
e.preventDefault();

View File

@ -32,6 +32,7 @@ import { DEFAULT_TAB_KEY, getTabUrl } from "@/components/navigation/tab-navigati
import { useTabPreferences } from "@/components/navigation/use-tab-preferences";
import { LeaveProjectModal } from "@/components/project/leave-project-modal";
import { PublishProjectModal } from "@/components/project/publish-project/modal";
import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
@ -175,7 +176,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
label: t("settings"),
icon: <Settings className="h-3.5 w-3.5 stroke-[1.5]" />,
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${project?.id}`);
if (project?.id) openProjectSettingsModal(project.id);
},
},
!isAuthorized

View File

@ -8,6 +8,7 @@ import { Fragment, useEffect, useCallback, useLayoutEffect, useRef, useState, ty
import { observer } from "mobx-react";
import Link from "next/link";
import { createPortal } from "react-dom";
import useSWR from "swr";
// icons
import { CirclePlus, LogOut, Mails } from "lucide-react";
// ui
@ -21,6 +22,7 @@ import { Loader } from "@plane/ui";
import { orderWorkspacesList, cn } from "@plane/utils";
// helpers
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { WorkspaceService } from "@/services/workspace.service";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useWorkspace } from "@/hooks/store/use-workspace";
@ -30,6 +32,8 @@ import { useInstance } from "@/hooks/store/use-instance";
import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
const workspaceService = new WorkspaceService();
type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar" | "expanded-toolbar";
};
@ -98,8 +102,12 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
const { signOut } = useUser();
const { updateUserProfile } = useUserProfile();
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
const { data: nodedcWorkspacePolicy } = useSWR(currentUser ? "NODEDC_WORKSPACE_POLICY" : null, () =>
workspaceService.getNodeDCWorkspacePolicy()
);
// derived values
const isWorkspaceCreationDisabled = config?.is_workspace_creation_disabled ?? false;
const canCreateWorkspace = !isWorkspaceCreationDisabled && nodedcWorkspacePolicy?.can_create_workspace === true;
// translation
const { t } = useTranslation();
// local state
@ -312,7 +320,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
)}
</div>
<div className="flex w-full flex-col items-start justify-start gap-2 px-4 py-2 text-13">
{!isWorkspaceCreationDisabled && (
{canCreateWorkspace && (
<Link href="/create-workspace" className="w-full">
<Menu.Item
as="div"

View File

@ -17,6 +17,9 @@ import { useFileSize } from "@/plane-web/hooks/use-file-size";
import { FileService } from "@/services/file.service";
const fileService = new FileService();
const isHttpUrl = (path?: string) => !!path && /^https?:\/\//i.test(path);
const isPublicPath = (path?: string) => !!path && path.startsWith("/") && !path.startsWith("//");
type TArgs = {
projectId?: string;
uploadFile: TFileHandler["upload"];
@ -44,7 +47,10 @@ export const useEditorConfig = () => {
return res?.exists ?? false;
},
delete: async (src: string) => {
if (src?.startsWith("http")) {
if (isPublicPath(src)) {
return;
}
if (isHttpUrl(src)) {
await fileService.deleteOldWorkspaceAsset(workspaceId, src);
} else {
await fileService.deleteNewAsset(
@ -58,7 +64,7 @@ export const useEditorConfig = () => {
},
getAssetDownloadSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
if (isHttpUrl(path) || isPublicPath(path)) {
return path;
} else {
return (
@ -72,7 +78,7 @@ export const useEditorConfig = () => {
},
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
if (isHttpUrl(path) || isPublicPath(path)) {
return path;
} else {
return (
@ -85,14 +91,17 @@ export const useEditorConfig = () => {
}
},
restore: async (src: string) => {
if (src?.startsWith("http")) {
if (isPublicPath(src)) {
return;
}
if (isHttpUrl(src)) {
await fileService.restoreOldEditorAsset(workspaceId, src);
} else {
await fileService.restoreNewAsset(workspaceSlug, src);
}
},
upload: uploadFile,
duplicate: duplicateFile,
duplicate: async (assetId: string) => (isPublicPath(assetId) ? assetId : duplicateFile(assetId)),
validation: {
maxFileSize,
},

View File

@ -39,6 +39,18 @@ export interface NodeDCWorkspacePolicy {
enabled: boolean;
can_create_workspace: boolean;
mode: string;
managed_by: "launcher" | "tasker";
default_managed_by: "launcher" | "tasker";
invite_approval: "tasker" | "nodedc" | "launcher" | "disabled";
default_invite_approval: "tasker" | "nodedc" | "launcher" | "disabled";
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 +114,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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -2094,6 +2094,27 @@
-webkit-backdrop-filter: blur(18px);
}
.nodedc-settings-table-surface,
.nodedc-settings-table-sticky {
background: rgb(36, 36, 38) !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.nodedc-settings-table-sticky {
position: sticky;
}
.nodedc-settings-table-sticky::after {
content: "";
position: absolute;
top: 0;
right: -1px;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.045);
}
.nodedc-settings-sidebar-shell {
border: 0 !important;
outline: none !important;
@ -5576,6 +5597,20 @@
color: var(--text-color-primary) !important;
}
.editor-callout-component {
border: 0 !important;
outline: none !important;
box-shadow:
inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.018),
0 10px 28px rgba(0, 0, 0, 0.08) !important;
border-radius: 1.35rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.025) !important;
color: var(--text-color-secondary) !important;
}
.nodedc-attachment-upload[data-drag-reject="true"] {
box-shadow:
inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.4),

View File

@ -32,11 +32,14 @@ export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) {
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// derived values
const activeBackgroundColor = COLORS_LIST.find((c) => node.attrs["data-background"] === c.key)?.backgroundColor;
const isLogoHidden = node.attrs["data-logo-in-use"] === "none";
return (
<NodeViewWrapper
key={node.attrs[ECalloutAttributeNames.ID]}
className="editor-callout-component group/callout-node relative my-2 flex items-start gap-4 rounded-lg bg-layer-3 p-4 break-words text-primary transition-colors duration-500"
className={`editor-callout-component group/callout-node relative my-2 flex items-start rounded-lg bg-layer-3 p-4 break-words text-primary transition-colors duration-500 ${
isLogoHidden ? "gap-0" : "gap-4"
}`}
style={{
backgroundColor: activeBackgroundColor,
}}

View File

@ -56,11 +56,11 @@ export const CustomCalloutExtensionConfig: CustomCalloutExtensionType = Node.cre
state.write(
`> <img src="${attrs["data-emoji-url"]}" alt="${attrs["data-emoji-unicode"]}" width="30px" />\n`
);
} else {
state.write("> \n");
} else if (logoInUse === "icon") {
state.write(`> <icon>${attrs["data-icon-name"]} icon</icon>\n`);
state.write("> \n");
}
// add an empty line after the logo
state.write("> \n");
// add '> ' before each line of the callout content
state.wrapBlock("> ", null, node, () => state.renderContent(node));
state.closeBlock(node);

View File

@ -24,6 +24,8 @@ type Props = {
export function CalloutBlockLogoSelector(props: Props) {
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
if (blockAttributes["data-logo-in-use"] === "none") return null;
const logoValue: TLogoProps = {
in_use: blockAttributes["data-logo-in-use"],
icon: {

View File

@ -29,7 +29,7 @@ export type TCalloutBlockEmojiAttributes = {
export type TCalloutBlockAttributes = {
[ECalloutAttributeNames.ID]: string | null;
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon" | "none";
[ECalloutAttributeNames.BACKGROUND]: string | undefined;
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";
} & TCalloutBlockIconAttributes &

View File

@ -49,10 +49,14 @@ export interface IWorkspaceMemberInvitation {
email: string;
id: string;
message: string;
nodedc_approval_status?: "not_required" | "pending" | "approved" | "rejected";
nodedc_approval_request_id?: string | null;
nodedc_approval_decided_at?: Date | null;
nodedc_platform_invite_link?: string | null;
responded_at: Date;
role: TUserPermissions;
token: string;
invite_link: string;
invite_link: string | null;
workspace: {
id: string;
logo_url: string;

View File

@ -6,11 +6,27 @@
import { Dialog, Transition } from "@headlessui/react";
import React, { Fragment } from "react";
import { createPortal } from "react-dom";
// constants
import { cn } from "../utils";
import { EModalPosition, EModalWidth } from "./constants";
// helpers
const MODAL_PORTAL_ID = "nodedc-modal-portal";
const ensureModalPortalRoot = () => {
if (typeof document === "undefined") return null;
const existingPortalRoot = document.getElementById(MODAL_PORTAL_ID);
if (existingPortalRoot) return existingPortalRoot;
const portalRoot = document.createElement("div");
portalRoot.id = MODAL_PORTAL_ID;
document.body.appendChild(portalRoot);
return portalRoot;
};
type Props = {
children: React.ReactNode;
handleClose?: () => void;
@ -29,9 +45,41 @@ export function ModalCore(props: Props) {
className = "",
} = props;
return (
const [portalRoot, setPortalRoot] = React.useState<HTMLElement | null>(null);
const skipNextCloseRef = React.useRef(false);
const skipNextCloseTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
setPortalRoot(ensureModalPortalRoot());
return () => {
if (skipNextCloseTimeoutRef.current) clearTimeout(skipNextCloseTimeoutRef.current);
};
}, []);
const guardContextMenuClose = () => {
skipNextCloseRef.current = true;
if (skipNextCloseTimeoutRef.current) clearTimeout(skipNextCloseTimeoutRef.current);
skipNextCloseTimeoutRef.current = setTimeout(() => {
skipNextCloseRef.current = false;
skipNextCloseTimeoutRef.current = null;
}, 300);
};
const handleDialogClose = () => {
if (skipNextCloseRef.current) {
skipNextCloseRef.current = false;
return;
}
handleClose?.();
};
const modal = (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-30" onClose={() => handleClose && handleClose()}>
<Dialog as="div" className="relative z-[180]" data-prevent-outside-click onClose={handleDialogClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@ -56,6 +104,16 @@ export function ModalCore(props: Props) {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
onContextMenuCapture={(event) => {
event.stopPropagation();
guardContextMenuClose();
}}
onPointerDownCapture={(event) => {
if (event.button === 2) {
event.stopPropagation();
guardContextMenuClose();
}
}}
className={cn(
"nodedc-glass-modal relative w-full transform rounded-[28px] text-left transition-all",
width,
@ -70,4 +128,8 @@ export function ModalCore(props: Props) {
</Dialog>
</Transition.Root>
);
if (typeof document !== "undefined" && !portalRoot) return null;
return portalRoot ? createPortal(modal, portalRoot) : modal;
}

View File

@ -29,7 +29,7 @@ export function Table<T>(props: TTableData<T>) {
<thead className={cn("divide-y divide-subtle", tHeadClassName)}>
<tr className={cn("divide-x divide-subtle text-13 text-primary", tHeadTrClassName)}>
{columns.map((column) => (
<th key={column.key} className={cn("px-2.5 py-2", thClassName)}>
<th key={column.key} className={cn("px-2.5 py-2", thClassName, column.thClassName)}>
{(column?.thRender && column?.thRender()) || column.content}
</th>
))}
@ -42,7 +42,14 @@ export function Table<T>(props: TTableData<T>) {
className={cn("divide-x divide-subtle text-13 text-secondary", tBodyTrClassName)}
>
{columns.map((column) => (
<td key={`${column.key}-${keyExtractor(item)}`} className={cn("px-2.5 py-2", tdClassName)}>
<td
key={`${column.key}-${keyExtractor(item)}`}
className={cn(
"px-2.5 py-2",
tdClassName,
typeof column.tdClassName === "function" ? column.tdClassName(item) : column.tdClassName
)}
>
{column.tdRender(item)}
</td>
))}

View File

@ -4,11 +4,15 @@
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
export type TTableColumn<T> = {
key: string;
content: string;
thRender?: () => React.ReactNode;
tdRender: (rowData: T) => React.ReactNode;
content: ReactNode;
thClassName?: string;
thRender?: () => ReactNode;
tdClassName?: string | ((rowData: T) => string);
tdRender: (rowData: T) => ReactNode;
};
export type TTableData<T> = {

View File

@ -20,6 +20,7 @@ WORKSPACE_SLUG = "nodedc"
PROJECT_IDENTIFIER = "NDCPLATFORM"
PROJECT_NAME = "NDC platform"
CODEX_EMAIL = "codex@nodedc.local"
PLATFORM_OWNER_EMAIL = "dcctouch@gmail.com"
STATE_TEMPLATES = [
{"group": "backlog", "name": "В обсуждении", "color": "#60646C", "default": True},
@ -595,6 +596,29 @@ server/control-plane-store.mjs переведен на atomic write: запис
Ограничение: email/password/profile update уже работает как dev-flow, но production UX для reset-password/invite-email и подтверждения смены email еще нужно вынести в отдельный полноценный этап.
""",
),
text_block(
"launcher",
"Актуализация 2026-05-09",
"""
За последние итерации Launcher фактически стал текущим control-plane для корпоративного контура NODE.DC. Через него уже проверяются: клиенты, участники, группы, инвайты, сервисный каталог, матрица доступов, Operational Core workspace binding, user profile, client avatar, fullscreen admin panels и service-specific role modal для Operational Core.
Зафиксирован продуктовый принцип: enterprise-контур управляется через Launcher. Для таких пользователей и workspace Launcher является source of truth по приглашениям, сервисному доступу и базовым ролям; downstream-приложения получают техническую проекцию и не должны становиться вторым независимым источником прав.
Отдельно вынесена новая большая карточка "Публичный контур пользователей". Она закрывает сценарий обычных внешних пользователей, которые приходят не через клиентскую компанию, а через запрос приглашения и дальше используют отдельные сервисы как standalone-продукты.
""",
),
checker(
"launcher-actual-20260509",
"Чекер актуализации 2026-05-09",
[
{"text": "Зафиксировать Launcher как source of truth для enterprise-доступов.", "checked": True},
{"text": "Зафиксировать Operational Core role modal как текущую модель детальных назначений.", "checked": True},
{"text": "Отделить public/self-service контур в самостоятельную карточку.", "checked": True},
"Заменить JSON-backed store на production persistence.",
"Описать recovery/MFA/email-change UX без раскрытия Authentik UI.",
"Вынести billing/limits в отдельный RFC после стабилизации public-контура.",
],
),
],
},
{
@ -867,6 +891,575 @@ Plane должен оставаться самостоятельным прод
"Запретить прямые записи Launcher в Plane DB.",
],
),
text_block(
"plane",
"Актуализация 2026-05-09",
"""
Текущая модель Operational Core разделена на два режима. Для workspace, которыми управляет Launcher, участники/инвайты/права должны быть скрыты или переведены в readonly внутри Task Manager, чтобы не получить конфликтующий source of truth. Для standalone/public workspace штатные механики Task Manager остаются включенными: пользователь может создавать workspace, приглашать участников и управлять проектами внутри продукта.
Практически проверено: корпоративные назначения из Launcher доходят до Operational Core, public-пользователь может создать workspace после выдачи доступа, а прямой доступ к сервису проходит через NODE.DC SSO. Safari-only падение workspace зафиксировано как отдельный deferred debug, потому что Chrome/Chromium flow работает и проблема не должна блокировать платформенную обвязку.
Развилка 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(
"plane-actual-20260509",
"Чекер актуализации 2026-05-09",
[
{"text": "Сохранить автономность Task Manager как standalone-продукта.", "checked": True},
{"text": "Зафиксировать managedBy=launcher для enterprise workspace.", "checked": True},
{"text": "Зафиксировать managedBy=tasker для standalone/public workspace.", "checked": True},
{"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.",
],
),
],
},
{
"slug": "design-guide-canon",
"name": "NDC дизайн-гайд: единый UI-код платформы",
"priority": "medium",
"state_group": "cancelled",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Карточка сохранена как отложенная архитектурная ссылка: реальный дизайн-канон NODE.DC живет в HDESIGN-CODE.md, а не в теле канбан-карточки.",
"Цель актуализации: убрать конфликт источников истины и зафиксировать, что дальнейшие UI-работы должны сверяться с документом дизайн-канона и существующими launcher/tasker компонентами.",
"Статус: deferred. Карточка не является активной задачей разработки до решения выделять дизайн-систему в отдельный пакет.",
),
"blocks": [
text_block(
"design-guide",
"Текущая архитектура",
"""
Единый визуальный канон NODE.DC зафиксирован в репозитории Operational Core в документе HDESIGN-CODE.md. Он описывает glass/card shell, top-bar, admin overlay, auth screens, spacing, typography, motion, token usage и правила интеграции в Launcher/Task Manager.
Карточка NDCPLATFORM-7 больше не должна конкурировать с этим документом и не должна хранить длинный HTML-дизайн-гайд. Канбан-карточка остается навигационной отметкой: дизайн-канон существует, но вынос в отдельную design-system работу пока отложен.
""",
),
text_block(
"design-guide",
"Этап 1. Зафиксировать источник дизайн-канона",
"""
Статус: выполнено.
HDESIGN-CODE.md является source of truth для текущего UI-кода NODE.DC. При разработке Launcher/admin overlay, auth screens и Task Manager NODE.DC-слоев сверяемся с ним, а не с историческим HTML-телом карточки.
""",
),
checker(
"design-guide1",
"Чекер этапа 1. Источник дизайн-канона",
[
{"text": "Признать HDESIGN-CODE.md реальным дизайн-каноном.", "checked": True},
{"text": "Оставить NDCPLATFORM-7 в deferred/cancelled, без активной разработки.", "checked": True},
{"text": "Не использовать старое HTML-тело карточки как актуальный UI-спек.", "checked": True},
],
),
text_block(
"design-guide",
"Реализация этапа 1",
"""
Карточка приведена к короткой structured layout записи. Исторический смысл сохранен, но актуальный дизайн-канон вынесен в документ репозитория, чтобы UI-решения не расходились между карточкой, кодом и документацией.
""",
),
text_block(
"design-guide",
"Этап 2. Future design-system extraction",
"""
Статус: backlog/deferred.
Если появится необходимость переиспользовать NODE.DC UI между несколькими приложениями как библиотеку, эту работу нужно открывать отдельным этапом внутри той же крупной карточки: tokens, primitives, surfaces, modals, cards, auth shell и cross-app visual QA.
""",
),
checker(
"design-guide2",
"Чекер этапа 2. Future design-system extraction",
[
"Принять решение, нужен ли отдельный design-system package.",
"Выделить переиспользуемые primitives без ломки Launcher/Tasker UI.",
"Описать visual QA для Auth/Launcher/Tasker экранов.",
],
),
],
},
{
"slug": "tasker-provisioning-workspace-onboarding",
"name": "Tasker provisioning и workspace onboarding",
"priority": "high",
"state_group": "started",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Архитектурный узел между Launcher control plane и Operational Core: пользователь должен попадать в Tasker через NODE.DC SSO без redirect-loop, 500-страниц и ручных Plane-инвайтов.",
"Цель: разделить доступ к приложению, workspace onboarding и доменные роли Tasker. Launcher выдает сервисный доступ и enterprise-назначения, Tasker сохраняет собственные workspace/project/task модели и standalone-режим.",
"Критерий приемки: пользователь с доступом к Operational Core получает предсказуемый исход: назначенный workspace/project, разрешенный self-service workspace или ожидание назначения; для managedBy=launcher нет второго источника прав внутри Tasker.",
),
"blocks": [
text_block(
"tasker-provisioning",
"Текущая архитектура",
"""
Launcher уже является рабочим control plane для клиентов, пользователей, групп, инвайтов, service grants, Operational Core workspace binding и project-level назначений. Authentik получает техническую group projection из Launcher и остается внутренним IdP.
Operational Core подключен как Plane fork: OIDC/handoff, ExternalIdentityLink, live access middleware, workspace policy hook и internal adapter endpoints живут в Tasker, а Launcher вызывает их через защищенный server-to-server API. Прямых записей Launcher в Plane DB нет.
Фактически реализованы: auto-create/link локального Tasker user по verified OIDC/handoff claims, workspace membership bridge, project membership bridge, policy check на создание workspace, NODE.DC create-workspace UX и очистка stale assignees после снятия пользователей из workspace/project.
Открытая архитектурная граница: нужно формально закрепить managedBy=launcher/managedBy=tasker для workspace, чтобы enterprise-workspace управлялись из Launcher, а standalone/public workspace сохраняли штатные Tasker users/invites/admin mechanics.
""",
),
text_block(
"tasker-provisioning",
"Этап 1. OIDC fail-safe и local user/link provisioning",
"""
Статус: реализовано.
Этап закрывает патологию первого входа: Authentik подтверждает пользователя, но Tasker не знает локального User/ExternalIdentityLink. Сейчас Tasker умеет idempotently создать или связать локального пользователя по verified OIDC/handoff claims и не уводит пользователя в бесконечный login loop.
""",
),
checker(
"tasker-provisioning1",
"Чекер этапа 1. OIDC fail-safe и local user/link provisioning",
[
{"text": "Развести ошибки: нет platform access, нет local user/link, нет workspace membership.", "checked": True},
{"text": "Добавить auto-link existing Plane user по email отдельным env-флагом.", "checked": True},
{"text": "Добавить auto-create Tasker user по verified OIDC claims отдельным env-флагом.", "checked": True},
{"text": "Создавать/обновлять ExternalIdentityLink provider=authentik + subject.", "checked": True},
{"text": "Синхронизировать display name/email/avatar без пересоздания старого Plane user.", "checked": True},
{"text": "Сохранить standalone Plane auth/API механизмы вне NODE.DC env-профиля.", "checked": True},
],
),
text_block(
"tasker-provisioning",
"Реализация этапа 1",
"""
Tasker OIDC слой живет в plane-src/apps/api/plane/authentication/views/app/oidc.py. Он проверяет state/nonce/JWKS/audience/issuer, принимает groups, резолвит или создает локального пользователя и обновляет ExternalIdentityLink.
Launcher service handoff ведет в /auth/nodedc/handoff/ и потребляется через защищенный /api/internal/handoff/consume. Handoff передает normalized user, avatarUrl и groups без раскрытия пароля или Authentik service token.
Профильная синхронизация идет из claims и Launcher avatar URL. Существующий Plane owner dcctouch@gmail.com не пересоздается: связь держится через ExternalIdentityLink и старый Plane user сохраняет workspace/project/task связи.
""",
),
text_block(
"tasker-provisioning",
"Этап 2. Launcher -> Tasker workspace adapter",
"""
Статус: реализовано.
Этап добавляет управляемый bridge для enterprise workspace: Launcher может назначить или снять пользователя в Operational Core workspace через internal Tasker API, а Tasker idempotently меняет WorkspaceMember без Plane email-invite.
""",
),
checker(
"tasker-provisioning2",
"Чекер этапа 2. Launcher -> Tasker workspace adapter",
[
{"text": "Добавить Tasker internal workspace catalog endpoint.", "checked": True},
{"text": "Добавить internal endpoint ensure workspace membership.", "checked": True},
{"text": "Добавить internal endpoint remove workspace membership.", "checked": True},
{"text": "Защитить endpoints внутренним token/secret.", "checked": True},
{"text": "Добавить Launcher admin routes для workspace membership.", "checked": True},
{"text": "Сохранять workspace membership projection в Launcher storage.", "checked": True},
{"text": "Не использовать Plane email-invite как основной enterprise flow.", "checked": True},
],
),
text_block(
"tasker-provisioning",
"Реализация этапа 2",
"""
Tasker adapter endpoints лежат в plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py и подключены в plane-src/apps/api/plane/urls.py. Они резолвят workspace/user по slug/id/email/subject, создают или деактивируют WorkspaceMember и возвращают normalized membership summary.
Launcher BFF вызывает adapter из server/dev-server.mjs, проверяет права администратора на client/user, пишет projection через server/control-plane-store.mjs и обновляет UI через admin overlay.
""",
),
text_block(
"tasker-provisioning",
"Этап 3. Workspace onboarding policy",
"""
Статус: частично реализовано.
Сервисный доступ к Operational Core и право создавать workspace разделены. Launcher уже возвращает workspacePolicy через internal access check, а Tasker спрашивает policy перед create-workspace flow. Полная трехрежимная модель client/user/group policy еще не оформлена как production data model.
""",
),
checker(
"tasker-provisioning3",
"Чекер этапа 3. Workspace onboarding policy",
[
{"text": "Добавить Launcher setting taskManager.workspaceCreationPolicy.", "checked": True},
{"text": "Возвращать workspacePolicy из /api/internal/access/check.", "checked": True},
{"text": "Добавить Tasker backend workspace policy resolver.", "checked": True},
{"text": "Сохранить self-service create-workspace flow в NODE.DC дизайне.", "checked": True},
"Оформить admin_managed ожидание назначения без create workspace.",
"Добавить production policy model на уровне client/service/user/group.",
"Связать policy с managedBy=launcher/managedBy=tasker.",
],
),
text_block(
"tasker-provisioning",
"Реализация этапа 3",
"""
Launcher хранит базовую настройку workspace creation policy в settings.taskManager. Tasker использует plane.authentication.nodedc_workspace_policy, чтобы получить решение из Launcher access-check endpoint и разрешить или запретить создание workspace.
Create workspace UI в Tasker приведен к NODE.DC auth-card layout без удаления штатной Plane формы: вариант nodedc-auth сохраняет standalone совместимость и не ломает остальные вызовы.
""",
),
text_block(
"tasker-provisioning",
"Этап 4. Project-level доступы Operational Core из Launcher",
"""
Статус: реализовано локально, ожидает ручной acceptance.
Этап закрывает следующий слой после workspace binding: Launcher может назначать роль пользователя внутри конкретного проекта Tasker. Это остается adapter-вызовом через internal API, а не прямой записью в Plane DB.
""",
),
checker(
"tasker-provisioning4",
"Чекер этапа 4. Project-level access bridge",
[
{"text": "Расширить Tasker workspace catalog проектами внутри workspace.", "checked": True},
{"text": "Добавить internal endpoint ensure project membership.", "checked": True},
{"text": "Добавить internal endpoint remove project membership.", "checked": True},
{"text": "Сохранять project membership projection в Launcher storage.", "checked": True},
{"text": "Показать workspaces и projects в Operational Core modal.", "checked": True},
{"text": "При project role гарантировать минимальный workspace membership.", "checked": True},
"Провести ручной acceptance: назначение роли проекта, refresh, вход пользователем в Tasker.",
],
),
text_block(
"tasker-provisioning",
"Реализация этапа 4",
"""
Tasker: добавлены endpoints /api/internal/nodedc/project-memberships/ensure/ и /api/internal/nodedc/project-memberships/remove/. Они защищены тем же internal token, резолвят workspace/project/user и idempotently создают или обновляют ProjectMember.
Launcher: добавлены admin routes для project memberships, control-plane projection taskManagerProjectMemberships и UI в Operational Core modal. Роль проекта меняется кликом: прочерк, гость, участник, админ.
Проверки этапа ранее проходили: npm run build в Launcher, node --check server/dev-server.mjs server/control-plane-store.mjs и python compile для Tasker adapter/routes.
""",
),
text_block(
"tasker-provisioning",
"Этап 5. Stale assignees cleanup после снятия пользователей",
"""
Статус: реализовано и закоммичено.
После удаления, блокировки или снятия пользователя из workspace/project Tasker не должен продолжать показывать его исполнителем в карточках и группировках. Последняя локальная правка удаляет IssueAssignee на membership remove и фильтрует assignee arrays только по активным workspace/project membership.
""",
),
checker(
"tasker-provisioning5",
"Чекер этапа 5. Stale assignees cleanup",
[
{"text": "Удалять IssueAssignee при снятии workspace membership.", "checked": True},
{"text": "Удалять IssueAssignee при снятии project membership.", "checked": True},
{"text": "Покрыть admin/license/member remove paths.", "checked": True},
{"text": "Фильтровать backend assignee_ids по active workspace/project membership.", "checked": True},
{"text": "Добавить frontend guard в internal Kanban card.", "checked": True},
{"text": "Проверить текущую БД на реально stale IssueAssignee.", "checked": True},
"Прогнать целевой regression по issue list/kanban после restart backend/web.",
],
),
text_block(
"tasker-provisioning",
"Реализация этапа 5",
"""
Затронуты Tasker файлы: plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py, workspace/project/member/license views, issue list/detail endpoints, common grouper utilities и internal-contour-card.tsx.
Проверка БД 2026-05-09: запрос по активным IssueAssignee без active WorkspaceMember/ProjectMember вернул 0 записей. Значит текущий runtime не содержит реально stale assignee links после последней очистки.
Этап закоммичен отдельным изменением 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 механизмы пользователей, инвайтов и ролей остаются включенными.
""",
),
checker(
"tasker-provisioning6",
"Чекер этапа 6. Source-of-truth split managedBy",
[
{"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 отдельно.",
{"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, новых ошибок в измененных файлах не выявлено.
""",
),
],
},
{
"slug": "safari-workspace-crash-debug",
"name": "Safari: workspace crash и storage/OIDC debug",
"priority": "high",
"state_group": "cancelled",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Отложенный debug Safari-only падения workspace в Task Manager. Проблема не блокирует текущую платформенную архитектуру, потому что Chrome/Chromium flow работает.",
"Цель актуализации: сохранить симптом и границы диагностики, но не смешивать browser-specific bug с Launcher/Auth/Tasker source-of-truth работами.",
"Статус: deferred. Возвращаться после закрытия критичного managedBy/source-of-truth этапа или при воспроизводимом Safari regression report.",
),
"blocks": [
text_block(
"safari-crash",
"Текущая архитектура",
"""
Safari-only падение workspace относится к browser/runtime compatibility слою Task Manager. Оно не меняет целевую архитектуру: Authentik остается внутренним IdP, Launcher source of truth для enterprise-доступов, Task Manager standalone-capable Operational Core module.
Пока Chrome/Chromium flow работает, этот debug не должен блокировать платформенную работу по access matrix, workspace onboarding и managedBy split. Карточка остается отложенной, чтобы не смешивать runtime browser bug с архитектурными задачами.
""",
),
text_block(
"safari-crash",
"Этап 1. Сохранить симптом и границы debug",
"""
Статус: зафиксировано как deferred.
Нужно вернуться к карточке только при наличии воспроизводимого сценария: Safari version, URL, user, workspace slug, console trace, network trace, storage/cookie state и отличие от Chrome на том же аккаунте.
""",
),
checker(
"safari1",
"Чекер этапа 1. Deferred symptom",
[
{"text": "Не считать Safari-only падение блокером Launcher/Auth/Tasker архитектуры.", "checked": True},
{"text": "Сохранить карточку как отдельный debug bucket.", "checked": True},
"Снять Safari console trace.",
"Снять Safari network trace.",
"Сравнить localStorage/sessionStorage/cookies Safari vs Chrome.",
],
),
text_block(
"safari-crash",
"Этап 2. Future Safari diagnostics",
"""
Статус: deferred.
Будущий debug должен идти от воспроизводимого crash-path: auth/session sync, workspace bootstrap API, storage hydration, frontend route boundary, browser-specific cookie policy или WebKit-only JS/runtime issue.
""",
),
checker(
"safari2",
"Чекер этапа 2. Safari diagnostics",
[
"Воспроизвести падение на актуальном Safari.",
"Отделить auth/session issue от frontend runtime crash.",
"Проверить WebKit cookie/storage policy на task.local.nodedc.",
"Сформулировать минимальный фикс без изменения общей архитектуры.",
],
),
],
},
{
"slug": "public-user-entry-and-service-access",
"name": "Публичный контур пользователей",
"priority": "high",
"state_group": "backlog",
"assignees": [PLATFORM_OWNER_EMAIL, CODEX_EMAIL],
"description_html": html(
"Отдельный public/open-access контур для внешних пользователей, которые приходят не как участники клиентской компании, а как самостоятельные пользователи сервисов NODE.DC.",
"Цель: не плодить новый админский интерфейс, а расширить существующий Launcher control plane. Enterprise-компании остаются company-scoped, public users попадают в отдельный Public Access Pool и получают сервисные доступы вручную через root-admin flow.",
"Критерий приемки: пользователь может нажать Запросить доступ в Auth/Login, заявка появляется в Launcher в разделе Инвайты как вкладка Заявки, root admin вручную апрувит/отклоняет, генерирует ссылку, а после входа пользователь видит только витрину и свои разрешенные сервисы.",
),
"blocks": [
text_block(
"public-users",
"Текущая архитектура",
"""
Уже есть единый Authentik login, branded под NODE.DC, и Launcher как точка входа. Launcher умеет показывать витрину приложений, хранить клиентов, пользователей, инвайты, группы, матрицу доступов и привязку Operational Core workspace к клиенту.
Enterprise-сценарий частично реализован: root admin создает компанию, добавляет туда client admin, выдает доступы, а client admin дальше управляет пользователями, группами, инвайтами и назначениями в рамках своей компании через Launcher. В Operational Core такие пользователи не должны видеть управление users/invites/workspace creation для launcher-managed workspace; они работают только в среде, которую им задал админ.
Public/open-access контур должен быть отдельной группой пользователей, не привязанной к клиентской компании. Рабочее название: Public Access Pool / Свободный доступ. Это не новый enterprise-client и не хаотичный список отдельных компаний, а отдельный cohort в Launcher, видимый root admin. Public user не получает Launcher admin overlay, пока ему явно не выдали роль; он видит витрину и сервисы.
Глобальная identity должна быть одна на email/Auth subject. Если пользователь сначала попал в Public Access Pool, а потом его надо перевести в enterprise-компанию, правильная операция не удаление и повторная регистрация, а административный перевод: создать company membership, выбрать роль/группы, при необходимости убрать public-cohort marker и пересчитать доступы.
Также появился standalone-сценарий Operational Core: public-пользователь после выдачи доступа к сервису может создать собственный workspace и управлять им через штатный Task Manager, но только для workspace managedBy=tasker. Создание NODE.DC identity и первичная выдача сервисного доступа остаются в Launcher.
Еще не реализованы: кнопка Запросить доступ в login flow, модель access requests с обязательными полями, вкладка Заявки рядом с текущими Инвайтами, Public Access Pool в Launcher admin, перевод public user в компанию, правила приглашения других людей в public Tasker workspace без почтовой инфраструктуры.
""",
),
text_block(
"public-users",
"Этап 1. Разделение enterprise и Public Access Pool",
"""
Статус: backlog.
Нужно формально разделить два режима: enterprise/direct-contract company scope и public/open-access pool. Enterprise-контур остается текущей реализацией: root admin создает компанию, назначает client admin, client admin управляет только своей компанией через Launcher.
Public Access Pool отдельный cohort для внешних самостоятельных пользователей. Он должен быть доступен root admin в существующем Launcher admin UI как отдельный режим/область рядом с компаниями, но не должен засорять список компаний сотнями псевдо-клиентов.
Главное правило безопасности: у каждого Operational Core workspace должен быть ровно один источник управления правами. managedBy=launcher означает управление из Launcher и readonly/hidden member management в Tasker; managedBy=tasker означает штатное workspace management внутри Operational Core, но identity/service entitlement все равно выдаются через Launcher.
""",
),
checker(
"public-users1",
"Чекер этапа 1. Разделение enterprise и Public Access Pool",
[
"Зафиксировать типы контуров: enterprise/direct-contract и public/open-access.",
"Добавить модель Public Access Pool или эквивалентный user cohort без создания отдельной компании на каждого пользователя.",
"Сделать global user уникальным по email/Auth subject независимо от public или enterprise происхождения.",
"Показать Public Access Pool root admin в существующем Launcher admin selector/режиме.",
"Скрыть Public Access Pool от client admin компаний.",
"Описать managedBy=launcher для корпоративных workspace.",
"Описать managedBy=tasker для публичных standalone workspace.",
"Развести видимость админки Launcher для root admin, client admin и public user.",
"Описать, как public-пользователь видит витрину сервисов без администрирования Launcher.",
"Зафиксировать запрет прямого создания Auth/Launcher users из Tasker без Launcher approval.",
],
),
text_block(
"public-users",
"Этап 2. Запрос приглашения из окна входа",
"""
Статус: backlog.
В branded login нужно добавить безопасный сценарий запроса доступа. Это не регистрация с паролем и не обход Authentik: пользователь оставляет заявку, а учетная запись и invite создаются только после ручного решения root admin.
Заявка должна попадать в Launcher admin, а не теряться в почте. Почтовое уведомление можно добавить позже как транспорт, но source of truth для обработки заявок должен быть внутри Launcher.
UI-решение: не добавлять новое большое окно. Расширить существующий раздел Инвайты вкладками Заявки и Сгенерированные инвайты. Для root admin вкладка Заявки показывает public/open-access заявки. Для client admin текущие company-scoped инвайты остаются без доступа к global public queue.
""",
),
checker(
"public-users2",
"Чекер этапа 2. Запрос приглашения из окна входа",
[
"Добавить вторичную кнопку Запросить доступ под кнопкой Войти.",
"Добавить форму заявки с обязательными полями: email, имя, фамилия, отчество, телефон, компания.",
"Оставить интересующий сервис/задачу и комментарий опциональными полями.",
"Блокировать отправку, если любое обязательное поле пустое.",
"Дублировать required validation server-side в Launcher public endpoint.",
"Не запрашивать пароль на этапе заявки.",
"Сохранять заявку server-side в Launcher storage/backend как accessRequest.",
"Расширить текущий раздел Инвайты вкладками Заявки и Сгенерированные инвайты.",
"Добавить статусы заявки: новая, в работе, принята, отклонена, архив.",
"Добавить действие Принять: создать invite в Public Access Pool для указанного email.",
"Добавить действие Отклонить без создания пользователя/Auth identity.",
"Пока нет почты, показывать сгенерированную ссылку для ручной передачи админом.",
],
),
text_block(
"public-users",
"Этап 3. Public user access flow",
"""
Статус: backlog.
После апрува пользователь регистрируется по invite link, попадает в Launcher и видит витрину сервисов. Администрирования Launcher у него нет. Он находится в Public Access Pool, но не получает доступ к Operational Core или другим сервисам автоматически.
Доступы к сервисам на первом этапе выдает root admin вручную через матрицу доступов. Когда public user получает Operational Core, он может создать собственный workspace в Tasker. Такой workspace должен быть managedBy=tasker: Tasker управляет проектами и workspace membership, Launcher управляет только identity, service entitlement и глобальной блокировкой пользователя.
""",
),
checker(
"public-users3",
"Чекер этапа 3. Public user access flow",
[
"Проверить, что public user не видит администрирование Launcher.",
"Проверить, что public user после invite попадает в Public Access Pool.",
"Не выдавать Operational Core автоматически только по факту принятого invite.",
"Показывать все сервисы витрины с состояниями доступен/нет доступа.",
"Раздавать сервисный доступ public user через матрицу доступов Launcher.",
"Открывать Operational Core без повторной авторизации после доступа.",
"Разрешить создание workspace внутри Tasker для managedBy=tasker.",
"Оставить настройки workspace/project внутри Tasker включенными для managedBy=tasker.",
"Скрывать настройки участников/инвайтов Tasker только для managedBy=launcher.",
],
),
text_block(
"public-users",
"Этап 4. Перевод Public user в enterprise-компанию",
"""
Статус: backlog.
Нужна штатная операция root admin: перевести пользователя из Public Access Pool в конкретную компанию. Это безопаснее, чем удалять пользователя и заставлять его регистрироваться заново по enterprise-инвайту, потому что Authentik identity, session history, audit trail, accepted invites и будущие billing/usage links остаются консистентными.
Целевая модель: Launcher хранит global user отдельно от company memberships. Public Access Pool cohort/source/status, а не единственный контейнер identity. При переводе root admin выбирает компанию, роль, группы и политику доступа. Система создает или обновляет ClientMembership, может архивировать public pool membership/request, пересчитывает Authentik groups и service grants. Если enterprise admin позже выписывает invite на email уже существующего public user, accept flow должен merge by email в тот же global user, а не создавать дубль.
Нужна также мягкая операция Убрать из Public Pool. Она не должна по умолчанию удалять global user/Auth identity: она архивирует public cohort и отзывает public-only grants. Полное удаление пользователя отдельное destructive действие с явным cleanup-планом.
""",
),
checker(
"public-users4",
"Чекер этапа 4. Перевод Public user в компанию",
[
"Добавить root-admin действие Перевести в компанию для пользователя из Public Access Pool.",
"Выбирать target company, company role и группы при переводе.",
"Создавать или обновлять ClientMembership без создания второго global user.",
"Принимать enterprise invite на email существующего public user через merge by email.",
"Архивировать или отключать public cohort marker после успешного перевода по выбору admin.",
"Пересчитывать Authentik groups/service grants после перевода.",
"Логировать перевод в audit с source pool, target company, actor и ролью.",
"Добавить действие Убрать из Public Pool без удаления global Auth identity по умолчанию.",
"Оставить полное удаление user отдельным destructive flow с подтверждением и cleanup.",
],
),
text_block(
"public-users",
"Этап 5. Public workspace collaboration и invite boundary",
"""
Статус: backlog.
Самый опасный участок как public-пользователи приглашают других людей в свой Operational Core workspace без готовой почтовой инфраструктуры. Нельзя позволять Tasker напрямую создавать Authentik/Launcher users: это ломает единый identity perimeter.
Правило для NODE.DC public mode: если приглашенный email уже принадлежит активному NODE.DC user с доступом к Operational Core, Tasker может добавить его в managedBy=tasker workspace по точному email/ID без глобального поиска людей. Если пользователя нет или у него нет entitlement на Operational Core, Tasker должен создать request в Launcher с контекстом workspace/requestedBy/targetEmail/role. Root admin вручную апрувит, генерирует invite link и после принятия доступ доводится до нужного workspace через Tasker adapter.
Для managedBy=launcher workspace любые Tasker-side invites/users остаются скрытыми или readonly. Приглашения в enterprise-контуре идут через Launcher company admin/root admin, как в текущей реализации.
""",
),
checker(
"public-users5",
"Чекер этапа 5. Public workspace collaboration",
[
"Запретить прямое создание Authentik/Launcher user из Tasker invite flow.",
"Разрешить добавление существующего NODE.DC user в public managedBy=tasker workspace по точному email/ID.",
"Проверять, что target user имеет Operational Core entitlement перед прямым добавлением.",
"Если target user отсутствует или не имеет entitlement, создавать Launcher accessRequest с Tasker workspace context.",
"Показывать такие заявки root admin во вкладке Заявки с источником Operational Core.",
"После approval создавать invite link и связывать accepted user с исходным Tasker workspace.",
"Оставить enterprise managedBy=launcher workspace без Tasker-side invites.",
"Отложить автоматическую email-доставку до отдельного mailer/email этапа.",
],
),
text_block(
"public-users",
"Этап 6. Billing-ready модель",
"""
Статус: backlog.
Биллинг пока не реализуется, но модель публичного контура должна не закрыть путь к оплатам. Для enterprise-клиентов остаются прямые договоры и ручные даты договора/оплаты. Для public/self-service пользователей позже появятся подписки, тарифы, лимиты и модульные entitlements.
""",
),
checker(
"public-users6",
"Чекер этапа 6. Billing-ready модель",
[
"Описать entitlement по каждому сервису отдельно.",
"Описать статус подписки public user: trial, active, expired, blocked.",
"Заложить лимиты Operational Core: workspace count, members count, storage.",
"Заложить лимиты Voice Tasker: minutes, requests, model tier.",
"Не внедрять платежный провайдер до отдельного billing RFC.",
"Не смешивать прямые договоры enterprise-клиентов и public subscriptions.",
],
),
],
},
{
@ -1073,6 +1666,7 @@ def ensure_issue(workspace, project, codex_user, spec):
issue.target_date = None
issue.external_source = SOURCE
issue.external_id = spec["slug"]
issue.created_by = issue.created_by or codex_user
issue.updated_by = codex_user
issue.save(disable_auto_set_user=True)