diff --git a/admin/package.json b/admin/package.json index 713a83e57..8d20fd5c4 100644 --- a/admin/package.json +++ b/admin/package.json @@ -14,6 +14,7 @@ "@headlessui/react": "^1.7.19", "@plane/types": "*", "@plane/ui": "*", + "@plane/constants": "*", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py index c069467a2..fd18ea87b 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -11,6 +11,7 @@ from plane.app.views import ( UserEndpoint, UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, + UserSessionEndpoint, ## End User ## Workspaces UserWorkSpacesEndpoint, @@ -29,6 +30,11 @@ urlpatterns = [ ), name="users", ), + path( + "users/session/", + UserSessionEndpoint.as_view(), + name="user-session", + ), path( "users/me/settings/", UserEndpoint.as_view( diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index e652e003e..bf765e719 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -222,4 +222,4 @@ from .error_404 import custom_404_view from .exporter.base import ExportIssuesEndpoint from .notification.base import MarkAllReadNotificationViewSet -from .user.base import AccountEndpoint, ProfileEndpoint +from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 9fb514d11..805f2a9f7 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -6,6 +6,7 @@ from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response +from rest_framework.permissions import AllowAny # Module imports from plane.app.serializers import ( @@ -180,6 +181,25 @@ class UserEndpoint(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) +class UserSessionEndpoint(BaseAPIView): + + permission_classes = [ + AllowAny, + ] + + def get(self, request): + if request.user.is_authenticated: + user = User.objects.get(pk=request.user.id) + serializer = UserMeSerializer(user) + data = {"is_authenticated": True} + data["user"] = serializer.data + return Response(data, status=status.HTTP_200_OK) + else: + return Response( + {"is_authenticated": False}, status=status.HTTP_200_OK + ) + + class UpdateUserOnBoardedEndpoint(BaseAPIView): @invalidate_cache(path="/api/users/me/") diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 24a3d7302..830ae1dc2 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -96,6 +96,7 @@ class WorkSpaceViewSet(BaseViewSet): @invalidate_cache(path="/api/workspaces/", user=False) @invalidate_cache(path="/api/users/me/workspaces/") + @invalidate_cache(path="/api/instances/", user=False) def create(self, request): try: serializer = WorkSpaceSerializer(data=request.data) @@ -151,8 +152,12 @@ class WorkSpaceViewSet(BaseViewSet): return super().partial_update(request, *args, **kwargs) @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False) - @invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False) + @invalidate_cache( + path="/api/users/me/workspaces/", multiple=True, user=False + ) + @invalidate_cache( + path="/api/users/me/settings/", multiple=True, user=False + ) def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 97d0bf908..c8e7bd316 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -100,6 +100,12 @@ class Adapter: user.save() Profile.objects.create(user=user) + if not user.is_active: + raise AuthenticationException( + AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + # Update user details user.last_login_medium = self.provider user.last_active = timezone.now() diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 73809b9ad..aefceb3eb 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -4,6 +4,9 @@ AUTHENTICATION_ERROR_CODES = { "INVALID_EMAIL": 5005, "EMAIL_REQUIRED": 5010, "SIGNUP_DISABLED": 5015, + "MAGIC_LINK_LOGIN_DISABLED": 5016, + "PASSWORD_LOGIN_DISABLED": 5018, + "USER_ACCOUNT_DEACTIVATED": 5019, # Password strength "INVALID_PASSWORD": 5020, "SMTP_NOT_CONFIGURED": 5025, @@ -35,6 +38,7 @@ AUTHENTICATION_ERROR_CODES = { "EXPIRED_PASSWORD_TOKEN": 5130, # Change password "INCORRECT_OLD_PASSWORD": 5135, + "MISSING_PASSWORD": 5138, "INVALID_NEW_PASSWORD": 5140, # set passowrd "PASSWORD_ALREADY_SET": 5145, @@ -47,6 +51,7 @@ AUTHENTICATION_ERROR_CODES = { "ADMIN_AUTHENTICATION_FAILED": 5175, "ADMIN_USER_ALREADY_EXIST": 5180, "ADMIN_USER_DOES_NOT_EXIST": 5185, + "ADMIN_USER_DEACTIVATED": 5190, } diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py index 430c6db2a..6306399aa 100644 --- a/apiserver/plane/authentication/provider/credentials/email.py +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -1,3 +1,6 @@ +# Python imports +import os + # Module imports from plane.authentication.adapter.credential import CredentialAdapter from plane.db.models import User @@ -5,6 +8,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) +from plane.license.utils.instance_value import get_configuration_value class EmailProvider(CredentialAdapter): @@ -23,6 +27,21 @@ class EmailProvider(CredentialAdapter): self.code = code self.is_signup = is_signup + (ENABLE_EMAIL_PASSWORD,) = get_configuration_value( + [ + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD"), + }, + ] + ) + + if ENABLE_EMAIL_PASSWORD == "0": + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["ENABLE_EMAIL_PASSWORD"], + error_message="ENABLE_EMAIL_PASSWORD", + ) + def set_user_data(self): if self.is_signup: # Check if the user already exists diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py index c1207d14d..1496544c7 100644 --- a/apiserver/plane/authentication/provider/credentials/magic_code.py +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -26,23 +26,20 @@ class MagicCodeProvider(CredentialAdapter): code=None, ): - (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( - get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD"), - }, - ] - ) + ( + EMAIL_HOST, + ENABLE_MAGIC_LINK_LOGIN, + ) = get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + ] ) if not (EMAIL_HOST): @@ -52,6 +49,15 @@ class MagicCodeProvider(CredentialAdapter): payload={"email": str(self.key)}, ) + if ENABLE_MAGIC_LINK_LOGIN == "0": + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "MAGIC_LINK_LOGIN_DISABLED" + ], + error_message="MAGIC_LINK_LOGIN_DISABLED", + payload={"email": str(self.key)}, + ) + super().__init__(request, self.provider) self.key = key self.code = code diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index 0abefd79f..2b7e4075a 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -24,62 +24,59 @@ class EmailCheckSignUpEndpoint(APIView): ] def post(self, request): - # Check instance configuration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], - error_message="INSTANCE_NOT_CONFIGURED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email", False) - - # Return error if email is not present - if not email: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], - error_message="EMAIL_REQUIRED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate email try: + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + email = request.data.get("email", False) + + # Return error if email is not present + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], + error_message="EMAIL_REQUIRED", + ) + + # Validate email validate_email(email) + + existing_user = User.objects.filter(email=email).first() + + if existing_user: + # check if the account is the deactivated + if not existing_user.is_active: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ALREADY_EXIST" + ], + error_message="USER_ALREADY_EXIST", + ) + return Response( + {"status": True}, + status=status.HTTP_200_OK, + ) except ValidationError: - exc = AuthenticationException( + raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], error_message="INVALID_EMAIL", ) + except AuthenticationException as e: return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, + e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) - existing_user = User.objects.filter(email=email).first() - - if existing_user: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], - error_message="USER_ALREADY_EXIST", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - return Response( - {"status": True}, - status=status.HTTP_200_OK, - ) - class EmailCheckSignInEndpoint(APIView): @@ -88,61 +85,59 @@ class EmailCheckSignInEndpoint(APIView): ] def post(self, request): - # Check instance configuration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], - error_message="INSTANCE_NOT_CONFIGURED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email", False) - - # Return error if email is not present - if not email: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], - error_message="EMAIL_REQUIRED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate email try: + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + + email = request.data.get("email", False) + + # Return error if email is not present + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], + error_message="EMAIL_REQUIRED", + ) + + # Validate email validate_email(email) + + existing_user = User.objects.filter(email=email).first() + + # If existing user + if existing_user: + # Raise different exception when user is not active + if not existing_user.is_active: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + + return Response( + { + "status": True, + "is_password_autoset": existing_user.is_password_autoset, + }, + status=status.HTTP_200_OK, + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) except ValidationError: - exc = AuthenticationException( + raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], error_message="INVALID_EMAIL", ) + except AuthenticationException as e: return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, + e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) - - existing_user = User.objects.filter(email=email).first() - - if existing_user: - return Response( - { - "status": True, - "is_password_autoset": existing_user.is_password_autoset, - }, - status=status.HTTP_200_OK, - ) - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], - error_message="USER_DOES_NOT_EXIST", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 4093be108..52fcdbc24 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -90,7 +90,9 @@ class SignInAuthEndpoint(View): ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): + existing_user = User.objects.filter(email=email).first() + + if not existing_user: exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], error_message="USER_DOES_NOT_EXIST", @@ -105,6 +107,22 @@ 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, key=email, code=password, is_signup=False @@ -197,7 +215,26 @@ class SignUpAuthEndpoint(View): ) return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): + existing_user = User.objects.filter(email=email).first() + + 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 0fa529674..3335eda7d 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -95,7 +95,26 @@ class MagicSignInEndpoint(View): ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): + # Existing User + existing_user = User.objects.filter(email=email).first() + + if not 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), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], error_message="USER_DOES_NOT_EXIST", @@ -167,8 +186,25 @@ class MagicSignUpEndpoint(View): "?" + urlencode(params), ) return HttpResponseRedirect(url) + # Existing user + existing_user = User.objects.filter(email=email).first() + if not 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) - if User.objects.filter(email=email).exists(): exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py index b26b57760..dd14ceb91 100644 --- a/apiserver/plane/authentication/views/app/password_management.py +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -63,23 +63,13 @@ class ForgotPasswordEndpoint(APIView): status=status.HTTP_400_BAD_REQUEST, ) - (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( - get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD"), - }, - ] - ) + (EMAIL_HOST,) = get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + ] ) if not (EMAIL_HOST): diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index 16ac058b0..3d17e93f5 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -36,55 +36,60 @@ class CSRFTokenEndpoint(APIView): class ChangePasswordEndpoint(APIView): def post(self, request): - serializer = ChangePasswordSerializer(data=request.data) user = User.objects.get(pk=request.user.id) - if serializer.is_valid(): - if not user.check_password(serializer.data.get("old_password")): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INCORRECT_OLD_PASSWORD" - ], - error_message="INCORRECT_OLD_PASSWORD", - payload={"error": "Old password is not correct"}, - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - # check the password score - results = zxcvbn(serializer.data.get("new_password")) - if results["score"] < 3: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_NEW_PASSWORD" - ], - error_message="INVALID_NEW_PASSWORD", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + old_password = request.data.get("old_password", False) + new_password = request.data.get("new_password", False) - # set_password also hashes the password that the user will get - user.set_password(serializer.data.get("new_password")) - user.is_password_autoset = False - user.save() - user_login(user=user, request=request, is_app=True) - return Response( - {"message": "Password updated successfully"}, - status=status.HTTP_200_OK, + if not old_password or not new_password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"], + error_message="MISSING_PASSWORD", + payload={"error": "Old or new password is missing"}, + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, ) - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + if not user.check_password(old_password): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INCORRECT_OLD_PASSWORD" + ], + error_message="INCORRECT_OLD_PASSWORD", + payload={"error": "Old password is not correct"}, + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + # check the password score + results = zxcvbn(new_password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INVALID_NEW_PASSWORD" + ], + error_message="INVALID_NEW_PASSWORD", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + # set_password also hashes the password that the user will get + user.set_password(new_password) + user.is_password_autoset = False + user.save() + user_login(user=user, request=request, is_app=True) + return Response( + {"message": "Password updated successfully"}, + status=status.HTTP_200_OK, + ) + class SetUserPasswordEndpoint(APIView): def post(self, request): user = User.objects.get(pk=request.user.id) diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index 49baad081..83f52e28f 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -68,6 +68,17 @@ 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( { "existing": True, diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index e11ab29b5..73690e2fa 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -83,7 +83,10 @@ class SignInAuthSpaceEndpoint(View): ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): + # Existing User + existing_user = User.objects.filter(email=email).first() + + if not existing_user: exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], error_message="USER_DOES_NOT_EXIST", @@ -98,6 +101,22 @@ class SignInAuthSpaceEndpoint(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_space=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + try: provider = EmailProvider( request=request, key=email, code=password, is_signup=False @@ -185,7 +204,26 @@ class SignUpAuthSpaceEndpoint(View): ) return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): + # Existing User + 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 = urljoin( + 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 45a8e3755..650b8955a 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -90,11 +90,14 @@ class MagicSignInSpaceEndpoint(View): ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_DOES_NOT_EXIST", - "error_message": "User could not be found with the given email.", - } + existing_user = User.objects.filter(email=email).first() + + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + params = exc.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( @@ -103,6 +106,22 @@ 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 = urljoin( + base_host(request=request, is_space=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) try: provider = MagicCodeProvider( request=request, key=f"magic_{email}", code=code @@ -155,8 +174,25 @@ class MagicSignUpSpaceEndpoint(View): "?" + urlencode(params), ) return HttpResponseRedirect(url) + # Existing User + 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 = urljoin( + base_host(request=request, is_space=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py index 655d8b1c8..58bf54b80 100644 --- a/apiserver/plane/authentication/views/space/signout.py +++ b/apiserver/plane/authentication/views/space/signout.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # Django imports from django.views import View from django.contrib.auth import logout diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py index 63b602518..facea7e9c 100644 --- a/apiserver/plane/db/management/commands/test_email.py +++ b/apiserver/plane/db/management/commands/test_email.py @@ -1,6 +1,9 @@ from django.core.mail import EmailMultiAlternatives, get_connection from django.core.management import BaseCommand, CommandError +from django.template.loader import render_to_string +from django.utils.html import strip_tags +# Module imports from plane.license.utils.instance_value import get_email_configuration @@ -37,10 +40,10 @@ class Command(BaseCommand): timeout=30, ) # Prepare email details - subject = "Email Notification from Plane" - message = ( - "This is a sample email notification sent from Plane application." - ) + subject = "Test email from Plane" + + html_content = render_to_string("emails/test_email.html") + text_content = strip_tags(html_content) self.stdout.write(self.style.SUCCESS("Trying to send test email...")) @@ -48,11 +51,14 @@ class Command(BaseCommand): try: msg = EmailMultiAlternatives( subject=subject, - body=message, + body=text_content, from_email=EMAIL_FROM, - to=[receiver_email], + to=[ + receiver_email, + ], connection=connection, ) + msg.attach_alternative(html_content, "text/html") msg.send() self.stdout.write(self.style.SUCCESS("Email successfully sent")) except Exception as e: diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index cddaff0eb..b10702b8a 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -16,4 +16,5 @@ from .admin import ( InstanceAdminSignUpEndpoint, InstanceAdminUserMeEndpoint, InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, ) diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index 945f4b1b1..0abac6b14 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -316,6 +316,20 @@ class InstanceAdminSignInEndpoint(View): # Fetch the user user = User.objects.filter(email=email).first() + # is_active + if not user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "ADMIN_USER_DEACTIVATED" + ], + error_message="ADMIN_USER_DEACTIVATED", + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + # Error out if the user is not present if not user: exc = AuthenticationException( @@ -395,6 +409,30 @@ class InstanceAdminUserMeEndpoint(BaseAPIView): ) +class InstanceAdminUserSessionEndpoint(BaseAPIView): + + permission_classes = [ + AllowAny, + ] + + def get(self, request): + if ( + request.user.is_authenticated + and InstanceAdmin.objects.filter(user=request.user).exists() + ): + serializer = InstanceAdminMeSerializer(request.user) + data = {"is_authenticated": True} + data["user"] = serializer.data + return Response( + data, + status=status.HTTP_200_OK, + ) + else: + return Response( + {"is_authenticated": False}, status=status.HTTP_200_OK + ) + + class InstanceAdminSignOutEndpoint(View): permission_classes = [ diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index b95ae74d6..b4f19e52c 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -10,6 +10,7 @@ from plane.license.api.views import ( SignUpScreenVisitedEndpoint, InstanceAdminUserMeEndpoint, InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, ) urlpatterns = [ @@ -28,6 +29,11 @@ urlpatterns = [ InstanceAdminUserMeEndpoint.as_view(), name="instance-admins", ), + path( + "admins/session/", + InstanceAdminUserSessionEndpoint.as_view(), + name="instance-admin-session", + ), path( "admins/sign-out/", InstanceAdminSignOutEndpoint.as_view(), diff --git a/apiserver/templates/emails/test_email.html b/apiserver/templates/emails/test_email.html new file mode 100644 index 000000000..4e4d3d940 --- /dev/null +++ b/apiserver/templates/emails/test_email.html @@ -0,0 +1,6 @@ + + +
This is a test email sent to verify if email configuration is working as expected in your Plane instance.
+ +Regards, Team Plane
+ \ No newline at end of file diff --git a/nginx/nginx.conf.dev b/nginx/nginx.conf.dev index c87849dd8..869c2e807 100644 --- a/nginx/nginx.conf.dev +++ b/nginx/nginx.conf.dev @@ -28,7 +28,7 @@ http { } location /god-mode/ { - proxy_pass http://admin:3000/god-mode/; + proxy_pass http://admin:3001/god-mode/; } location /api/ { @@ -45,7 +45,7 @@ http { location /spaces/ { rewrite ^/spaces/?$ /spaces/login break; - proxy_pass http://space:4000/spaces/; + proxy_pass http://space:3002/spaces/; } location /${BUCKET_NAME}/ { diff --git a/package.json b/package.json index 05c1c7f24..a63bbf340 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "packages/tailwind-config-custom", "packages/tsconfig", "packages/ui", - "packages/types" + "packages/types", + "packages/constants" ], "scripts": { "build": "turbo run build", @@ -25,7 +26,7 @@ "devDependencies": { "autoprefixer": "^10.4.15", "eslint-config-custom": "*", - "postcss": "^8.4.38", + "postcss": "^8.4.29", "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", "tailwindcss": "^3.3.3", diff --git a/packages/constants/package.json b/packages/constants/package.json new file mode 100644 index 000000000..be581d08a --- /dev/null +++ b/packages/constants/package.json @@ -0,0 +1,10 @@ +{ + "name": "@plane/constants", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*" + } +} diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts new file mode 100644 index 000000000..86e75f4ff --- /dev/null +++ b/packages/constants/src/auth.ts @@ -0,0 +1,371 @@ +import { ReactNode } from "react"; +import Link from "next/link"; + +export enum EPageTypes { + PUBLIC = "PUBLIC", + NON_AUTHENTICATED = "NON_AUTHENTICATED", + SET_PASSWORD = "SET_PASSWORD", + ONBOARDING = "ONBOARDING", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", +} + +// TODO: remove this +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +export enum EAuthErrorCodes { + // Global + INSTANCE_NOT_CONFIGURED = "5000", + INVALID_EMAIL = "5005", + EMAIL_REQUIRED = "5010", + SIGNUP_DISABLED = "5015", + MAGIC_LINK_LOGIN_DISABLED = "5017", + PASSWORD_LOGIN_DISABLED = "5019", + SMTP_NOT_CONFIGURED = "5025", + // Password strength + INVALID_PASSWORD = "5020", + // Sign Up + USER_ACCOUNT_DEACTIVATED = "5019", + USER_ALREADY_EXIST = "5030", + AUTHENTICATION_FAILED_SIGN_UP = "5035", + REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040", + INVALID_EMAIL_SIGN_UP = "5045", + INVALID_EMAIL_MAGIC_SIGN_UP = "5050", + MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", + // Sign In + USER_DOES_NOT_EXIST = "5060", + AUTHENTICATION_FAILED_SIGN_IN = "5065", + REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", + INVALID_EMAIL_SIGN_IN = "5075", + INVALID_EMAIL_MAGIC_SIGN_IN = "5080", + MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085", + // Both Sign in and Sign up for magic + INVALID_MAGIC_CODE = "5090", + EXPIRED_MAGIC_CODE = "5095", + EMAIL_CODE_ATTEMPT_EXHAUSTED = "5100", + // Oauth + GOOGLE_NOT_CONFIGURED = "5105", + GITHUB_NOT_CONFIGURED = "5110", + GOOGLE_OAUTH_PROVIDER_ERROR = "5115", + GITHUB_OAUTH_PROVIDER_ERROR = "5120", + // Reset Password + INVALID_PASSWORD_TOKEN = "5125", + EXPIRED_PASSWORD_TOKEN = "5130", + // Change password + INCORRECT_OLD_PASSWORD = "5135", + MISSING_PASSWORD= "5138", + INVALID_NEW_PASSWORD = "5140", + // set passowrd + PASSWORD_ALREADY_SET = "5145", + // Admin + ADMIN_ALREADY_EXIST = "5150", + REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", + INVALID_ADMIN_EMAIL = "5160", + INVALID_ADMIN_PASSWORD = "5165", + REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", + ADMIN_AUTHENTICATION_FAILED = "5175", + ADMIN_USER_ALREADY_EXIST = "5180", + ADMIN_USER_DOES_NOT_EXIST = "5185", +} + +export type TAuthErrorInfo = { + type: EErrorAlertType; + code: EAuthErrorCodes; + title: string; + message: ReactNode; +}; + +const errorCodeMessages: { + [key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; +} = { + // global + [EAuthErrorCodes.INSTANCE_NOT_CONFIGURED]: { + title: `Instance not configured`, + message: () => `Instance not configured. Please contact your administrator.`, + }, + [EAuthErrorCodes.SIGNUP_DISABLED]: { + title: `Sign up disabled`, + message: () => `Sign up disabled. Please contact your administrator.`, + }, + [EAuthErrorCodes.INVALID_PASSWORD]: { + title: `Invalid password`, + message: () => `Invalid password. Please try again.`, + }, + [EAuthErrorCodes.SMTP_NOT_CONFIGURED]: { + title: `SMTP not configured`, + message: () => `SMTP not configured. Please contact your administrator.`, + }, + + // email check in both sign up and sign in + [EAuthErrorCodes.INVALID_EMAIL]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_REQUIRED]: { + title: `Email required`, + message: () => `Email required. Please try again.`, + }, + + // sign up + [EAuthenticationErrorCodes.USER_ALREADY_EXIST]: { + title: `User already exists`, + message: (email = undefined) => ( +
diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx
index 97cd0c500..0139ae9ad 100644
--- a/space/components/issues/navbar/index.tsx
+++ b/space/components/issues/navbar/index.tsx
@@ -40,7 +40,7 @@ const IssueNavbar: FCChange password
Current password
- New password
- Confirm password
-