ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: self-service workspace invite approval
This commit is contained in:
parent
42bc0fb0e6
commit
0be8f01283
|
|
@ -116,6 +116,15 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||||
invite_link = serializers.SerializerMethodField()
|
invite_link = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_invite_link(self, obj):
|
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}"
|
return f"/workspace-invitations/?invitation_id={obj.id}&slug={obj.workspace.slug}&token={obj.token}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,15 @@ from rest_framework.response import Response
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.app.permissions import WorkSpaceAdminPermission
|
from plane.app.permissions import WorkSpaceAdminPermission
|
||||||
from plane.authentication.nodedc_workspace_policy import (
|
from plane.authentication.nodedc_workspace_policy import (
|
||||||
|
get_nodedc_workspace_creation_policy,
|
||||||
is_nodedc_launcher_managed_workspace,
|
is_nodedc_launcher_managed_workspace,
|
||||||
|
is_nodedc_workspace_invite_approval_required,
|
||||||
nodedc_launcher_managed_workspace_response,
|
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 (
|
from plane.app.serializers import (
|
||||||
WorkSpaceMemberInviteSerializer,
|
WorkSpaceMemberInviteSerializer,
|
||||||
WorkSpaceMemberSerializer,
|
WorkSpaceMemberSerializer,
|
||||||
|
|
@ -56,8 +62,10 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
)
|
)
|
||||||
|
|
||||||
def create(self, request, slug):
|
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):
|
if is_nodedc_launcher_managed_workspace(request.user, slug):
|
||||||
return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN)
|
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", [])
|
emails = request.data.get("emails", [])
|
||||||
# Check if email is provided
|
# Check if email is provided
|
||||||
|
|
@ -119,6 +127,11 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
),
|
),
|
||||||
role=email.get("role", 5),
|
role=email.get("role", 5),
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
|
nodedc_approval_status=(
|
||||||
|
WorkspaceMemberInvite.NODEDC_APPROVAL_PENDING
|
||||||
|
if requires_nodedc_approval
|
||||||
|
else WorkspaceMemberInvite.NODEDC_APPROVAL_NOT_REQUIRED
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
|
|
@ -135,6 +148,33 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
|
|
||||||
current_site = base_host(request=request, is_app=True)
|
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
|
# Send invitations
|
||||||
for invitation in workspace_invitations:
|
for invitation in workspace_invitations:
|
||||||
workspace_invitation.delay(
|
workspace_invitation.delay(
|
||||||
|
|
@ -165,6 +205,15 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN)
|
return Response(nodedc_launcher_managed_workspace_response(), status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
workspace_member_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug)
|
workspace_member_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug)
|
||||||
|
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()
|
workspace_member_invite.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
@ -187,6 +236,18 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||||
|
|
||||||
token = request.data.get("token", "")
|
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
|
# Validate the token to verify the user received the invitation email
|
||||||
if not token or workspace_invite.token != token:
|
if not token or workspace_invite.token != token:
|
||||||
return Response(
|
return Response(
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,12 @@ from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
|
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
|
||||||
from plane.authentication.nodedc_workspace_policy import (
|
from plane.authentication.nodedc_workspace_policy import (
|
||||||
|
get_nodedc_workspace_creation_policy,
|
||||||
is_nodedc_launcher_managed_workspace,
|
is_nodedc_launcher_managed_workspace,
|
||||||
|
is_nodedc_workspace_invite_approval_required,
|
||||||
nodedc_launcher_managed_workspace_response,
|
nodedc_launcher_managed_workspace_response,
|
||||||
)
|
)
|
||||||
|
from plane.authentication.nodedc_workspace_invites import cancel_nodedc_workspace_member_access
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
|
|
@ -150,6 +153,19 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
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
|
# Deactivate the users from the projects where the user is part of
|
||||||
_ = ProjectMember.objects.filter(
|
_ = ProjectMember.objects.filter(
|
||||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
|
||||||
"mode": "standalone",
|
"mode": "standalone",
|
||||||
"managed_by": "tasker",
|
"managed_by": "tasker",
|
||||||
"default_managed_by": "tasker",
|
"default_managed_by": "tasker",
|
||||||
|
"invite_approval": "tasker",
|
||||||
|
"default_invite_approval": "tasker",
|
||||||
"workspaces": [],
|
"workspaces": [],
|
||||||
"reason": "NODE.DC workspace policy is not configured.",
|
"reason": "NODE.DC workspace policy is not configured.",
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +43,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
|
||||||
"mode": "unlinked",
|
"mode": "unlinked",
|
||||||
"managed_by": "tasker",
|
"managed_by": "tasker",
|
||||||
"default_managed_by": "tasker",
|
"default_managed_by": "tasker",
|
||||||
|
"invite_approval": "tasker",
|
||||||
|
"default_invite_approval": "tasker",
|
||||||
"workspaces": [],
|
"workspaces": [],
|
||||||
"reason": "NODE.DC identity is not linked." if enforce_unlinked else "Standalone user without NODE.DC identity.",
|
"reason": "NODE.DC identity is not linked." if enforce_unlinked else "Standalone user without NODE.DC identity.",
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +73,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
|
||||||
"mode": "unavailable",
|
"mode": "unavailable",
|
||||||
"managed_by": "tasker",
|
"managed_by": "tasker",
|
||||||
"default_managed_by": "tasker",
|
"default_managed_by": "tasker",
|
||||||
|
"invite_approval": "disabled",
|
||||||
|
"default_invite_approval": "tasker",
|
||||||
"workspaces": [],
|
"workspaces": [],
|
||||||
"reason": "NODE.DC workspace policy is unavailable.",
|
"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",
|
"mode": "legacy_access_check",
|
||||||
"managed_by": "tasker",
|
"managed_by": "tasker",
|
||||||
"default_managed_by": "tasker",
|
"default_managed_by": "tasker",
|
||||||
|
"invite_approval": "tasker",
|
||||||
|
"default_invite_approval": "tasker",
|
||||||
"workspaces": [],
|
"workspaces": [],
|
||||||
"reason": payload.get("reason") or "NODE.DC access check does not expose workspace policy.",
|
"reason": payload.get("reason") or "NODE.DC access check does not expose workspace policy.",
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +101,13 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
fallback=workspace_policy.get("managedBy") or workspace_policy.get("defaultManagedBy"),
|
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 {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
|
@ -100,6 +115,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
|
||||||
"mode": workspace_policy.get("mode") or "unknown",
|
"mode": workspace_policy.get("mode") or "unknown",
|
||||||
"managed_by": managed_by,
|
"managed_by": managed_by,
|
||||||
"default_managed_by": normalize_managed_by(workspace_policy.get("defaultManagedBy") or workspace_policy.get("managedBy")),
|
"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,
|
"workspaces": workspaces,
|
||||||
"reason": workspace_policy.get("reason") or payload.get("reason") or "NODE.DC workspace policy decision.",
|
"reason": workspace_policy.get("reason") or payload.get("reason") or "NODE.DC workspace policy decision.",
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +130,10 @@ def normalize_managed_by(value):
|
||||||
return "launcher" if value == "launcher" else "tasker"
|
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):
|
def normalize_workspace_management_list(value):
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return []
|
return []
|
||||||
|
|
@ -161,3 +182,7 @@ def nodedc_launcher_managed_workspace_response():
|
||||||
"error": "nodedc_launcher_managed_workspace",
|
"error": "nodedc_launcher_managed_workspace",
|
||||||
"reason": "Участниками и ролями этого workspace управляет Launcher.",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ from django.views import View
|
||||||
from plane.authentication.utils.host import base_host
|
from plane.authentication.utils.host import base_host
|
||||||
from plane.authentication.utils.login import user_login
|
from plane.authentication.utils.login import user_login
|
||||||
from plane.authentication.utils.redirection_path import get_redirection_path
|
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.authentication.views.nodedc_logout import get_nodedc_internal_token
|
||||||
from plane.db.models import ExternalIdentityLink, Profile, User
|
from plane.db.models import ExternalIdentityLink, Profile, User
|
||||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
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_SESSION_KEY = "nodedc_oidc"
|
||||||
OIDC_PROVIDER = "authentik"
|
OIDC_PROVIDER = "authentik"
|
||||||
|
NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX = "/auth/nodedc/workspace-invite/accept/"
|
||||||
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
|
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -161,10 +166,45 @@ class NodeDCHandoffEndpoint(View):
|
||||||
|
|
||||||
user_login(request=request, user=user, is_app=True)
|
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)
|
path = next_path or get_redirection_path(user=user)
|
||||||
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
|
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():
|
def get_oidc_config():
|
||||||
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
|
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
|
||||||
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
|
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
|
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
|
||||||
|
from plane.utils.host import base_host
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
ExternalIdentityLink,
|
ExternalIdentityLink,
|
||||||
IssueAssignee,
|
IssueAssignee,
|
||||||
|
|
@ -18,6 +20,7 @@ from plane.db.models import (
|
||||||
User,
|
User,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceMember,
|
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):
|
def serialize_project_membership(project_member, created):
|
||||||
return {
|
return {
|
||||||
"created": created,
|
"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")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
class NodeDCInternalProjectMembershipEnsureEndpoint(View):
|
class NodeDCInternalProjectMembershipEnsureEndpoint(View):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -238,6 +238,17 @@ class WorkspaceMember(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceMemberInvite(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")
|
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite")
|
||||||
email = models.CharField(max_length=255)
|
email = models.CharField(max_length=255)
|
||||||
accepted = models.BooleanField(default=False)
|
accepted = models.BooleanField(default=False)
|
||||||
|
|
@ -245,6 +256,14 @@ class WorkspaceMemberInvite(BaseModel):
|
||||||
message = models.TextField(null=True)
|
message = models.TextField(null=True)
|
||||||
responded_at = models.DateTimeField(null=True)
|
responded_at = models.DateTimeField(null=True)
|
||||||
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
|
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:
|
class Meta:
|
||||||
unique_together = ["email", "workspace", "deleted_at"]
|
unique_together = ["email", "workspace", "deleted_at"]
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ from plane.authentication.views.nodedc_logout import (
|
||||||
from plane.authentication.views.nodedc_workspace_adapter import (
|
from plane.authentication.views.nodedc_workspace_adapter import (
|
||||||
NodeDCInternalProjectMembershipEnsureEndpoint,
|
NodeDCInternalProjectMembershipEnsureEndpoint,
|
||||||
NodeDCInternalProjectMembershipRemoveEndpoint,
|
NodeDCInternalProjectMembershipRemoveEndpoint,
|
||||||
|
NodeDCInternalWorkspaceInviteApproveEndpoint,
|
||||||
|
NodeDCInternalWorkspaceInviteRejectEndpoint,
|
||||||
NodeDCInternalWorkspaceListEndpoint,
|
NodeDCInternalWorkspaceListEndpoint,
|
||||||
NodeDCInternalWorkspaceMembershipEnsureEndpoint,
|
NodeDCInternalWorkspaceMembershipEnsureEndpoint,
|
||||||
NodeDCInternalWorkspaceMembershipRemoveEndpoint,
|
NodeDCInternalWorkspaceMembershipRemoveEndpoint,
|
||||||
|
|
@ -46,6 +48,16 @@ urlpatterns = [
|
||||||
NodeDCInternalWorkspaceMembershipRemoveEndpoint.as_view(),
|
NodeDCInternalWorkspaceMembershipRemoveEndpoint.as_view(),
|
||||||
name="nodedc-internal-workspace-membership-remove",
|
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(
|
path(
|
||||||
"api/internal/nodedc/project-memberships/ensure/",
|
"api/internal/nodedc/project-memberships/ensure/",
|
||||||
NodeDCInternalProjectMembershipEnsureEndpoint.as_view(),
|
NodeDCInternalProjectMembershipEnsureEndpoint.as_view(),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
|
||||||
mode: "unavailable",
|
mode: "unavailable",
|
||||||
managed_by: "tasker",
|
managed_by: "tasker",
|
||||||
default_managed_by: "tasker",
|
default_managed_by: "tasker",
|
||||||
|
invite_approval: "tasker",
|
||||||
|
default_invite_approval: "tasker",
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
reason: "NODE.DC workspace policy is unavailable.",
|
reason: "NODE.DC workspace policy is unavailable.",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,10 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
|
||||||
|
|
||||||
const peekOverviewClassName = cn(
|
const peekOverviewClassName = cn(
|
||||||
!embedIssue
|
!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",
|
: "h-full w-full",
|
||||||
!embedIssue && {
|
!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",
|
peekMode === "side-peek",
|
||||||
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
||||||
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
|
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
|
||||||
|
|
@ -173,7 +173,7 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
|
||||||
>
|
>
|
||||||
{!embedIssue && peekMode === "side-peek" && (
|
{!embedIssue && peekMode === "side-peek" && (
|
||||||
<div
|
<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}
|
onMouseDown={startPeekResizing}
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-label="Resize external contour panel"
|
aria-label="Resize external contour panel"
|
||||||
|
|
|
||||||
|
|
@ -238,9 +238,9 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
shouldRenderPeekSurface
|
shouldRenderPeekSurface
|
||||||
? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
|
? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
|
||||||
: "h-full w-full",
|
: "h-full w-full",
|
||||||
!embedIssue && "absolute z-[25]",
|
!embedIssue && "absolute z-[80]",
|
||||||
!embedIssue && {
|
!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",
|
peekMode === "side-peek",
|
||||||
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
||||||
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
|
"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" && (
|
{shouldAllowPeekResize && peekMode === "side-peek" && (
|
||||||
<div
|
<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}
|
onMouseDown={startPeekResizing}
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-label="Resize issue panel"
|
aria-label="Resize issue panel"
|
||||||
|
|
|
||||||
|
|
@ -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]">
|
<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="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<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">
|
<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-danger-primary" aria-hidden="true" />
|
<AlertTriangle className="h-6 w-6 text-[rgb(var(--nodedc-accent-rgb))]" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<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">
|
<Dialog.Title as="h3" className="text-16 leading-6 font-medium text-primary">
|
||||||
{currentUser?.id === userDetails.id
|
{currentUser?.id === userDetails.id
|
||||||
? "Leave workspace?"
|
? "Покинуть рабочее пространство?"
|
||||||
: `Remove ${userDetails?.display_name}?`}
|
: `Удалить ${userDetails?.display_name}?`}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{currentUser?.id === userDetails.id ? (
|
{currentUser?.id === userDetails.id ? (
|
||||||
<p className="text-13 text-secondary">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-13 text-secondary">
|
<p className="text-13 text-secondary">
|
||||||
Are you sure you want to remove member-{" "}
|
Вы уверены, что хотите удалить <span className="font-bold">{userDetails?.display_name}</span>?
|
||||||
<span className="font-bold">{userDetails?.display_name}</span>? They will no longer have
|
Доступ к этому рабочему пространству будет закрыт. Действие нельзя отменить.
|
||||||
access to this workspace. This action cannot be undone.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,16 +87,16 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
||||||
<Button variant="secondary" onClick={handleClose}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Cancel
|
Отменить
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="error-fill" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
|
<Button variant="primary" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
|
||||||
{currentUser?.id === userDetails.id
|
{currentUser?.id === userDetails.id
|
||||||
? isRemoving
|
? isRemoving
|
||||||
? "Leaving"
|
? "Выход..."
|
||||||
: "Leave"
|
: "Покинуть"
|
||||||
: isRemoving
|
: isRemoving
|
||||||
? "Removing"
|
? "Удаление..."
|
||||||
: "Remove"}
|
: "Удалить"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
// ui
|
|
||||||
import { useTranslation } from "@plane/i18n";
|
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
|
// ui
|
||||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import { useUser } from "@/hooks/store/user";
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
|
@ -30,7 +29,6 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
|
||||||
const [isRemoving, setIsRemoving] = useState(false);
|
const [isRemoving, setIsRemoving] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { data: currentUser } = useUser();
|
const { data: currentUser } = useUser();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -49,23 +47,24 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
|
||||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
|
<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="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<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">
|
<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-danger-primary" aria-hidden="true" />
|
<AlertTriangle className="h-6 w-6 text-[rgb(var(--nodedc-accent-rgb))]" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
<h3 className="text-h5-medium leading-6 text-primary">
|
<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>
|
</h3>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{currentUser?.id === userDetails.id ? (
|
{currentUser?.id === userDetails.id ? (
|
||||||
<p className="text-body-xs-regular text-secondary">
|
<p className="text-body-xs-regular text-secondary">
|
||||||
{t("workspace_settings.settings.members.leave_confirmation")}
|
Вы потеряете доступ к этому рабочему пространству. Действие нельзя отменить.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-body-xs-regular text-secondary">
|
<p className="text-body-xs-regular text-secondary">
|
||||||
{/* TODO: Add translation here */}
|
Вы уверены, что хотите удалить <span className="font-bold">{userDetails?.display_name}</span>? Доступ
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,16 +73,16 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
||||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
<Button variant="secondary" size="lg" onClick={handleClose}>
|
||||||
{t("cancel")}
|
Отменить
|
||||||
</Button>
|
</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
|
{currentUser?.id === userDetails.id
|
||||||
? isRemoving
|
? isRemoving
|
||||||
? t("leaving")
|
? "Выход..."
|
||||||
: t("leave")
|
: "Покинуть"
|
||||||
: isRemoving
|
: isRemoving
|
||||||
? t("removing")
|
? "Удаление..."
|
||||||
: t("remove")}
|
: "Удалить"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ModalCore>
|
</ModalCore>
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,9 @@ import { useParams } from "next/navigation";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { ROLE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { ROLE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
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 { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import type { TContextMenuItem } from "@plane/ui";
|
import { cn, copyTextToClipboard } from "@plane/utils";
|
||||||
import { ActionDropdown } from "@plane/ui";
|
|
||||||
import { copyTextToClipboard } from "@plane/utils";
|
|
||||||
import { SelectionDropdown } from "@/components/common/selection-dropdown";
|
import { SelectionDropdown } from "@/components/common/selection-dropdown";
|
||||||
// components
|
// components
|
||||||
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
|
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
|
||||||
|
|
@ -26,6 +24,8 @@ type Props = {
|
||||||
invitationId: string;
|
invitationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NodeDCInvitationApprovalStatus = "not_required" | "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitationsListItem(props: Props) {
|
export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitationsListItem(props: Props) {
|
||||||
const { invitationId } = props;
|
const { invitationId } = props;
|
||||||
// router
|
// router
|
||||||
|
|
@ -43,6 +43,10 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||||
const invitationDetails = getWorkspaceInvitationDetails(invitationId);
|
const invitationDetails = getWorkspaceInvitationDetails(invitationId);
|
||||||
const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString());
|
const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString());
|
||||||
const currentWorkspaceRole = currentWorkspaceMemberInfo?.role;
|
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
|
// is the current logged in user admin
|
||||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||||
// role change access-
|
// role change access-
|
||||||
|
|
@ -61,15 +65,15 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||||
await deleteMemberInvitation(workspaceSlug.toString(), invitationDetails.id);
|
await deleteMemberInvitation(workspaceSlug.toString(), invitationDetails.id);
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: "Success!",
|
title: "Инвайт удалён",
|
||||||
message: "Invitation removed successfully.",
|
message: "Пользователь больше не увидит это приглашение.",
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { error?: string };
|
const error = err as { error?: string };
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Error!",
|
title: "Ошибка",
|
||||||
message: error?.error || "Something went wrong. Please try again.",
|
message: error?.error || "Не удалось удалить инвайт. Попробуйте ещё раз.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -78,39 +82,19 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||||
|
|
||||||
const handleCopyText = async () => {
|
const handleCopyText = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!invitationDetails.invite_link) return;
|
||||||
const inviteLink = new URL(invitationDetails.invite_link, window.location.origin).href;
|
const inviteLink = new URL(invitationDetails.invite_link, window.location.origin).href;
|
||||||
await copyTextToClipboard(inviteLink);
|
await copyTextToClipboard(inviteLink);
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: t("common.link_copied"),
|
title: "Инвайт скопирован",
|
||||||
message: t("entity.link_copied_to_clipboard", { entity: t("common.invite") }),
|
message: "Передайте ссылку приглашённому пользователю.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating invite link:", 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmWorkspaceMemberRemove
|
<ConfirmWorkspaceMemberRemove
|
||||||
|
|
@ -132,8 +116,8 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-11">
|
<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">
|
<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>{t("common.pending")}</p>
|
<p>{nodedcInviteStatusLabel(nodedcApprovalStatus, t("common.pending"))}</p>
|
||||||
</div>
|
</div>
|
||||||
<SelectionDropdown
|
<SelectionDropdown
|
||||||
options={Object.keys(ROLE)
|
options={Object.keys(ROLE)
|
||||||
|
|
@ -167,7 +151,7 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
menuButton={
|
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
|
<span
|
||||||
className={`flex items-center rounded-sm text-caption-sm-medium ${
|
className={`flex items-center rounded-sm text-caption-sm-medium ${
|
||||||
hasRoleChangeAccess ? "" : "text-placeholder"
|
hasRoleChangeAccess ? "" : "text-placeholder"
|
||||||
|
|
@ -182,14 +166,48 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
disabled={!hasRoleChangeAccess}
|
disabled={!hasRoleChangeAccess || isNodeDCApprovalLocked}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
/>
|
/>
|
||||||
{isAdmin && (
|
{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>
|
||||||
</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";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ export const WorkspaceMembersList = observer(function WorkspaceMembersList(props
|
||||||
await fetchWorkspaceMemberInvitations(workspaceSlug.toString());
|
await fetchWorkspaceMemberInvitations(workspaceSlug.toString());
|
||||||
await fetchWorkspaceMembers(workspaceSlug.toString());
|
await fetchWorkspaceMembers(workspaceSlug.toString());
|
||||||
}
|
}
|
||||||
: null
|
: null,
|
||||||
|
{ refreshInterval: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!workspaceMemberIds && !workspaceMemberInvitationIds) return <MembersSettingsLoader />;
|
if (!workspaceMemberIds && !workspaceMemberInvitationIds) return <MembersSettingsLoader />;
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,11 @@ export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMem
|
||||||
setInviteModal(false);
|
setInviteModal(false);
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: "Success!",
|
title: nodedcWorkspacePolicy?.invite_approval === "nodedc" ? "Запрос отправлен" : "Success!",
|
||||||
message: t("workspace_settings.settings.members.invitations_sent_successfully"),
|
message:
|
||||||
|
nodedcWorkspacePolicy?.invite_approval === "nodedc"
|
||||||
|
? "Приглашение отправлено на подтверждение NODE.DC."
|
||||||
|
: t("workspace_settings.settings.members.invitations_sent_successfully"),
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? (error as Error & { error?: string }).error : undefined;
|
const message = error instanceof Error ? (error as Error & { error?: string }).error : undefined;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export interface NodeDCWorkspacePolicy {
|
||||||
mode: string;
|
mode: string;
|
||||||
managed_by: "launcher" | "tasker";
|
managed_by: "launcher" | "tasker";
|
||||||
default_managed_by: "launcher" | "tasker";
|
default_managed_by: "launcher" | "tasker";
|
||||||
|
invite_approval: "tasker" | "nodedc" | "launcher" | "disabled";
|
||||||
|
default_invite_approval: "tasker" | "nodedc" | "launcher" | "disabled";
|
||||||
workspaces: Array<{
|
workspaces: Array<{
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,14 @@ export interface IWorkspaceMemberInvitation {
|
||||||
email: string;
|
email: string;
|
||||||
id: string;
|
id: string;
|
||||||
message: 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;
|
responded_at: Date;
|
||||||
role: TUserPermissions;
|
role: TUserPermissions;
|
||||||
token: string;
|
token: string;
|
||||||
invite_link: string;
|
invite_link: string | null;
|
||||||
workspace: {
|
workspace: {
|
||||||
id: string;
|
id: string;
|
||||||
logo_url: string;
|
logo_url: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue