ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый realtime слой Tasker
Добавлен NODE.DC realtime event stream для workspace/project/member/invite/profile событий. Обновлены frontend stores и live controller. Доработаны settings modal, member dropdown и confirm remove под NODE.DC UX.
This commit is contained in:
parent
fc59481703
commit
480f85cce8
|
|
@ -135,3 +135,34 @@ def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fiel
|
||||||
transaction.on_commit(_publish)
|
transaction.on_commit(_publish)
|
||||||
if publish_external_bridge:
|
if publish_external_bridge:
|
||||||
publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=actor_id, changed_fields=changed_fields)
|
publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=actor_id, changed_fields=changed_fields)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_assignee_cleanup_issue_events_on_commit(project_id=None, workspace_id=None, assignee_id=None, actor_id=None):
|
||||||
|
if not assignee_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
from plane.db.models import Issue
|
||||||
|
|
||||||
|
issues = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
deleted_at__isnull=True,
|
||||||
|
issue_assignee__assignee_id=assignee_id,
|
||||||
|
issue_assignee__deleted_at__isnull=True,
|
||||||
|
)
|
||||||
|
.select_related("workspace")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
if project_id:
|
||||||
|
issues = issues.filter(project_id=project_id)
|
||||||
|
if workspace_id:
|
||||||
|
issues = issues.filter(workspace_id=workspace_id)
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
publish_issue_event_on_commit(
|
||||||
|
"issue.updated",
|
||||||
|
issue,
|
||||||
|
actor_id=actor_id,
|
||||||
|
changed_fields=["assignees"],
|
||||||
|
publish_external_bridge=True,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from plane.settings.redis import redis_instance
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user"
|
||||||
|
|
||||||
|
|
||||||
|
def nodedc_user_event_channel(user_id):
|
||||||
|
return f"{NODEDC_EVENT_CHANNEL_PREFIX}:{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_user_ids(user_ids):
|
||||||
|
normalized_user_ids = []
|
||||||
|
seen_user_ids = set()
|
||||||
|
|
||||||
|
for user_id in user_ids or []:
|
||||||
|
if not user_id:
|
||||||
|
continue
|
||||||
|
normalized_user_id = str(user_id)
|
||||||
|
if normalized_user_id in seen_user_ids:
|
||||||
|
continue
|
||||||
|
seen_user_ids.add(normalized_user_id)
|
||||||
|
normalized_user_ids.append(normalized_user_id)
|
||||||
|
|
||||||
|
return normalized_user_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_payload_to_users(user_ids, payload):
|
||||||
|
client = redis_instance()
|
||||||
|
for user_id in _normalize_user_ids(user_ids):
|
||||||
|
client.publish(
|
||||||
|
nodedc_user_event_channel(user_id),
|
||||||
|
json.dumps({**payload, "target_user_id": user_id}, cls=DjangoJSONEncoder),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_nodedc_event_to_users_on_commit(event_type, user_ids, payload=None):
|
||||||
|
event_payload = {
|
||||||
|
"event_id": str(uuid4()),
|
||||||
|
"type": event_type,
|
||||||
|
"emitted_at": timezone.now(),
|
||||||
|
**(payload or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _publish():
|
||||||
|
try:
|
||||||
|
_publish_payload_to_users(user_ids, event_payload)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to publish NODE.DC realtime event")
|
||||||
|
|
||||||
|
transaction.on_commit(_publish)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_nodedc_workspace_event_on_commit(workspace, event_type, payload=None, extra_user_ids=None):
|
||||||
|
workspace_payload = {
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"workspace_slug": workspace.slug,
|
||||||
|
**(payload or {}),
|
||||||
|
}
|
||||||
|
extra_user_ids = _normalize_user_ids(extra_user_ids or [])
|
||||||
|
|
||||||
|
def _publish():
|
||||||
|
try:
|
||||||
|
from plane.db.models import WorkspaceMember
|
||||||
|
|
||||||
|
workspace_user_ids = WorkspaceMember.objects.filter(
|
||||||
|
workspace_id=workspace.id,
|
||||||
|
is_active=True,
|
||||||
|
member__is_bot=False,
|
||||||
|
deleted_at__isnull=True,
|
||||||
|
).values_list("member_id", flat=True)
|
||||||
|
|
||||||
|
_publish_payload_to_users(
|
||||||
|
[*workspace_user_ids, *extra_user_ids],
|
||||||
|
{
|
||||||
|
"event_id": str(uuid4()),
|
||||||
|
"type": event_type,
|
||||||
|
"emitted_at": timezone.now(),
|
||||||
|
**workspace_payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to publish NODE.DC workspace realtime event")
|
||||||
|
|
||||||
|
transaction.on_commit(_publish)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_nodedc_user_profile_event_on_commit(user, changed_fields=None):
|
||||||
|
changed_fields = sorted(set(changed_fields or []))
|
||||||
|
payload = {
|
||||||
|
"member_id": str(user.id),
|
||||||
|
"email": user.email,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"avatar": user.avatar or None,
|
||||||
|
"changed_fields": changed_fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _publish():
|
||||||
|
try:
|
||||||
|
from plane.db.models import WorkspaceMember
|
||||||
|
|
||||||
|
memberships = (
|
||||||
|
WorkspaceMember.objects.filter(
|
||||||
|
member_id=user.id,
|
||||||
|
is_active=True,
|
||||||
|
deleted_at__isnull=True,
|
||||||
|
)
|
||||||
|
.select_related("workspace")
|
||||||
|
.only("workspace_id", "workspace__slug")
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace_ids = []
|
||||||
|
client = redis_instance()
|
||||||
|
for membership in memberships:
|
||||||
|
workspace_ids.append(str(membership.workspace_id))
|
||||||
|
workspace_user_ids = WorkspaceMember.objects.filter(
|
||||||
|
workspace_id=membership.workspace_id,
|
||||||
|
is_active=True,
|
||||||
|
member__is_bot=False,
|
||||||
|
deleted_at__isnull=True,
|
||||||
|
).values_list("member_id", flat=True)
|
||||||
|
event_payload = {
|
||||||
|
"event_id": str(uuid4()),
|
||||||
|
"type": "user.profile.updated",
|
||||||
|
"emitted_at": timezone.now(),
|
||||||
|
"workspace_id": str(membership.workspace_id),
|
||||||
|
"workspace_slug": membership.workspace.slug,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
for target_user_id in _normalize_user_ids([*workspace_user_ids, user.id]):
|
||||||
|
client.publish(
|
||||||
|
nodedc_user_event_channel(target_user_id),
|
||||||
|
json.dumps({**event_payload, "target_user_id": target_user_id}, cls=DjangoJSONEncoder),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not workspace_ids:
|
||||||
|
_publish_payload_to_users(
|
||||||
|
[user.id],
|
||||||
|
{
|
||||||
|
"event_id": str(uuid4()),
|
||||||
|
"type": "user.profile.updated",
|
||||||
|
"emitted_at": timezone.now(),
|
||||||
|
**payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to publish NODE.DC profile realtime event")
|
||||||
|
|
||||||
|
transaction.on_commit(_publish)
|
||||||
|
|
@ -9,6 +9,8 @@ from django.db.models import Min
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseViewSet, BaseAPIView
|
from .base import BaseViewSet, BaseAPIView
|
||||||
|
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
|
||||||
|
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
|
||||||
from plane.authentication.nodedc_workspace_policy import (
|
from plane.authentication.nodedc_workspace_policy import (
|
||||||
is_nodedc_launcher_managed_workspace,
|
is_nodedc_launcher_managed_workspace,
|
||||||
nodedc_launcher_managed_workspace_response,
|
nodedc_launcher_managed_workspace_response,
|
||||||
|
|
@ -176,6 +178,18 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||||
],
|
],
|
||||||
batch_size=10,
|
batch_size=10,
|
||||||
)
|
)
|
||||||
|
for project_member in project_members:
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
project_member.workspace,
|
||||||
|
"project_member.created",
|
||||||
|
payload={
|
||||||
|
"project_id": str(project_member.project_id),
|
||||||
|
"member_id": str(project_member.member_id),
|
||||||
|
"role": project_member.role,
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[project_member.member_id],
|
||||||
|
)
|
||||||
# Send emails to notify the users
|
# Send emails to notify the users
|
||||||
[
|
[
|
||||||
project_add_user_email.delay(
|
project_add_user_email.delay(
|
||||||
|
|
@ -301,6 +315,17 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||||
|
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
project_member.workspace,
|
||||||
|
"project_member.updated",
|
||||||
|
payload={
|
||||||
|
"project_id": str(project_member.project_id),
|
||||||
|
"member_id": str(project_member.member_id),
|
||||||
|
"role": project_member.role,
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[project_member.member_id],
|
||||||
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
@ -336,9 +361,24 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
publish_assignee_cleanup_issue_events_on_commit(
|
||||||
|
project_id=project_id,
|
||||||
|
assignee_id=project_member.member_id,
|
||||||
|
actor_id=request.user.id,
|
||||||
|
)
|
||||||
project_member.is_active = False
|
project_member.is_active = False
|
||||||
project_member.save()
|
project_member.save()
|
||||||
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
|
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
project_member.workspace,
|
||||||
|
"project_member.deleted",
|
||||||
|
payload={
|
||||||
|
"project_id": str(project_member.project_id),
|
||||||
|
"member_id": str(project_member.member_id),
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[project_member.member_id],
|
||||||
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
|
|
@ -367,10 +407,25 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||||
},
|
},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
publish_assignee_cleanup_issue_events_on_commit(
|
||||||
|
project_id=project_id,
|
||||||
|
assignee_id=project_member.member_id,
|
||||||
|
actor_id=request.user.id,
|
||||||
|
)
|
||||||
# Deactivate the user
|
# Deactivate the user
|
||||||
project_member.is_active = False
|
project_member.is_active = False
|
||||||
project_member.save()
|
project_member.save()
|
||||||
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
|
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
project_member.workspace,
|
||||||
|
"project_member.deleted",
|
||||||
|
payload={
|
||||||
|
"project_id": str(project_member.project_id),
|
||||||
|
"member_id": str(project_member.member_id),
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[project_member.member_id],
|
||||||
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.app.permissions import WorkSpaceAdminPermission
|
from plane.app.permissions import WorkSpaceAdminPermission
|
||||||
|
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
|
||||||
from plane.authentication.nodedc_workspace_policy import (
|
from plane.authentication.nodedc_workspace_policy import (
|
||||||
get_nodedc_workspace_creation_policy,
|
get_nodedc_workspace_creation_policy,
|
||||||
is_nodedc_launcher_managed_workspace,
|
is_nodedc_launcher_managed_workspace,
|
||||||
|
|
@ -159,6 +160,18 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
invitation.nodedc_approval_request_id = approval_request_id
|
invitation.nodedc_approval_request_id = approval_request_id
|
||||||
invitation.save(update_fields=["nodedc_approval_request_id", "updated_at"])
|
invitation.save(update_fields=["nodedc_approval_request_id", "updated_at"])
|
||||||
approved_requests.append(approval_request_id)
|
approved_requests.append(approval_request_id)
|
||||||
|
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace,
|
||||||
|
"workspace_invite.created",
|
||||||
|
payload={
|
||||||
|
"invite_id": str(invitation.id),
|
||||||
|
"email": invitation.email,
|
||||||
|
"status": invitation.nodedc_approval_status,
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[request.user.id, getattr(invited_user, "id", None)],
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
WorkspaceMemberInvite.objects.filter(id__in=[invitation.id for invitation in workspace_invitations]).delete()
|
WorkspaceMemberInvite.objects.filter(id__in=[invitation.id for invitation in workspace_invitations]).delete()
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -197,6 +210,18 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
"invitee_email": invitation.email,
|
"invitee_email": invitation.email,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace,
|
||||||
|
"workspace_invite.created",
|
||||||
|
payload={
|
||||||
|
"invite_id": str(invitation.id),
|
||||||
|
"email": invitation.email,
|
||||||
|
"status": invitation.nodedc_approval_status,
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[request.user.id, getattr(invited_user, "id", None)],
|
||||||
|
)
|
||||||
|
|
||||||
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
|
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
@ -214,7 +239,23 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
status=status.HTTP_502_BAD_GATEWAY,
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
invited_user = User.objects.filter(email__iexact=workspace_member_invite.email, is_bot=False).first()
|
||||||
|
workspace = workspace_member_invite.workspace
|
||||||
|
invite_payload = {
|
||||||
|
"invite_id": str(workspace_member_invite.id),
|
||||||
|
"email": workspace_member_invite.email,
|
||||||
|
"status": workspace_member_invite.nodedc_approval_status,
|
||||||
|
"source": "tasker",
|
||||||
|
}
|
||||||
|
extra_user_ids = [workspace_member_invite.created_by_id, getattr(invited_user, "id", None)]
|
||||||
|
|
||||||
workspace_member_invite.delete()
|
workspace_member_invite.delete()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace,
|
||||||
|
"workspace_invite.deleted",
|
||||||
|
payload=invite_payload,
|
||||||
|
extra_user_ids=extra_user_ids,
|
||||||
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -340,6 +381,17 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace_invite.workspace,
|
||||||
|
"workspace_member.created",
|
||||||
|
payload={
|
||||||
|
"member_id": str(user.id),
|
||||||
|
"role": workspace_invite.role,
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[user.id, workspace_invite.created_by_id],
|
||||||
|
)
|
||||||
|
|
||||||
# Delete the invitation
|
# Delete the invitation
|
||||||
workspace_invite.delete()
|
workspace_invite.delete()
|
||||||
|
|
||||||
|
|
@ -447,6 +499,16 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
invitation.workspace,
|
||||||
|
"workspace_member.created",
|
||||||
|
payload={
|
||||||
|
"member_id": str(request.user.id),
|
||||||
|
"role": invitation.role,
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[request.user.id, invitation.created_by_id],
|
||||||
|
)
|
||||||
|
|
||||||
# Bulk create the user for all the workspaces
|
# Bulk create the user for all the workspaces
|
||||||
WorkspaceMember.objects.bulk_create(
|
WorkspaceMember.objects.bulk_create(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ from rest_framework import status
|
||||||
from rest_framework.response import Response
|
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.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
|
||||||
|
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
|
||||||
from plane.authentication.nodedc_workspace_policy import (
|
from plane.authentication.nodedc_workspace_policy import (
|
||||||
get_nodedc_workspace_creation_policy,
|
get_nodedc_workspace_creation_policy,
|
||||||
is_nodedc_launcher_managed_workspace,
|
is_nodedc_launcher_managed_workspace,
|
||||||
|
|
@ -54,7 +56,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||||
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
|
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
|
||||||
|
|
||||||
# Get all active workspace members
|
# Get all active workspace members
|
||||||
workspace_members = self.get_queryset()
|
workspace_members = self.get_queryset().filter(is_active=True)
|
||||||
if workspace_member.role > 5:
|
if workspace_member.role > 5:
|
||||||
serializer = WorkspaceMemberAdminSerializer(workspace_members, fields=("id", "member", "role"), many=True)
|
serializer = WorkspaceMemberAdminSerializer(workspace_members, fields=("id", "member", "role"), many=True)
|
||||||
else:
|
else:
|
||||||
|
|
@ -102,6 +104,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||||
|
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace_member.workspace,
|
||||||
|
"workspace_member.updated",
|
||||||
|
payload={
|
||||||
|
"member_id": str(workspace_member.member_id),
|
||||||
|
"role": workspace_member.role,
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[workspace_member.member_id],
|
||||||
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
@ -166,6 +178,20 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||||
status=status.HTTP_502_BAD_GATEWAY,
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
project_ids = list(
|
||||||
|
ProjectMember.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
member_id=workspace_member.member_id,
|
||||||
|
is_active=True,
|
||||||
|
).values_list("project_id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
publish_assignee_cleanup_issue_events_on_commit(
|
||||||
|
workspace_id=workspace_member.workspace_id,
|
||||||
|
assignee_id=workspace_member.member_id,
|
||||||
|
actor_id=request.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -174,6 +200,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||||
|
|
||||||
workspace_member.is_active = False
|
workspace_member.is_active = False
|
||||||
workspace_member.save()
|
workspace_member.save()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace_member.workspace,
|
||||||
|
"workspace_member.deleted",
|
||||||
|
payload={
|
||||||
|
"member_id": str(workspace_member.member_id),
|
||||||
|
"project_ids": [str(project_id) for project_id in project_ids],
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[workspace_member.member_id],
|
||||||
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@invalidate_cache(
|
@invalidate_cache(
|
||||||
|
|
@ -224,6 +260,20 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
project_ids = list(
|
||||||
|
ProjectMember.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
member_id=workspace_member.member_id,
|
||||||
|
is_active=True,
|
||||||
|
).values_list("project_id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
publish_assignee_cleanup_issue_events_on_commit(
|
||||||
|
workspace_id=workspace_member.workspace_id,
|
||||||
|
assignee_id=workspace_member.member_id,
|
||||||
|
actor_id=request.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
# # 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
|
||||||
|
|
@ -233,6 +283,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||||
# # Deactivate the user
|
# # Deactivate the user
|
||||||
workspace_member.is_active = False
|
workspace_member.is_active = False
|
||||||
workspace_member.save()
|
workspace_member.save()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace_member.workspace,
|
||||||
|
"workspace_member.deleted",
|
||||||
|
payload={
|
||||||
|
"member_id": str(workspace_member.member_id),
|
||||||
|
"project_ids": [str(project_id) for project_id in project_ids],
|
||||||
|
"source": "tasker",
|
||||||
|
},
|
||||||
|
extra_user_ids=[workspace_member.member_id],
|
||||||
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ 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.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
|
||||||
|
from plane.app.realtime.nodedc_events import (
|
||||||
|
publish_nodedc_user_profile_event_on_commit,
|
||||||
|
publish_nodedc_workspace_event_on_commit,
|
||||||
|
)
|
||||||
from plane.utils.host import base_host
|
from plane.utils.host import base_host
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
ExternalIdentityLink,
|
ExternalIdentityLink,
|
||||||
|
|
@ -361,6 +366,9 @@ class NodeDCInternalUserProfileSyncEndpoint(View):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
updated_fields = sync_user_profile_from_payload(user, payload)
|
updated_fields = sync_user_profile_from_payload(user, payload)
|
||||||
|
|
||||||
|
if updated_fields:
|
||||||
|
publish_nodedc_user_profile_event_on_commit(user, changed_fields=updated_fields)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|
@ -434,6 +442,17 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
|
||||||
profile.last_workspace_id = workspace.id
|
profile.last_workspace_id = workspace.id
|
||||||
profile.save(update_fields=["last_workspace_id", "updated_at"])
|
profile.save(update_fields=["last_workspace_id", "updated_at"])
|
||||||
|
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace,
|
||||||
|
"workspace_member.created" if created else "workspace_member.updated",
|
||||||
|
payload={
|
||||||
|
"member_id": str(user.id),
|
||||||
|
"role": membership.role,
|
||||||
|
"source": "launcher",
|
||||||
|
},
|
||||||
|
extra_user_ids=[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)})
|
return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -475,7 +494,16 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
project_ids = list(
|
||||||
|
ProjectMember.objects.filter(
|
||||||
|
project__workspace=workspace,
|
||||||
|
member=user,
|
||||||
|
is_active=True,
|
||||||
|
).values_list("project_id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
publish_assignee_cleanup_issue_events_on_commit(workspace_id=workspace.id, assignee_id=user.id)
|
||||||
ProjectMember.objects.filter(
|
ProjectMember.objects.filter(
|
||||||
project__workspace=workspace,
|
project__workspace=workspace,
|
||||||
member=user,
|
member=user,
|
||||||
|
|
@ -488,6 +516,17 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
|
||||||
membership.is_active = False
|
membership.is_active = False
|
||||||
membership.save(update_fields=["is_active", "updated_at"])
|
membership.save(update_fields=["is_active", "updated_at"])
|
||||||
|
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace,
|
||||||
|
"workspace_member.deleted",
|
||||||
|
payload={
|
||||||
|
"member_id": str(user.id),
|
||||||
|
"project_ids": [str(project_id) for project_id in project_ids],
|
||||||
|
"source": "launcher",
|
||||||
|
},
|
||||||
|
extra_user_ids=[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|
@ -530,6 +569,19 @@ class NodeDCInternalWorkspaceInviteApproveEndpoint(View):
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
invitation.workspace,
|
||||||
|
"workspace_invite.approved",
|
||||||
|
payload={
|
||||||
|
"invite_id": str(invitation.id),
|
||||||
|
"email": invitation.email,
|
||||||
|
"status": invitation.nodedc_approval_status,
|
||||||
|
"source": "launcher",
|
||||||
|
},
|
||||||
|
extra_user_ids=[invitation.created_by_id, getattr(invited_user, "id", None)],
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
|
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -561,6 +613,19 @@ class NodeDCInternalWorkspaceInviteRejectEndpoint(View):
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
invitation.workspace,
|
||||||
|
"workspace_invite.rejected",
|
||||||
|
payload={
|
||||||
|
"invite_id": str(invitation.id),
|
||||||
|
"email": invitation.email,
|
||||||
|
"status": invitation.nodedc_approval_status,
|
||||||
|
"source": "launcher",
|
||||||
|
},
|
||||||
|
extra_user_ids=[invitation.created_by_id, getattr(invited_user, "id", None)],
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
|
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -648,6 +713,28 @@ class NodeDCInternalProjectMembershipEnsureEndpoint(View):
|
||||||
profile.last_workspace_id = workspace.id
|
profile.last_workspace_id = workspace.id
|
||||||
profile.save(update_fields=["last_workspace_id", "updated_at"])
|
profile.save(update_fields=["last_workspace_id", "updated_at"])
|
||||||
|
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace,
|
||||||
|
"workspace_member.updated",
|
||||||
|
payload={
|
||||||
|
"member_id": str(user.id),
|
||||||
|
"role": workspace_membership.role,
|
||||||
|
"source": "launcher",
|
||||||
|
},
|
||||||
|
extra_user_ids=[user.id],
|
||||||
|
)
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace,
|
||||||
|
"project_member.created" if created else "project_member.updated",
|
||||||
|
payload={
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"member_id": str(user.id),
|
||||||
|
"role": project_member.role,
|
||||||
|
"source": "launcher",
|
||||||
|
},
|
||||||
|
extra_user_ids=[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse({"ok": True, "membership": serialize_project_membership(project_member, created)})
|
return JsonResponse({"ok": True, "membership": serialize_project_membership(project_member, created)})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -695,10 +782,22 @@ class NodeDCInternalProjectMembershipRemoveEndpoint(View):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
publish_assignee_cleanup_issue_events_on_commit(project_id=project.id, assignee_id=user.id)
|
||||||
project_member.is_active = False
|
project_member.is_active = False
|
||||||
project_member.save(update_fields=["is_active", "updated_at"])
|
project_member.save(update_fields=["is_active", "updated_at"])
|
||||||
IssueAssignee.objects.filter(project=project, assignee=user).delete()
|
IssueAssignee.objects.filter(project=project, assignee=user).delete()
|
||||||
|
|
||||||
|
publish_nodedc_workspace_event_on_commit(
|
||||||
|
workspace,
|
||||||
|
"project_member.deleted",
|
||||||
|
payload={
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"member_id": str(user.id),
|
||||||
|
"source": "launcher",
|
||||||
|
},
|
||||||
|
extra_user_ids=[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ FROM node:22-alpine AS base
|
||||||
|
|
||||||
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
# *****************************************************************************
|
# *****************************************************************************
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { CollaborationController } from "./collaboration.controller";
|
||||||
import { DocumentController } from "./document.controller";
|
import { DocumentController } from "./document.controller";
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { IssueStreamController } from "./issue-stream.controller";
|
import { IssueStreamController } from "./issue-stream.controller";
|
||||||
|
import { NodeDCStreamController } from "./nodedc-stream.controller";
|
||||||
import { PdfExportController } from "./pdf-export.controller";
|
import { PdfExportController } from "./pdf-export.controller";
|
||||||
|
|
||||||
export const CONTROLLERS = [
|
export const CONTROLLERS = [
|
||||||
|
|
@ -15,5 +16,6 @@ export const CONTROLLERS = [
|
||||||
DocumentController,
|
DocumentController,
|
||||||
HealthController,
|
HealthController,
|
||||||
IssueStreamController,
|
IssueStreamController,
|
||||||
|
NodeDCStreamController,
|
||||||
PdfExportController,
|
PdfExportController,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request } from "express";
|
||||||
|
import type Redis from "ioredis";
|
||||||
|
import type { WebSocket as WSSocket } from "ws";
|
||||||
|
// plane imports
|
||||||
|
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
|
||||||
|
import { logger } from "@plane/logger";
|
||||||
|
// redis
|
||||||
|
import { redisManager } from "@/redis";
|
||||||
|
// services
|
||||||
|
import { UserService } from "@/services/user.service";
|
||||||
|
|
||||||
|
const NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user";
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||||
|
|
||||||
|
const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
|
||||||
|
if (ws.readyState !== 1) return;
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
@Controller("/nodedc")
|
||||||
|
export class NodeDCStreamController {
|
||||||
|
[key: string]: unknown;
|
||||||
|
|
||||||
|
@WSDecorator("/stream")
|
||||||
|
handleConnection(ws: WSSocket, req: Request) {
|
||||||
|
void this.handleNodeDCStream(ws, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleNodeDCStream(ws: WSSocket, req: Request) {
|
||||||
|
const cookie = req.headers.cookie?.toString();
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
ws.close(1008, "Missing NODE.DC stream credentials");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscriber: Redis | undefined;
|
||||||
|
let heartbeat: NodeJS.Timeout | undefined;
|
||||||
|
let isCleanedUp = false;
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
if (isCleanedUp) return;
|
||||||
|
isCleanedUp = true;
|
||||||
|
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
if (!subscriber) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await subscriber.unsubscribe();
|
||||||
|
subscriber.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("NODEDC_STREAM_CONTROLLER: Redis cleanup failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userService = new UserService();
|
||||||
|
const user = await userService.currentUser(cookie);
|
||||||
|
const redisClient = redisManager.getClient();
|
||||||
|
|
||||||
|
if (!redisClient) {
|
||||||
|
ws.close(1011, "NODE.DC stream unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = `${NODEDC_EVENT_CHANNEL_PREFIX}:${user.id}`;
|
||||||
|
subscriber = redisClient.duplicate({ lazyConnect: true });
|
||||||
|
await subscriber.connect();
|
||||||
|
await subscriber.subscribe(channel);
|
||||||
|
|
||||||
|
subscriber.on("message", (_channel, message) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(message) as Record<string, unknown>;
|
||||||
|
sendJson(ws, event);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("NODEDC_STREAM_CONTROLLER: Failed to forward event:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
subscriber.on("error", (error) => {
|
||||||
|
logger.error("NODEDC_STREAM_CONTROLLER: Redis subscriber error:", error);
|
||||||
|
ws.close(1011, "NODE.DC stream subscriber failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
sendJson(ws, { type: "nodedc.stream.ping", server_ts: new Date().toISOString() });
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
sendJson(ws, {
|
||||||
|
type: "nodedc.stream.ready",
|
||||||
|
user_id: user.id,
|
||||||
|
server_ts: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket authentication failed:", error);
|
||||||
|
ws.close(1008, "NODE.DC stream authentication failed");
|
||||||
|
await cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on("message", (message) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(message.toString()) as { type?: string };
|
||||||
|
if (payload.type === "nodedc.stream.pong") return;
|
||||||
|
} catch {
|
||||||
|
// Client messages are optional for this stream.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
void cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (error: Error) => {
|
||||||
|
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket connection error:", error);
|
||||||
|
ws.close(1011, "NODE.DC stream connection failed");
|
||||||
|
void cleanup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
// types
|
// types
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
|
|
@ -22,12 +21,11 @@ type Props = {
|
||||||
onSubmit: () => Promise<void>;
|
onSubmit: () => Promise<void>;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) {
|
export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) {
|
||||||
const { data, onSubmit, isOpen, onClose } = props;
|
const { data, onSubmit, isOpen, onClose, projectId } = props;
|
||||||
// router
|
|
||||||
const { projectId } = useParams();
|
|
||||||
// states
|
// states
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
|
|
@ -50,44 +48,62 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember
|
||||||
if (!projectId) return <></>;
|
if (!projectId) return <></>;
|
||||||
|
|
||||||
const isCurrentUser = currentUser?.id === data?.id;
|
const isCurrentUser = currentUser?.id === data?.id;
|
||||||
const currentProjectDetails = getProjectById(projectId.toString());
|
const currentProjectDetails = getProjectById(projectId);
|
||||||
|
const memberName = data?.display_name || "участника";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
|
<ModalCore
|
||||||
<div className="bg-surface-1 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
isOpen={isOpen}
|
||||||
<div className="sm:flex sm:items-start">
|
handleClose={handleClose}
|
||||||
<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">
|
position={EModalPosition.CENTER}
|
||||||
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
|
width={EModalWidth.XXL}
|
||||||
|
className="overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] p-0 shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
|
||||||
|
>
|
||||||
|
<div className="p-6 sm:p-7">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="grid size-12 shrink-0 place-items-center rounded-[1.2rem] bg-[rgb(var(--nodedc-accent-rgb))]/18 text-[rgb(var(--nodedc-accent-rgb))]">
|
||||||
|
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="min-w-0">
|
||||||
<h3 className="text-16 leading-6 font-medium text-primary">
|
<p className="text-12 font-semibold tracking-[0.24em] text-tertiary uppercase">NODE.DC Tasker</p>
|
||||||
{isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`}
|
<h3 className="mt-2 text-18 leading-6 font-semibold text-primary">
|
||||||
|
{isCurrentUser ? "Покинуть проект?" : `Удалить ${memberName} из проекта?`}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2">
|
<p className="mt-3 text-13 leading-6 text-secondary">
|
||||||
<p className="text-13 text-secondary">
|
{isCurrentUser ? (
|
||||||
{isCurrentUser ? (
|
<>
|
||||||
<>
|
Вы потеряете доступ к проекту{" "}
|
||||||
Are you sure you want to leave the <span className="font-bold">{currentProjectDetails?.name}</span>{" "}
|
<span className="font-semibold text-primary">{currentProjectDetails?.name}</span>. Вернуться можно
|
||||||
project? You will be able to join the project if invited again or if it{"'"}s public.
|
будет только после нового приглашения или если проект публичный.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Are you sure you want to remove member- <span className="font-bold">{data?.display_name}</span>?
|
Пользователь <span className="font-semibold text-primary">{memberName}</span> потеряет доступ к
|
||||||
They will no longer have access to this project. This action cannot be undone.
|
проекту. Действие нельзя отменить автоматически.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-7 flex justify-end gap-3">
|
||||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
<Button
|
||||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
variant="secondary"
|
||||||
Cancel
|
size="lg"
|
||||||
</Button>
|
onClick={handleClose}
|
||||||
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
|
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
|
||||||
{isCurrentUser ? (isDeleteLoading ? "Leaving..." : "Leave") : isDeleteLoading ? "Removing..." : "Remove"}
|
>
|
||||||
</Button>
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleDeletion}
|
||||||
|
loading={isDeleteLoading}
|
||||||
|
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
|
||||||
|
>
|
||||||
|
{isCurrentUser ? (isDeleteLoading ? "Выходим..." : "Покинуть") : isDeleteLoading ? "Удаляем..." : "Удалить"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalCore>
|
</ModalCore>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
|
||||||
await leaveProject(workspaceSlug.toString(), projectId.toString())
|
await leaveProject(workspaceSlug.toString(), projectId.toString())
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
router.push(`/${workspaceSlug}/projects`);
|
router.push(`/${workspaceSlug}/projects`);
|
||||||
|
return undefined;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setToast({
|
setToast({
|
||||||
|
|
@ -55,6 +56,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
|
||||||
title: "You can’t leave this project yet.",
|
title: "You can’t leave this project yet.",
|
||||||
message: err?.error || "Something went wrong. Please try again.",
|
message: err?.error || "Something went wrong. Please try again.",
|
||||||
});
|
});
|
||||||
|
return undefined;
|
||||||
});
|
});
|
||||||
} else
|
} else
|
||||||
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
|
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
|
||||||
|
|
@ -73,6 +75,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
|
||||||
<ConfirmProjectMemberRemove
|
<ConfirmProjectMemberRemove
|
||||||
isOpen={removeMemberModal !== null}
|
isOpen={removeMemberModal !== null}
|
||||||
onClose={() => setRemoveMemberModal(null)}
|
onClose={() => setRemoveMemberModal(null)}
|
||||||
|
projectId={projectId}
|
||||||
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
|
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
|
||||||
onSubmit={() => handleRemove(removeMemberModal.member.id)}
|
onSubmit={() => handleRemove(removeMemberModal.member.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -82,7 +85,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
|
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
|
||||||
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
||||||
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
|
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
|
||||||
tHeadClassName="border-b border-white/6"
|
tHeadClassName="border-b border-white/6"
|
||||||
thClassName="text-left font-medium divide-x-0 text-placeholder"
|
thClassName="text-left font-medium divide-x-0 text-placeholder"
|
||||||
tBodyClassName="divide-y-0"
|
tBodyClassName="divide-y-0"
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,12 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
|
||||||
<SearchSelectionDropdown
|
<SearchSelectionDropdown
|
||||||
value={value}
|
value={value}
|
||||||
label={
|
label={
|
||||||
<div className="flex h-3.5 items-center gap-2">
|
<div className="flex min-h-5 min-w-0 items-center gap-2">
|
||||||
{selectedOption && (
|
{selectedOption && (
|
||||||
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
|
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
|
||||||
)}
|
)}
|
||||||
{selectedOption ? (
|
{selectedOption ? (
|
||||||
selectedOption.member?.display_name
|
<span className="truncate">{selectedOption.member?.display_name}</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />
|
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
|
||||||
import { PROJECT_DETAILS } from "@/constants/fetch-keys";
|
import { PROJECT_DETAILS } from "@/constants/fetch-keys";
|
||||||
// hooks
|
// hooks
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
import { useProject } from "@/hooks/store/use-project";
|
||||||
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
import { useUserPermissions } from "@/hooks/store/user";
|
import { useUserPermissions } from "@/hooks/store/user";
|
||||||
// local imports
|
// local imports
|
||||||
import { MemberSelect } from "./member-select";
|
import { MemberSelect } from "./member-select";
|
||||||
|
|
@ -60,6 +61,9 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
||||||
// store hooks
|
// store hooks
|
||||||
const { allowPermissions } = useUserPermissions();
|
const { allowPermissions } = useUserPermissions();
|
||||||
|
|
||||||
|
const {
|
||||||
|
project: { fetchProjectMembers, getProjectMemberFetchStatus },
|
||||||
|
} = useMember();
|
||||||
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
|
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
|
||||||
// derived values
|
// derived values
|
||||||
const isAdmin = allowPermissions(
|
const isAdmin = allowPermissions(
|
||||||
|
|
@ -76,6 +80,14 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
||||||
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
|
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceSlug || !projectId || hasFetchedProjectMembers) return;
|
||||||
|
|
||||||
|
void fetchProjectMembers(workspaceSlug, projectId, true).catch(console.error);
|
||||||
|
}, [fetchProjectMembers, hasFetchedProjectMembers, projectId, workspaceSlug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentProjectDetails) return;
|
if (!currentProjectDetails) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
|
@ -194,7 +193,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
|
||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
position={EModalPosition.CENTER}
|
position={EModalPosition.CENTER}
|
||||||
width={EModalWidth.VIIXL}
|
width={EModalWidth.VIIXL}
|
||||||
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
|
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
|
||||||
>
|
>
|
||||||
{workspaceSlug && activeProjectId ? (
|
{workspaceSlug && activeProjectId ? (
|
||||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}>
|
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}>
|
||||||
|
|
@ -233,7 +232,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
|
||||||
size="sm"
|
size="sm"
|
||||||
className="min-h-0 flex-1 overflow-y-auto"
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">
|
<div className="mx-auto w-full max-w-[82rem] px-5 pb-7 lg:px-6">
|
||||||
<ProjectSettingsModalContent
|
<ProjectSettingsModalContent
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
projectId={activeProjectId}
|
projectId={activeProjectId}
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,22 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
<div className="flex justify-end gap-3 p-4 sm:px-6">
|
||||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
|
||||||
|
>
|
||||||
Отменить
|
Отменить
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleDeletion}
|
||||||
|
loading={isRemoving}
|
||||||
|
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
|
||||||
|
>
|
||||||
{currentUser?.id === userDetails.id
|
{currentUser?.id === userDetails.id
|
||||||
? isRemoving
|
? isRemoving
|
||||||
? "Выход..."
|
? "Выход..."
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
|
||||||
if (isEmpty(columns)) return <MembersLayoutLoader />;
|
if (isEmpty(columns)) return <MembersLayoutLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]">
|
<div className="w-full overflow-visible rounded-[1.2rem]">
|
||||||
{removeMemberModal && (
|
{removeMemberModal && (
|
||||||
<ConfirmWorkspaceMemberRemove
|
<ConfirmWorkspaceMemberRemove
|
||||||
isOpen={removeMemberModal.member.id.length > 0}
|
isOpen={removeMemberModal.member.id.length > 0}
|
||||||
|
|
@ -109,7 +109,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
|
||||||
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
|
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
|
||||||
}
|
}
|
||||||
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
||||||
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
|
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
|
||||||
tHeadClassName="border-b border-white/6"
|
tHeadClassName="border-b border-white/6"
|
||||||
thClassName="text-left font-medium divide-x-0 text-placeholder"
|
thClassName="text-left font-medium divide-x-0 text-placeholder"
|
||||||
tBodyClassName="divide-y-0"
|
tBodyClassName="divide-y-0"
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,14 @@ import {
|
||||||
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
|
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
|
||||||
const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]);
|
const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]);
|
||||||
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
|
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
|
||||||
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]);
|
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>([
|
||||||
|
"general",
|
||||||
|
"members",
|
||||||
|
"export",
|
||||||
|
"storage",
|
||||||
|
"webhooks",
|
||||||
|
"ai-voice-tasker",
|
||||||
|
]);
|
||||||
const workspaceAIService = new WorkspaceAIService();
|
const workspaceAIService = new WorkspaceAIService();
|
||||||
const workspaceService = new WorkspaceService();
|
const workspaceService = new WorkspaceService();
|
||||||
|
|
||||||
|
|
@ -77,7 +84,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const canLoadVoiceTaskerEntitlement =
|
const canLoadVoiceTaskerEntitlement =
|
||||||
!!currentWorkspace?.slug &&
|
!!currentWorkspace?.slug &&
|
||||||
allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, currentWorkspace.slug);
|
allowPermissions(
|
||||||
|
WORKSPACE_SETTINGS["ai-voice-tasker"].access,
|
||||||
|
EUserPermissionsLevel.WORKSPACE,
|
||||||
|
currentWorkspace.slug
|
||||||
|
);
|
||||||
const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR(
|
const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR(
|
||||||
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
|
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
|
||||||
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
|
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
|
||||||
|
|
@ -94,7 +105,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search);
|
const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search);
|
||||||
setIsOpen(Boolean(tab));
|
setIsOpen(Boolean(tab));
|
||||||
if (tab) setActiveTab(tab);
|
if (tab) setActiveTab(tab);
|
||||||
setActiveWebhookId(tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined);
|
setActiveWebhookId(
|
||||||
|
tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalEvent = (event: Event) => {
|
const handleModalEvent = (event: Event) => {
|
||||||
|
|
@ -162,7 +175,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab === "webhooks" && currentWorkspace?.slug) {
|
if (activeTab === "webhooks" && currentWorkspace?.slug) {
|
||||||
return <WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />;
|
return (
|
||||||
|
<WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <WorkspaceDetails />;
|
return <WorkspaceDetails />;
|
||||||
|
|
@ -178,7 +193,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
position={EModalPosition.CENTER}
|
position={EModalPosition.CENTER}
|
||||||
width={EModalWidth.VIIXL}
|
width={EModalWidth.VIIXL}
|
||||||
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
|
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
|
||||||
>
|
>
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
<div className="hidden h-full w-[296px] shrink-0 md:block">
|
<div className="hidden h-full w-[296px] shrink-0 md:block">
|
||||||
|
|
@ -212,7 +227,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
|
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
|
<div className="mx-auto w-full max-w-[78rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -260,7 +275,7 @@ function WorkspaceModalSidebar({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
|
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
|
||||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">
|
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||||
{t(category)}
|
{t(category)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useSWRConfig } from "swr";
|
||||||
|
// plane imports
|
||||||
|
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||||
|
// hooks
|
||||||
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
|
import { useProject } from "@/hooks/store/use-project";
|
||||||
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
|
|
||||||
|
type TNodeDCRealtimeEvent = {
|
||||||
|
event_id?: string;
|
||||||
|
type?: string;
|
||||||
|
workspace_slug?: string;
|
||||||
|
project_id?: string;
|
||||||
|
member_id?: string;
|
||||||
|
affected_user_ids?: string[];
|
||||||
|
project_ids?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_PROCESSED_EVENTS = 250;
|
||||||
|
|
||||||
|
const buildNodeDCStreamUrl = () => {
|
||||||
|
const liveBaseUrl = LIVE_BASE_URL?.trim() || window.location.origin;
|
||||||
|
const liveBasePath = LIVE_BASE_PATH?.trim() || "/live";
|
||||||
|
const url = new URL(liveBaseUrl);
|
||||||
|
|
||||||
|
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
url.pathname = `${liveBasePath.replace(/\/$/, "")}/nodedc/stream`;
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWorkspacePath = (pathname: string | null, workspaceSlug: string) =>
|
||||||
|
pathname === `/${workspaceSlug}` || !!pathname?.startsWith(`/${workspaceSlug}/`);
|
||||||
|
|
||||||
|
const isProjectPath = (pathname: string | null, workspaceSlug: string, projectId: string) =>
|
||||||
|
pathname === `/${workspaceSlug}/projects/${projectId}` ||
|
||||||
|
!!pathname?.startsWith(`/${workspaceSlug}/projects/${projectId}/`);
|
||||||
|
|
||||||
|
const isCurrentUserAffected = (event: TNodeDCRealtimeEvent, currentUserId?: string) => {
|
||||||
|
if (!currentUserId) return false;
|
||||||
|
if (event.member_id === currentUserId) return true;
|
||||||
|
return event.affected_user_ids?.includes(currentUserId) ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useAppRouter();
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const workspaceRoot = useWorkspace();
|
||||||
|
const projectStore = useProject();
|
||||||
|
const memberRoot = useMember();
|
||||||
|
const userStore = useUser();
|
||||||
|
const pathnameRef = useRef(pathname);
|
||||||
|
const currentUserIdRef = useRef(currentUserId);
|
||||||
|
const processedEventIdsRef = useRef<string[]>([]);
|
||||||
|
const processedEventSetRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pathnameRef.current = pathname;
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentUserIdRef.current = currentUserId;
|
||||||
|
}, [currentUserId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || typeof window === "undefined") return;
|
||||||
|
|
||||||
|
let socket: WebSocket | undefined;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
|
||||||
|
const rememberEvent = (eventId?: string) => {
|
||||||
|
if (!eventId) return true;
|
||||||
|
if (processedEventSetRef.current.has(eventId)) return false;
|
||||||
|
|
||||||
|
processedEventIdsRef.current.push(eventId);
|
||||||
|
processedEventSetRef.current.add(eventId);
|
||||||
|
|
||||||
|
if (processedEventIdsRef.current.length > MAX_PROCESSED_EVENTS) {
|
||||||
|
const removedEventId = processedEventIdsRef.current.shift();
|
||||||
|
if (removedEventId) processedEventSetRef.current.delete(removedEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshWorkspaceScope = async (event: TNodeDCRealtimeEvent) => {
|
||||||
|
const workspaceSlug = event.workspace_slug;
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
workspaceRoot.fetchWorkspaces(),
|
||||||
|
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
|
||||||
|
memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug),
|
||||||
|
projectStore.fetchProjects(workspaceSlug),
|
||||||
|
userStore.permission.fetchUserProjectPermissions(workspaceSlug),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === "workspace_member.deleted" &&
|
||||||
|
isCurrentUserAffected(event, currentUserIdRef.current) &&
|
||||||
|
isWorkspacePath(pathnameRef.current, workspaceSlug)
|
||||||
|
) {
|
||||||
|
router.replace(workspaceRoot.getWorkspaceRedirectionUrl());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshProjectScope = async (event: TNodeDCRealtimeEvent) => {
|
||||||
|
const workspaceSlug = event.workspace_slug;
|
||||||
|
const projectId = event.project_id;
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
workspaceRoot.fetchWorkspaces(),
|
||||||
|
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
|
||||||
|
projectStore.fetchProjects(workspaceSlug),
|
||||||
|
userStore.permission.fetchUserProjectPermissions(workspaceSlug),
|
||||||
|
projectId ? memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
projectId &&
|
||||||
|
event.type === "project_member.deleted" &&
|
||||||
|
isCurrentUserAffected(event, currentUserIdRef.current) &&
|
||||||
|
isProjectPath(pathnameRef.current, workspaceSlug, projectId)
|
||||||
|
) {
|
||||||
|
router.replace(`/${workspaceSlug}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshInviteScope = async (event: TNodeDCRealtimeEvent) => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
mutate("USER_WORKSPACE_INVITATIONS_NOTICE"),
|
||||||
|
event.workspace_slug
|
||||||
|
? memberRoot.workspace.fetchWorkspaceMemberInvitations(event.workspace_slug)
|
||||||
|
: Promise.resolve(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshProfileScope = async (event: TNodeDCRealtimeEvent) => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
isCurrentUserAffected(event, currentUserIdRef.current) ? userStore.fetchCurrentUser() : Promise.resolve(),
|
||||||
|
event.workspace_slug ? memberRoot.workspace.fetchWorkspaceMembers(event.workspace_slug) : Promise.resolve(),
|
||||||
|
event.workspace_slug && event.project_id
|
||||||
|
? memberRoot.project.fetchProjectMembers(event.workspace_slug, event.project_id, true)
|
||||||
|
: Promise.resolve(),
|
||||||
|
mutate("USER_INFORMATION"),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEvent = async (event: TNodeDCRealtimeEvent) => {
|
||||||
|
if (!rememberEvent(event.event_id)) return;
|
||||||
|
|
||||||
|
if (event.type?.startsWith("workspace_member.")) {
|
||||||
|
await refreshWorkspaceScope(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type?.startsWith("project_member.")) {
|
||||||
|
await refreshProjectScope(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type?.startsWith("workspace_invite.")) {
|
||||||
|
await refreshInviteScope(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "user.profile.updated") {
|
||||||
|
await refreshProfileScope(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const delay = Math.min(1000 * 2 ** reconnectAttempt, 15000);
|
||||||
|
reconnectAttempt += 1;
|
||||||
|
reconnectTimer = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(buildNodeDCStreamUrl());
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (message) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(message.data) as TNodeDCRealtimeEvent;
|
||||||
|
if (event.type === "nodedc.stream.ping") {
|
||||||
|
socket?.send(JSON.stringify({ type: "nodedc.stream.pong" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === "nodedc.stream.ready") return;
|
||||||
|
|
||||||
|
void handleEvent(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to process NODE.DC realtime event", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
socket = undefined;
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
socket?.close();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to connect NODE.DC realtime stream", error);
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
}, [enabled, memberRoot, mutate, projectStore, router, userStore, workspaceRoot]);
|
||||||
|
};
|
||||||
|
|
@ -18,6 +18,7 @@ import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC }
|
||||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
|
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
|
import { useNodeDCRealtimeEvents } from "@/hooks/use-nodedc-realtime-events";
|
||||||
// services
|
// services
|
||||||
import { WorkspaceService } from "@/services/workspace.service";
|
import { WorkspaceService } from "@/services/workspace.service";
|
||||||
|
|
||||||
|
|
@ -64,6 +65,8 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
|
||||||
const shouldWatchPendingInvites =
|
const shouldWatchPendingInvites =
|
||||||
pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard && pathname !== "/invitations";
|
pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard && pathname !== "/invitations";
|
||||||
|
|
||||||
|
useNodeDCRealtimeEvents(pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard, currentUser?.id);
|
||||||
|
|
||||||
const { data: pendingWorkspaceInvitations } = useSWR(
|
const { data: pendingWorkspaceInvitations } = useSWR(
|
||||||
shouldWatchPendingInvites ? "USER_WORKSPACE_INVITATIONS_NOTICE" : null,
|
shouldWatchPendingInvites ? "USER_WORKSPACE_INVITATIONS_NOTICE" : null,
|
||||||
() => workspaceService.userWorkspaceInvitations(),
|
() => workspaceService.userWorkspaceInvitations(),
|
||||||
|
|
@ -79,7 +82,12 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
|
||||||
|
|
||||||
const inviteKey = pendingWorkspaceInvitations
|
const inviteKey = pendingWorkspaceInvitations
|
||||||
.map((invitation) => invitation.id)
|
.map((invitation) => invitation.id)
|
||||||
.toSorted()
|
.reduce<string[]>((sortedInvitationIds, invitationId) => {
|
||||||
|
const insertAt = sortedInvitationIds.findIndex((sortedInvitationId) => sortedInvitationId > invitationId);
|
||||||
|
if (insertAt === -1) sortedInvitationIds.push(invitationId);
|
||||||
|
else sortedInvitationIds.splice(insertAt, 0, invitationId);
|
||||||
|
return sortedInvitationIds;
|
||||||
|
}, [])
|
||||||
.join(":");
|
.join(":");
|
||||||
if (pendingInviteToastKey.current === inviteKey) return;
|
if (pendingInviteToastKey.current === inviteKey) return;
|
||||||
pendingInviteToastKey.current = inviteKey;
|
pendingInviteToastKey.current = inviteKey;
|
||||||
|
|
|
||||||
|
|
@ -236,15 +236,17 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
|
||||||
fetchWorkspaceMembers = async (workspaceSlug: string) =>
|
fetchWorkspaceMembers = async (workspaceSlug: string) =>
|
||||||
await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => {
|
await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
const nextWorkspaceMemberMap: Record<string, IWorkspaceMembership> = {};
|
||||||
response.forEach((member) => {
|
response.forEach((member) => {
|
||||||
set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at });
|
set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at });
|
||||||
set(this.workspaceMemberMap, [workspaceSlug, member.member.id], {
|
nextWorkspaceMemberMap[member.member.id] = {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
member: member.member.id,
|
member: member.member.id,
|
||||||
role: member.role,
|
role: member.role as EUserPermissions,
|
||||||
is_active: member.is_active,
|
is_active: member.is_active,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
set(this.workspaceMemberMap, workspaceSlug, nextWorkspaceMemberMap);
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,20 @@ export class ProjectStore implements IProjectStore {
|
||||||
}
|
}
|
||||||
const projectsResponse = await this.projectService.getProjects(workspaceSlug);
|
const projectsResponse = await this.projectService.getProjects(workspaceSlug);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
const workspaceId = this.rootStore.workspaceRoot.getWorkspaceBySlug(workspaceSlug)?.id;
|
||||||
|
const projectIds = new Set(projectsResponse.map((project) => project.id));
|
||||||
|
|
||||||
|
if (workspaceId) {
|
||||||
|
Object.values(this.projectMap)
|
||||||
|
.filter((project) => project.workspace === workspaceId && !projectIds.has(project.id))
|
||||||
|
.forEach((project) => {
|
||||||
|
delete this.projectMap[project.id];
|
||||||
|
if (this.rootStore.user.permission.workspaceProjectsPermissions?.[workspaceSlug]) {
|
||||||
|
delete this.rootStore.user.permission.workspaceProjectsPermissions[workspaceSlug][project.id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
projectsResponse.forEach((project) => {
|
projectsResponse.forEach((project) => {
|
||||||
update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
|
update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -188,9 +188,10 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
|
||||||
try {
|
try {
|
||||||
const workspaceResponse = await this.workspaceService.userWorkspaces();
|
const workspaceResponse = await this.workspaceService.userWorkspaces();
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
workspaceResponse.forEach((workspace) => {
|
this.workspaces = workspaceResponse.reduce<Record<string, IWorkspace>>((workspaceMap, workspace) => {
|
||||||
set(this.workspaces, [workspace.id], workspace);
|
workspaceMap[workspace.id] = workspace;
|
||||||
});
|
return workspaceMap;
|
||||||
|
}, {});
|
||||||
});
|
});
|
||||||
return workspaceResponse;
|
return workspaceResponse;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,6 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
|
||||||
const filteredOptions =
|
const filteredOptions =
|
||||||
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
const comboboxProps: any = {
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (multiple) comboboxProps.multiple = true;
|
|
||||||
|
|
||||||
const openDropdown = () => {
|
const openDropdown = () => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
if (referenceElement) referenceElement.focus();
|
if (referenceElement) referenceElement.focus();
|
||||||
|
|
@ -74,7 +66,7 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
|
||||||
|
|
||||||
const closeDropdown = () => {
|
const closeDropdown = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
onClose && onClose();
|
onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
|
@ -85,10 +77,24 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
|
||||||
else openDropdown();
|
else openDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChange = (nextValue: any) => {
|
||||||
|
onChange(nextValue);
|
||||||
|
if (!multiple) closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const comboboxProps: any = {
|
||||||
|
value,
|
||||||
|
onChange: handleChange,
|
||||||
|
disabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (multiple) comboboxProps.multiple = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
|
role="presentation"
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
className={cn("relative flex-shrink-0 text-left", className)}
|
className={cn("relative flex-shrink-0 text-left", className)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue