ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: 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()
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue