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):
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"):
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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
# Python imports
import os
import uuid
import random
import string
@ -32,6 +33,8 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
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):
@ -46,7 +49,17 @@ class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
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(
{
"error": "New account creation is disabled. Please contact your site administrator"
@ -140,7 +153,8 @@ class SignUpEndpoint(BaseAPIView):
else 15,
member=user,
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,
)
@ -224,15 +238,9 @@ class SignInEndpoint(BaseAPIView):
},
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
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -288,7 +296,8 @@ class SignInEndpoint(BaseAPIView):
else 15,
member=user,
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,
)
@ -360,6 +369,31 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
def post(self, request):
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:
return Response(
{"error": "Please provide a valid email address"},
@ -410,8 +444,7 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
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)
return Response({"key": key}, status=status.HTTP_200_OK)
@ -443,13 +476,6 @@ class MagicSignInEndpoint(BaseAPIView):
if str(token) == str(user_token):
if User.objects.filter(email=email).exists():
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:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
@ -467,7 +493,9 @@ class MagicSignInEndpoint(BaseAPIView):
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"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",
},
@ -498,7 +526,9 @@ class MagicSignInEndpoint(BaseAPIView):
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"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",
},
@ -506,6 +536,7 @@ class MagicSignInEndpoint(BaseAPIView):
except RequestException as e:
capture_exception(e)
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -561,7 +592,8 @@ class MagicSignInEndpoint(BaseAPIView):
else 15,
member=user,
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,
)

View File

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

View File

@ -30,6 +30,8 @@ from plane.db.models import (
ProjectMember,
)
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):
@ -137,6 +139,40 @@ class OauthEndpoint(BaseAPIView):
id_token = request.data.get("credential", 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:
return Response(
{
@ -174,15 +210,7 @@ class OauthEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
## Login Case
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -239,7 +267,8 @@ class OauthEndpoint(BaseAPIView):
else 15,
member=user,
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,
)
@ -291,6 +320,23 @@ class OauthEndpoint(BaseAPIView):
except User.DoesNotExist:
## 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
if "@" in email:
@ -373,7 +419,8 @@ class OauthEndpoint(BaseAPIView):
else 15,
member=user,
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,
)

View File

@ -12,11 +12,14 @@ from plane.app.serializers import (
)
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.utils.paginator import BasePaginator
from django.db.models import Q, F, Count, Case, When, Value, IntegerField
class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer
model = User
@ -45,14 +48,69 @@ class UserEndpoint(BaseViewSet):
def deactivate(self, request):
# Check all workspace user is active
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(
{
"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,
)
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
user.is_active = False
user.save()

View File

@ -1313,6 +1313,62 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
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):
permission_classes = [
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 user is None:
user = User.objects.create(email=admin_email, username=uuid.uuid4().hex)
user.set_password(uuid.uuid4().hex)
user.set_password(admin_email)
user.save()
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
@ -88,13 +88,11 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
f"Instance succesfully registered with owner: {instance.primary_owner.email}"
f"Instance successfully registered with owner: {instance.primary_owner.email}"
)
)
return
self.stdout.write(self.style.WARNING("Instance could not be registered"))
return
raise CommandError("Instance could not be registered")
else:
self.stdout.write(
self.style.SUCCESS(