diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index 4c8a04d599c..3a81d668332 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -226,6 +226,7 @@ def patch(self, request, slug, project_id, pk): def delete(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) project_member.is_active = False + project_member.access_revoked = True project_member.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py index 45c997596fd..9f6ca381e0f 100644 --- a/apps/api/plane/app/views/project/invite.py +++ b/apps/api/plane/app/views/project/invite.py @@ -154,9 +154,31 @@ def create(self, request, slug): # 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 + # Admin-removed members cannot self-join until an admin re-adds or invites them + revoked_project_ids = list( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id__in=validated_project_ids, + member=request.user, + is_active=False, + access_revoked=True, + ).values_list("project_id", flat=True) + ) + if revoked_project_ids: + return Response( + { + "error": "Your access to this project was revoked. Ask a project admin to add you again." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + # Reactivate only voluntary leavers (not admin-revoked) _ = ProjectMember.objects.filter( - workspace__slug=slug, project_id__in=validated_project_ids, member=request.user + workspace__slug=slug, + project_id__in=validated_project_ids, + member=request.user, + is_active=False, + access_revoked=False, ).update(is_active=True) ProjectMember.objects.bulk_create( @@ -167,6 +189,7 @@ def create(self, request, slug): role=workspace_role, workspace=workspace, created_by=request.user, + access_revoked=False, ) for project_id in validated_project_ids ], @@ -251,7 +274,7 @@ def post(self, request, slug, project_id, pk): # 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 + workspace_id=project_invite.workspace_id, project_id=project_id, member=user ).first() if project_member is None: # Create a Project Member @@ -259,10 +282,12 @@ def post(self, request, slug, project_id, pk): project_id=project_id, member=user, role=project_invite.role, + access_revoked=False, ) else: project_member.is_active = True - project_member.role = project_member.role + project_member.role = project_invite.role + project_member.access_revoked = False project_member.save() return Response( diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 973462182ab..1710c0ca8f9 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -89,10 +89,13 @@ def create(self, request, slug, project_id): ): project_member.role = member_roles[str(project_member.member_id)] project_member.is_active = True + project_member.access_revoked = False bulk_project_members.append(project_member) # Update the roles of the existing members - ProjectMember.objects.bulk_update(bulk_project_members, ["is_active", "role"], batch_size=100) + ProjectMember.objects.bulk_update( + bulk_project_members, ["is_active", "role", "access_revoked"], batch_size=100 + ) # Get the minimum sort_order for each member in the workspace member_sort_orders = ( @@ -280,10 +283,17 @@ def partial_update(self, request, slug, project_id, pk): status=status.HTTP_403_FORBIDDEN, ) + was_active = project_member.is_active serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True) if serializer.is_valid(): serializer.save() + if was_active and serializer.instance.is_active is False: + serializer.instance.access_revoked = True + serializer.instance.save(update_fields=["access_revoked", "updated_at"]) + elif (not was_active) and serializer.instance.is_active is True: + serializer.instance.access_revoked = False + serializer.instance.save(update_fields=["access_revoked", "updated_at"]) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -317,6 +327,7 @@ def destroy(self, request, slug, project_id, pk): ) project_member.is_active = False + project_member.access_revoked = True project_member.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -343,8 +354,9 @@ def leave(self, request, slug, project_id): }, status=status.HTTP_400_BAD_REQUEST, ) - # Deactivate the user + # Deactivate the user (voluntary leave — public self-join still allowed) project_member.is_active = False + project_member.access_revoked = False project_member.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py index 0c61bce4419..905d351a222 100644 --- a/apps/api/plane/app/views/workspace/invite.py +++ b/apps/api/plane/app/views/workspace/invite.py @@ -206,6 +206,7 @@ def post(self, request, slug, pk): ).first() if workspace_member is not None: workspace_member.is_active = True + workspace_member.access_revoked = False workspace_member.role = workspace_invite.role workspace_member.save() else: @@ -214,6 +215,7 @@ def post(self, request, slug, pk): workspace=workspace_invite.workspace, member=user, role=workspace_invite.role, + access_revoked=False, ) # Set the user last_workspace_id to the accepted workspace @@ -287,7 +289,7 @@ def create(self, request): ) # Update the WorkspaceMember for this specific invitation WorkspaceMember.objects.filter(workspace_id=invitation.workspace_id, member=request.user).update( - is_active=True, role=invitation.role + is_active=True, role=invitation.role, access_revoked=False ) # Track event diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py index 67c7637a8c2..bb901e007ed 100644 --- a/apps/api/plane/app/views/workspace/member.py +++ b/apps/api/plane/app/views/workspace/member.py @@ -143,9 +143,10 @@ def destroy(self, request, slug, pk): # 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 - ).update(is_active=False, updated_at=timezone.now()) + ).update(is_active=False, access_revoked=True, updated_at=timezone.now()) workspace_member.is_active = False + workspace_member.access_revoked = True workspace_member.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -199,8 +200,9 @@ def leave(self, request, slug): workspace__slug=slug, member_id=workspace_member.member_id, is_active=True ).update(is_active=False, updated_at=timezone.now()) - # # Deactivate the user + # # Deactivate the user (voluntary leave) workspace_member.is_active = False + workspace_member.access_revoked = False workspace_member.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/db/migrations/0123_projectmember_access_revoked.py b/apps/api/plane/db/migrations/0123_projectmember_access_revoked.py new file mode 100644 index 00000000000..569eaaf15e2 --- /dev/null +++ b/apps/api/plane/db/migrations/0123_projectmember_access_revoked.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0122_alter_draftissue_assignees_alter_issue_assignees_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="projectmember", + name="access_revoked", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/api/plane/db/migrations/0124_workspacemember_access_revoked.py b/apps/api/plane/db/migrations/0124_workspacemember_access_revoked.py new file mode 100644 index 00000000000..ac5ca54b89a --- /dev/null +++ b/apps/api/plane/db/migrations/0124_workspacemember_access_revoked.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0123_projectmember_access_revoked"), + ] + + operations = [ + migrations.AddField( + model_name="workspacemember", + name="access_revoked", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/api/plane/db/migrations/0125_mark_inactive_members_access_revoked.py b/apps/api/plane/db/migrations/0125_mark_inactive_members_access_revoked.py new file mode 100644 index 00000000000..243926394bc --- /dev/null +++ b/apps/api/plane/db/migrations/0125_mark_inactive_members_access_revoked.py @@ -0,0 +1,27 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.db import migrations + + +def mark_inactive_members_revoked(apps, schema_editor): + WorkspaceMember = apps.get_model("db", "WorkspaceMember") + ProjectMember = apps.get_model("db", "ProjectMember") + WorkspaceMember.objects.filter(is_active=False).update(access_revoked=True) + ProjectMember.objects.filter(is_active=False).update(access_revoked=True) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0124_workspacemember_access_revoked"), + ] + + operations = [ + migrations.RunPython(mark_inactive_members_revoked, noop_reverse), + ] diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index 4039b1d2903..5255b129fd5 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -222,6 +222,8 @@ class ProjectMember(ProjectBaseModel): preferences = models.JSONField(default=get_default_preferences) sort_order = models.FloatField(default=65535) is_active = models.BooleanField(default=True) + # Set when an admin removes the member; blocks self-join until admin re-adds/invites + access_revoked = models.BooleanField(default=False) def save(self, *args, **kwargs): if self._state.adding and self.member: diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index 80a3e3e3e42..e60decc5e26 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -208,6 +208,8 @@ class WorkspaceMember(BaseModel): default_props = models.JSONField(default=get_default_props) issue_props = models.JSONField(default=get_issue_props) is_active = models.BooleanField(default=True) + # Set when an admin removes the member; blocks rejoin until invited again + access_revoked = models.BooleanField(default=False) getting_started_checklist = models.JSONField(default=dict) tips = models.JSONField(default=dict) explored_features = models.JSONField(default=dict) diff --git a/apps/api/plane/tests/unit/utils/test_project_access_revoked.py b/apps/api/plane/tests/unit/utils/test_project_access_revoked.py new file mode 100644 index 00000000000..bcf8b85cd58 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_project_access_revoked.py @@ -0,0 +1,13 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest + +from plane.db.models import ProjectMember + + +@pytest.mark.unit +def test_project_member_has_access_revoked_field(): + field = ProjectMember._meta.get_field("access_revoked") + assert field.default is False diff --git a/apps/web/core/components/project/confirm-project-member-remove.tsx b/apps/web/core/components/project/confirm-project-member-remove.tsx index 52a7c30600e..96ae8126997 100644 --- a/apps/web/core/components/project/confirm-project-member-remove.tsx +++ b/apps/web/core/components/project/confirm-project-member-remove.tsx @@ -73,7 +73,8 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember ) : ( <> Are you sure you want to remove member- {data?.display_name}? - They will no longer have access to this project. This action cannot be undone. + They will no longer have access to this project and cannot rejoin until you add or invite them + again. > )}
@@ -85,7 +86,7 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember -