chore: user deactivation and login restriction (#2855)

* chore: user deactivation

* chore: deactivation and login disabled

* chore: added get configuration value

* chore: serializer message change

* chore: instance admin passowrd change

* chore: removed triage

* chore: v3 endpoint for user profile

* chore: added enable signin

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Bavisetti Narayan 2023-11-24 12:22:24 +05:30 committed by sriram veeraghanta
parent 34e6ef0d8d
commit 0669dab1c4
10 changed files with 252 additions and 54 deletions

View File

@ -159,10 +159,10 @@ class ChangePasswordSerializer(serializers.Serializer):
def validate(self, data): def validate(self, data):
if data.get("old_password") == data.get("new_password"): if data.get("old_password") == data.get("new_password"):
raise serializers.ValidationError("New password cannot be same as old password.") raise serializers.ValidationError({"error": "New password cannot be same as old password."})
if data.get("new_password") != data.get("confirm_password"): if data.get("new_password") != data.get("confirm_password"):
raise serializers.ValidationError("confirm password should be same as the new password.") raise serializers.ValidationError({"error": "Confirm password should be same as the new password."})
return data return data

View File

@ -18,6 +18,7 @@ from plane.app.views import (
WorkspaceUserProfileEndpoint, WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint, WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint, WorkspaceLabelsEndpoint,
WorkspaceUserProfileIssuesGroupedEndpoint
) )
@ -189,6 +190,11 @@ urlpatterns = [
WorkspaceUserProfileIssuesEndpoint.as_view(), WorkspaceUserProfileIssuesEndpoint.as_view(),
name="workspace-user-profile-issues", name="workspace-user-profile-issues",
), ),
path(
"v3/workspaces/<str:slug>/user-issues/<uuid:user_id>/",
WorkspaceUserProfileIssuesGroupedEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path( path(
"workspaces/<str:slug>/labels/", "workspaces/<str:slug>/labels/",
WorkspaceLabelsEndpoint.as_view(), WorkspaceLabelsEndpoint.as_view(),

View File

@ -44,6 +44,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint, WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint, WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint, WorkspaceLabelsEndpoint,
WorkspaceUserProfileIssuesGroupedEndpoint
) )
from .state import StateViewSet from .state import StateViewSet
from .view import ( from .view import (

View File

@ -133,7 +133,7 @@ class ChangePasswordEndpoint(BaseAPIView):
if serializer.is_valid(): if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")): if not user.check_password(serializer.data.get("old_password")):
return Response( return Response(
{"old_password": ["Wrong password."]}, {"error": "Old password is not correct"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# set_password also hashes the password that the user will get # set_password also hashes the password that the user will get

View File

@ -1,4 +1,5 @@
# Python imports # Python imports
import os
import uuid import uuid
import random import random
import string import string
@ -32,6 +33,8 @@ from plane.db.models import (
) )
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
def get_tokens_for_user(user): def get_tokens_for_user(user):
@ -46,7 +49,17 @@ class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
def post(self, request): def post(self, request):
if not settings.ENABLE_SIGNUP: instance_configuration = InstanceConfiguration.objects.values("key", "value")
if (
not get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response( return Response(
{ {
"error": "New account creation is disabled. Please contact your site administrator" "error": "New account creation is disabled. Please contact your site administrator"
@ -140,7 +153,8 @@ class SignUpEndpoint(BaseAPIView):
else 15, else 15,
member=user, member=user,
created_by_id=project_member_invite.created_by_id, created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites )
for project_member_invite in project_member_invites
], ],
ignore_conflicts=True, ignore_conflicts=True,
) )
@ -224,15 +238,9 @@ class SignInEndpoint(BaseAPIView):
}, },
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
# settings last active for the user # settings last active for the user
user.is_active = True
user.last_active = timezone.now() user.last_active = timezone.now()
user.last_login_time = timezone.now() user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR") user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -288,7 +296,8 @@ class SignInEndpoint(BaseAPIView):
else 15, else 15,
member=user, member=user,
created_by_id=project_member_invite.created_by_id, created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites )
for project_member_invite in project_member_invites
], ],
ignore_conflicts=True, ignore_conflicts=True,
) )
@ -360,6 +369,31 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
email = request.data.get("email", False) email = request.data.get("email", False)
instance_configuration = InstanceConfiguration.objects.values("key", "value")
if (
not get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
if not email: if not email:
return Response( return Response(
{"error": "Please provide a valid email address"}, {"error": "Please provide a valid email address"},
@ -410,8 +444,7 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
ri.set(key, json.dumps(value), ex=expiry) ri.set(key, json.dumps(value), ex=expiry)
current_site = request.META.get("HTTP_ORIGIN")
current_site = request.META.get('HTTP_ORIGIN')
magic_link.delay(email, key, token, current_site) magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK) return Response({"key": key}, status=status.HTTP_200_OK)
@ -443,13 +476,6 @@ class MagicSignInEndpoint(BaseAPIView):
if str(token) == str(user_token): if str(token) == str(user_token):
if User.objects.filter(email=email).exists(): if User.objects.filter(email=email).exists():
user = User.objects.get(email=email) user = User.objects.get(email=email)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
try: try:
# Send event to Jitsu for tracking # Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API: if settings.ANALYTICS_BASE_API:
@ -467,7 +493,9 @@ class MagicSignInEndpoint(BaseAPIView):
"user": {"email": email, "id": str(user.id)}, "user": {"email": email, "id": str(user.id)},
"device_ctx": { "device_ctx": {
"ip": request.META.get("REMOTE_ADDR"), "ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"), "user_agent": request.META.get(
"HTTP_USER_AGENT"
),
}, },
"event_type": "SIGN_IN", "event_type": "SIGN_IN",
}, },
@ -498,7 +526,9 @@ class MagicSignInEndpoint(BaseAPIView):
"user": {"email": email, "id": str(user.id)}, "user": {"email": email, "id": str(user.id)},
"device_ctx": { "device_ctx": {
"ip": request.META.get("REMOTE_ADDR"), "ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"), "user_agent": request.META.get(
"HTTP_USER_AGENT"
),
}, },
"event_type": "SIGN_UP", "event_type": "SIGN_UP",
}, },
@ -506,6 +536,7 @@ class MagicSignInEndpoint(BaseAPIView):
except RequestException as e: except RequestException as e:
capture_exception(e) capture_exception(e)
user.is_active = True
user.last_active = timezone.now() user.last_active = timezone.now()
user.last_login_time = timezone.now() user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR") user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -561,7 +592,8 @@ class MagicSignInEndpoint(BaseAPIView):
else 15, else 15,
member=user, member=user,
created_by_id=project_member_invite.created_by_id, created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites )
for project_member_invite in project_member_invites
], ],
ignore_conflicts=True, ignore_conflicts=True,
) )

View File

@ -45,23 +45,23 @@ class ConfigurationEndpoint(BaseAPIView):
get_configuration_value( get_configuration_value(
instance_configuration, instance_configuration,
"EMAIL_HOST_USER", "EMAIL_HOST_USER",
os.environ.get("GITHUB_APP_NAME", None), os.environ.get("EMAIL_HOST_USER", None),
), ),
) )
and bool( and bool(
get_configuration_value( get_configuration_value(
instance_configuration, instance_configuration,
"EMAIL_HOST_PASSWORD", "EMAIL_HOST_PASSWORD",
os.environ.get("GITHUB_APP_NAME", None), os.environ.get("EMAIL_HOST_PASSWORD", None),
) )
) )
) and get_configuration_value( ) and get_configuration_value(
instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0" instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "1"
) == "1" ) == "1"
data["email_password_login"] = ( data["email_password_login"] = (
get_configuration_value( get_configuration_value(
instance_configuration, "ENABLE_EMAIL_PASSWORD", "0" instance_configuration, "ENABLE_EMAIL_PASSWORD", "1"
) )
== "1" == "1"
) )

View File

@ -30,6 +30,8 @@ from plane.db.models import (
ProjectMember, ProjectMember,
) )
from .base import BaseAPIView from .base import BaseAPIView
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
def get_tokens_for_user(user): def get_tokens_for_user(user):
@ -137,6 +139,40 @@ class OauthEndpoint(BaseAPIView):
id_token = request.data.get("credential", False) id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False) client_id = request.data.get("clientId", False)
instance_configuration = InstanceConfiguration.objects.values(
"key", "value"
)
if (
(
not get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID"),
)
or not get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID"),
)
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
if not medium or not id_token: if not medium or not id_token:
return Response( return Response(
{ {
@ -174,15 +210,7 @@ class OauthEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
## Login Case user.is_active = True
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
user.last_active = timezone.now() user.last_active = timezone.now()
user.last_login_time = timezone.now() user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR") user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -239,7 +267,8 @@ class OauthEndpoint(BaseAPIView):
else 15, else 15,
member=user, member=user,
created_by_id=project_member_invite.created_by_id, created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites )
for project_member_invite in project_member_invites
], ],
ignore_conflicts=True, ignore_conflicts=True,
) )
@ -291,6 +320,23 @@ class OauthEndpoint(BaseAPIView):
except User.DoesNotExist: except User.DoesNotExist:
## Signup Case ## Signup Case
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
username = uuid.uuid4().hex username = uuid.uuid4().hex
if "@" in email: if "@" in email:
@ -373,7 +419,8 @@ class OauthEndpoint(BaseAPIView):
else 15, else 15,
member=user, member=user,
created_by_id=project_member_invite.created_by_id, created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites )
for project_member_invite in project_member_invites
], ],
ignore_conflicts=True, ignore_conflicts=True,
) )

