ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: self-service workspace invite approval

This commit is contained in:
DCCONSTRUCTIONS 2026-05-09 22:57:07 +03:00
parent 42bc0fb0e6
commit 0be8f01283
22 changed files with 707 additions and 77 deletions

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

@ -21,9 +21,15 @@ 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,
@ -56,8 +62,10 @@ 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
@ -119,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:
@ -135,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(
@ -165,6 +205,15 @@ class WorkspaceInvitationsViewset(BaseViewSet):
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)
@ -187,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

@ -13,9 +13,12 @@ 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 (
@ -150,6 +153,19 @@ 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

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

@ -23,6 +23,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"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.",
}
@ -41,6 +43,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"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.",
}
@ -69,6 +73,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"mode": "unavailable",
"managed_by": "tasker",
"default_managed_by": "tasker",
"invite_approval": "disabled",
"default_invite_approval": "tasker",
"workspaces": [],
"reason": "NODE.DC workspace policy is unavailable.",
}
@ -82,6 +88,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"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.",
}
@ -93,6 +101,13 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
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,
@ -100,6 +115,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"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.",
}
@ -113,6 +130,10 @@ 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 []
@ -161,3 +182,7 @@ def nodedc_launcher_managed_workspace_response():
"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,11 +4,13 @@ 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.utils.host import base_host
from plane.db.models import (
ExternalIdentityLink,
IssueAssignee,
@ -18,6 +20,7 @@ from plane.db.models import (
User,
Workspace,
WorkspaceMember,
WorkspaceMemberInvite,
)
@ -226,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,
@ -414,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):

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

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

@ -62,6 +62,8 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
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

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

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

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

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

@ -59,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;

View File

@ -41,6 +41,8 @@ export interface NodeDCWorkspacePolicy {
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;

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;