Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
287 lines
11 KiB
Python
287 lines
11 KiB
Python
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# See the LICENSE file for details.
|
|
|
|
# Python imports
|
|
import jwt
|
|
from datetime import datetime
|
|
|
|
# Django imports
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.validators import validate_email
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
|
|
# Third Party imports
|
|
from rest_framework.response import Response
|
|
from rest_framework import status
|
|
from rest_framework.permissions import AllowAny
|
|
|
|
# Module imports
|
|
from .base import BaseViewSet, BaseAPIView
|
|
from plane.app.serializers import (
|
|
ProjectMemberInviteSerializer,
|
|
ProjectMemberInvitePublicSerializer,
|
|
)
|
|
from plane.app.permissions import allow_permission, ROLE
|
|
from plane.db.models import (
|
|
ProjectMember,
|
|
Workspace,
|
|
ProjectMemberInvite,
|
|
User,
|
|
WorkspaceMember,
|
|
Project,
|
|
ProjectUserProperty,
|
|
)
|
|
from plane.db.models.project import ProjectNetwork
|
|
from plane.utils.host import base_host
|
|
|
|
|
|
class ProjectInvitationsViewset(BaseViewSet):
|
|
serializer_class = ProjectMemberInviteSerializer
|
|
model = ProjectMemberInvite
|
|
|
|
search_fields = []
|
|
|
|
def get_queryset(self):
|
|
return self.filter_queryset(
|
|
super()
|
|
.get_queryset()
|
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
|
.filter(project_id=self.kwargs.get("project_id"))
|
|
.select_related("project")
|
|
.select_related("workspace", "workspace__owner")
|
|
)
|
|
|
|
@allow_permission([ROLE.ADMIN])
|
|
def create(self, request, slug, project_id):
|
|
emails = request.data.get("emails", [])
|
|
|
|
# Check if email is provided
|
|
if not emails:
|
|
return Response({"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
for email in emails:
|
|
workspace_role = WorkspaceMember.objects.filter(
|
|
workspace__slug=slug, member__email=email.get("email"), is_active=True
|
|
).role
|
|
|
|
if workspace_role in [5, 20] and workspace_role != email.get("role", 5):
|
|
return Response({"error": "You cannot invite a user with different role than workspace role"})
|
|
|
|
workspace = Workspace.objects.get(slug=slug)
|
|
|
|
project_invitations = []
|
|
for email in emails:
|
|
try:
|
|
validate_email(email.get("email"))
|
|
project_invitations.append(
|
|
ProjectMemberInvite(
|
|
email=email.get("email").strip().lower(),
|
|
project_id=project_id,
|
|
workspace_id=workspace.id,
|
|
token=jwt.encode(
|
|
{"email": email, "timestamp": datetime.now().timestamp()},
|
|
settings.SECRET_KEY,
|
|
algorithm="HS256",
|
|
),
|
|
role=email.get("role", 5),
|
|
created_by=request.user,
|
|
)
|
|
)
|
|
except ValidationError:
|
|
return Response(
|
|
{
|
|
"error": f"Invalid email - {email} provided a valid email address is required to send the invite" # noqa: E501
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Create workspace member invite
|
|
project_invitations = ProjectMemberInvite.objects.bulk_create(
|
|
project_invitations, batch_size=10, ignore_conflicts=True
|
|
)
|
|
current_site = base_host(request=request, is_app=True)
|
|
|
|
# Send invitations
|
|
for invitation in project_invitations:
|
|
project_invitations.delay(
|
|
invitation.email,
|
|
project_id,
|
|
invitation.token,
|
|
current_site,
|
|
request.user.email,
|
|
)
|
|
|
|
return Response({"message": "Email sent successfully"}, status=status.HTTP_200_OK)
|
|
|
|
|
|
class UserProjectInvitationsViewset(BaseViewSet):
|
|
serializer_class = ProjectMemberInviteSerializer
|
|
model = ProjectMemberInvite
|
|
|
|
def get_queryset(self):
|
|
return self.filter_queryset(
|
|
super()
|
|
.get_queryset()
|
|
.filter(email=self.request.user.email)
|
|
.select_related("workspace", "workspace__owner", "project")
|
|
)
|
|
|
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
|
def create(self, request, slug):
|
|
project_ids = request.data.get("project_ids", [])
|
|
|
|
# Get the workspace user role
|
|
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
|
|
|
|
# Get all the projects
|
|
projects = Project.objects.filter(id__in=project_ids, workspace__slug=slug).only("id", "network")
|
|
# Check if user has permission to join each project
|
|
for project in projects:
|
|
if project.network == ProjectNetwork.SECRET.value and workspace_member.role != ROLE.ADMIN.value:
|
|
return Response(
|
|
{"error": "Only workspace admins can join private project"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
workspace_role = workspace_member.role
|
|
workspace = workspace_member.workspace
|
|
|
|
# Use the workspace-scoped, network-validated project IDs only.
|
|
# Raw project_ids may contain UUIDs from other workspaces; those are
|
|
# absent from the `projects` queryset and therefore bypass the SECRET
|
|
# network check above (GHSA-45hc-q4mw-jhxm).
|
|
validated_project_ids = [str(p.id) for p in projects]
|
|
|
|
# If the user was already part of workspace
|
|
_ = ProjectMember.objects.filter(
|
|
workspace__slug=slug, project_id__in=validated_project_ids, member=request.user
|
|
).update(is_active=True)
|
|
|
|
ProjectMember.objects.bulk_create(
|
|
[
|
|
ProjectMember(
|
|
project_id=project_id,
|
|
member=request.user,
|
|
role=workspace_role,
|
|
workspace=workspace,
|
|
created_by=request.user,
|
|
)
|
|
for project_id in validated_project_ids
|
|
],
|
|
ignore_conflicts=True,
|
|
)
|
|
|
|
ProjectUserProperty.objects.bulk_create(
|
|
[
|
|
ProjectUserProperty(
|
|
project_id=project_id,
|
|
user=request.user,
|
|
workspace=workspace,
|
|
created_by=request.user,
|
|
)
|
|
for project_id in validated_project_ids
|
|
],
|
|
ignore_conflicts=True,
|
|
)
|
|
|
|
return Response({"message": "Projects joined successfully"}, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class ProjectJoinEndpoint(BaseAPIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
def post(self, request, slug, project_id, pk):
|
|
project_invite = ProjectMemberInvite.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
|
|
|
|
token = request.data.get("token", "")
|
|
|
|
# Validate the token to verify the user received the invitation email
|
|
if not token or project_invite.token != token:
|
|
return Response(
|
|
{"error": "You do not have permission to join the project"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# Require an authenticated session — the accepting user must be the
|
|
# person who was invited. Without this check an attacker who knows the
|
|
# invitee email and obtains the token can hijack the project membership
|
|
# (GHSA-g36h-p63v-g9c7).
|
|
if not request.user.is_authenticated:
|
|
return Response(
|
|
{"error": "Authentication required to accept project invitation"},
|
|
status=status.HTTP_401_UNAUTHORIZED,
|
|
)
|
|
if request.user.email.lower() != project_invite.email.lower():
|
|
return Response(
|
|
{"error": "You do not have permission to accept this invitation"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
if project_invite.responded_at is None:
|
|
accepted = request.data.get("accepted", False)
|
|
if not isinstance(accepted, bool):
|
|
return Response(
|
|
{"error": "`accepted` must be a boolean"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
project_invite.accepted = accepted
|
|
project_invite.responded_at = timezone.now()
|
|
project_invite.save()
|
|
|
|
if project_invite.accepted:
|
|
# Use the authenticated user directly — they've already been
|
|
# validated as the invite recipient above.
|
|
user = request.user
|
|
|
|
# Check if user is a part of workspace
|
|
workspace_member = WorkspaceMember.objects.filter(workspace__slug=slug, member=user).first()
|
|
# Add him to workspace
|
|
if workspace_member is None:
|
|
_ = WorkspaceMember.objects.create(
|
|
workspace_id=project_invite.workspace_id,
|
|
member=user,
|
|
role=(15 if project_invite.role >= 15 else project_invite.role),
|
|
)
|
|
else:
|
|
# Else make him active
|
|
workspace_member.is_active = True
|
|
workspace_member.save()
|
|
|
|
# Check if the user was already a member of project then activate the user
|
|
project_member = ProjectMember.objects.filter(
|
|
workspace_id=project_invite.workspace_id, member=user
|
|
).first()
|
|
if project_member is None:
|
|
# Create a Project Member
|
|
_ = ProjectMember.objects.create(
|
|
project_id=project_id,
|
|
member=user,
|
|
role=project_invite.role,
|
|
)
|
|
else:
|
|
project_member.is_active = True
|
|
project_member.role = project_member.role
|
|
project_member.save()
|
|
|
|
return Response(
|
|
{"message": "Project Invitation Accepted"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
return Response(
|
|
{"message": "Project Invitation was not accepted"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
return Response(
|
|
{"error": "You have already responded to the invitation request"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
def get(self, request, slug, project_id, pk):
|
|
project_invitation = ProjectMemberInvite.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
|
serializer = ProjectMemberInvitePublicSerializer(project_invitation)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|