diff --git a/plane-src/apps/api/plane/app/serializers/workspace.py b/plane-src/apps/api/plane/app/serializers/workspace.py index 49df602..2ec5a53 100644 --- a/plane-src/apps/api/plane/app/serializers/workspace.py +++ b/plane-src/apps/api/plane/app/serializers/workspace.py @@ -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: diff --git a/plane-src/apps/api/plane/app/views/workspace/invite.py b/plane-src/apps/api/plane/app/views/workspace/invite.py index 11fc184..9fbcc27 100644 --- a/plane-src/apps/api/plane/app/views/workspace/invite.py +++ b/plane-src/apps/api/plane/app/views/workspace/invite.py @@ -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( diff --git a/plane-src/apps/api/plane/app/views/workspace/member.py b/plane-src/apps/api/plane/app/views/workspace/member.py index 0009516..e35a3b6 100644 --- a/plane-src/apps/api/plane/app/views/workspace/member.py +++ b/plane-src/apps/api/plane/app/views/workspace/member.py @@ -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 diff --git a/plane-src/apps/api/plane/authentication/nodedc_workspace_invites.py b/plane-src/apps/api/plane/authentication/nodedc_workspace_invites.py new file mode 100644 index 0000000..cf39649 --- /dev/null +++ b/plane-src/apps/api/plane/authentication/nodedc_workspace_invites.py @@ -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 diff --git a/plane-src/apps/api/plane/authentication/nodedc_workspace_join.py b/plane-src/apps/api/plane/authentication/nodedc_workspace_join.py new file mode 100644 index 0000000..9bba7a1 --- /dev/null +++ b/plane-src/apps/api/plane/authentication/nodedc_workspace_join.py @@ -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, + } diff --git a/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py b/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py index 392104f..5938d77 100644 --- a/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py +++ b/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py @@ -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" diff --git a/plane-src/apps/api/plane/authentication/views/app/oidc.py b/plane-src/apps/api/plane/authentication/views/app/oidc.py index 02056e2..2452c34 100644 --- a/plane-src/apps/api/plane/authentication/views/app/oidc.py +++ b/plane-src/apps/api/plane/authentication/views/app/oidc.py @@ -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() diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py index 1812734..e9ffe4a 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py @@ -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): diff --git a/plane-src/apps/api/plane/db/migrations/0139_workspacememberinvite_nodedc_approval.py b/plane-src/apps/api/plane/db/migrations/0139_workspacememberinvite_nodedc_approval.py new file mode 100644 index 0000000..4b3b8a3 --- /dev/null +++ b/plane-src/apps/api/plane/db/migrations/0139_workspacememberinvite_nodedc_approval.py @@ -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), + ), + ] diff --git a/plane-src/apps/api/plane/db/migrations/0140_workspacememberinvite_nodedc_platform_invite_link.py b/plane-src/apps/api/plane/db/migrations/0140_workspacememberinvite_nodedc_platform_invite_link.py new file mode 100644 index 0000000..bec1020 --- /dev/null +++ b/plane-src/apps/api/plane/db/migrations/0140_workspacememberinvite_nodedc_platform_invite_link.py @@ -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), + ), + ] diff --git a/plane-src/apps/api/plane/db/models/workspace.py b/plane-src/apps/api/plane/db/models/workspace.py index 6a54855..bd0b497 100644 --- a/plane-src/apps/api/plane/db/models/workspace.py +++ b/plane-src/apps/api/plane/db/models/workspace.py @@ -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"] diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index 16eb9f7..a409107 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -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(), diff --git a/plane-src/apps/web/app/(all)/create-workspace/page.tsx b/plane-src/apps/web/app/(all)/create-workspace/page.tsx index 1c1391a..ad5c117 100644 --- a/plane-src/apps/web/app/(all)/create-workspace/page.tsx +++ b/plane-src/apps/web/app/(all)/create-workspace/page.tsx @@ -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.", }); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/peek-shell.tsx b/plane-src/apps/web/ce/components/projects/external-contours/peek-shell.tsx index b10996c..bd76a3a 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/peek-shell.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/peek-shell.tsx @@ -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" && (
{shouldAllowPeekResize && peekMode === "side-peek" && (
-
-