diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 5d757ef57..ac0c3d711 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -35,6 +35,8 @@ from plane.license.models import Instance, InstanceAdmin from plane.utils.cache import cache_response, invalidate_cache from plane.utils.paginator import BasePaginator from plane.authentication.utils.host import user_ip +from plane.bgtasks.user_deactivation_email_task import user_deactivation_email +from plane.utils.host import base_host class UserEndpoint(BaseViewSet): @@ -192,6 +194,11 @@ class UserEndpoint(BaseViewSet): user.last_logout_time = timezone.now() user.save() + # Send an email to the user + user_deactivation_email.delay( + base_host(request=request, is_app=True), user.id + ) + # Logout the user logout(request) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 5876e934f..906d55700 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -18,6 +18,8 @@ from plane.db.models import ( ) from plane.license.utils.instance_value import get_configuration_value from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES +from plane.bgtasks.user_activation_email_task import user_activation_email +from plane.authentication.utils.host import base_host class Adapter: @@ -120,6 +122,13 @@ class Adapter: user.last_login_ip = self.request.META.get("REMOTE_ADDR") user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") user.token_updated_at = timezone.now() + # If user is not active, send the activation email and set the user as active + if not user.is_active: + user_activation_email.delay( + base_host(request=self.request), user.id + ) + # Set user as active + user.is_active = True user.save() return user @@ -168,12 +177,6 @@ class Adapter: # Create profile Profile.objects.create(user=user) - if not user.is_active: - raise AuthenticationException( - AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - # Save user data user = self.save_user_data(user=user) diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index 5b3ac7337..6af8859fc 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -95,17 +95,7 @@ class EmailCheckEndpoint(APIView): # If existing user if existing_user: - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - return Response( - exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST - ) - + # Return response return Response( { "existing": True, diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index f21e431a4..08a3e8b01 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -107,22 +107,6 @@ class SignInAuthEndpoint(View): ) return HttpResponseRedirect(url) - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), - ) - return HttpResponseRedirect(url) - try: provider = EmailProvider( request=request, @@ -222,22 +206,6 @@ class SignUpAuthEndpoint(View): if existing_user: # Existing User - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), - ) - return HttpResponseRedirect(url) - exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py index bb3c72534..8b2d1accd 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -112,22 +112,6 @@ class MagicSignInEndpoint(View): ) return HttpResponseRedirect(url) - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), - ) - return HttpResponseRedirect(url) - try: provider = MagicCodeProvider( request=request, diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index a86a29c09..560ae0e31 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -93,17 +93,7 @@ class EmailCheckSpaceEndpoint(APIView): # If existing user if existing_user: - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - return Response( - exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST - ) - + # Return response return Response( { "existing": True, diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index 7a5613a75..4329ed26d 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -89,19 +89,6 @@ class SignInAuthSpaceEndpoint(View): url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" - return HttpResponseRedirect(url) - try: provider = EmailProvider( request=request, key=email, code=password, is_signup=False @@ -178,19 +165,6 @@ class SignUpAuthSpaceEndpoint(View): existing_user = User.objects.filter(email=email).first() if existing_user: - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" - return HttpResponseRedirect(url) - exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index 52a1d0422..838039f96 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -101,18 +101,6 @@ class MagicSignInSpaceEndpoint(View): return HttpResponseRedirect(url) # Active User - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" - return HttpResponseRedirect(url) try: provider = MagicCodeProvider( request=request, key=f"magic_{email}", code=code diff --git a/apiserver/plane/bgtasks/user_activation_email_task.py b/apiserver/plane/bgtasks/user_activation_email_task.py new file mode 100644 index 000000000..2fdfc4ddb --- /dev/null +++ b/apiserver/plane/bgtasks/user_activation_email_task.py @@ -0,0 +1,70 @@ +# Python imports +import logging + +# Django imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import User +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def user_activation_email(current_site, user_id): + try: + # Send email to user when account is activated + user = User.objects.get(id=user_id) + subject = f"{user.first_name or user.display_name or user.email} has been activated on Plane" + + context = { + "email": str(user.email), + "profile_url": current_site + "/profile", + } + + # Send email to user + html_content = render_to_string( + "emails/user/user_activation.html", context + ) + + text_content = strip_tags(html_content) + # Configure email connection from the database + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[user.email], + connection=connection, + ) + + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apiserver/plane/bgtasks/user_deactivation_email_task.py b/apiserver/plane/bgtasks/user_deactivation_email_task.py new file mode 100644 index 000000000..fa8523d50 --- /dev/null +++ b/apiserver/plane/bgtasks/user_deactivation_email_task.py @@ -0,0 +1,72 @@ +# Python imports +import logging + +# Django imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import User +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def user_deactivation_email(current_site, user_id): + try: + # Send email to user when account is deactivated + user = User.objects.get(id=user_id) + subject = f"{user.first_name or user.display_name or user.email} has been deactivated on Plane" + + context = { + "email": str(user.email), + "login_url": current_site + "/login", + } + + # Send email to user + html_content = render_to_string( + "emails/user/user_deactivation.html", context + ) + + text_content = strip_tags(html_content) + # Configure email connection from the database + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + # Send email + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[user.email], + connection=connection, + ) + + # Attach HTML content + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apiserver/plane/utils/host.py b/apiserver/plane/utils/host.py new file mode 100644 index 000000000..4046c1e20 --- /dev/null +++ b/apiserver/plane/utils/host.py @@ -0,0 +1,42 @@ +# Python imports +from urllib.parse import urlsplit + +# Django imports +from django.conf import settings + + +def base_host(request, is_admin=False, is_space=False, is_app=False): + """Utility function to return host / origin from the request""" + # Calculate the base origin from request + base_origin = str( + request.META.get("HTTP_ORIGIN") + or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}" + or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}""" + ) + + # Admin redirections + if is_admin: + if settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + else: + return base_origin + "/god-mode/" + + # Space redirections + if is_space: + if settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + else: + return base_origin + "/spaces/" + + # App Redirection + if is_app: + if settings.APP_BASE_URL: + return settings.APP_BASE_URL + else: + return base_origin + + return base_origin + + +def user_ip(request): + return str(request.META.get("REMOTE_ADDR")) diff --git a/apiserver/templates/emails/user/user_activation.html b/apiserver/templates/emails/user/user_activation.html new file mode 100644 index 000000000..1ec60e955 --- /dev/null +++ b/apiserver/templates/emails/user/user_activation.html @@ -0,0 +1,1570 @@ + + + + + + + + Your Plane account is now active + + + + + + + + + + + + + + + diff --git a/apiserver/templates/emails/user/user_deactivation.html b/apiserver/templates/emails/user/user_deactivation.html new file mode 100644 index 000000000..b6bc7b768 --- /dev/null +++ b/apiserver/templates/emails/user/user_deactivation.html @@ -0,0 +1,1571 @@ + + + + + + + + Your Plane account has been deactivated + + + + + + + + + + + + + + +