View File

@ -12,11 +12,14 @@ from plane.app.serializers import (
) )
from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, IssueActivity, WorkspaceMember from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember
from plane.license.models import Instance, InstanceAdmin from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
from django.db.models import Q, F, Count, Case, When, Value, IntegerField
class UserEndpoint(BaseViewSet): class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer serializer_class = UserSerializer
model = User model = User
@ -45,14 +48,69 @@ class UserEndpoint(BaseViewSet):
def deactivate(self, request): def deactivate(self, request):
# Check all workspace user is active # Check all workspace user is active
user = self.get_object() user = self.get_object()
if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists():
projects_to_deactivate = []
workspaces_to_deactivate = []
projects = ProjectMember.objects.filter(
member=request.user, is_active=True
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
default=0,
output_field=IntegerField(),
)
),
total_members=Count("id"),
)
for project in projects:
if project.other_admin_exists > 0 or (project.total_members == 1):
project.is_active = False
projects_to_deactivate.append(project)
else:
return Response( return Response(
{ {
"error": "You cannot deactivate account as you are a member in some workspaces." "error": "You cannot deactivate account as you are the only admin in some projects."
}, },
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
workspaces = WorkspaceMember.objects.filter(
member=request.user, is_active=True
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
default=0,
output_field=IntegerField(),
)
),
total_members=Count("id"),
)
for workspace in workspaces:
if workspace.other_admin_exists > 0 or (workspace.total_members == 1):
workspace.is_active = False
workspaces_to_deactivate.append(workspace)
else:
return Response(
{
"error": "You cannot deactivate account as you are the only admin in some workspaces."
},
status=status.HTTP_400_BAD_REQUEST,
)
ProjectMember.objects.bulk_update(
projects_to_deactivate, ["is_active"], batch_size=100
)
WorkspaceMember.objects.bulk_update(
workspaces_to_deactivate, ["is_active"], batch_size=100
)
# Deactivate the user # Deactivate the user
user.is_active = False user.is_active = False
user.save() user.save()

View File

@ -1313,6 +1313,62 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
class WorkspaceUserProfileIssuesGroupedEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issue_queryset = (
Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class WorkspaceLabelsEndpoint(BaseAPIView): class WorkspaceLabelsEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
WorkspaceViewerPermission, WorkspaceViewerPermission,

View File

@ -45,7 +45,7 @@ class Command(BaseCommand):
# If the user does not exist create the user and add him to the database # If the user does not exist create the user and add him to the database
if user is None: if user is None:
user = User.objects.create(email=admin_email, username=uuid.uuid4().hex) user = User.objects.create(email=admin_email, username=uuid.uuid4().hex)
user.set_password(uuid.uuid4().hex) user.set_password(admin_email)
user.save() user.save()
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
@ -88,13 +88,11 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
f"Instance succesfully registered with owner: {instance.primary_owner.email}" f"Instance successfully registered with owner: {instance.primary_owner.email}"
) )
) )
return return
raise CommandError("Instance could not be registered")
self.stdout.write(self.style.WARNING("Instance could not be registered"))
return
else: else:
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(