From 9b7b23f5a214bee38a8f76621a8748819865cde7 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 14 May 2024 20:53:51 +0530 Subject: [PATCH] [WEB-1309] fix: auth fixes (#4456) * dev: magic link login and email password disable * dev: user account deactivation * dev: change nginx conf routes * feat: changemod space * fix: space app dir fixes * dev: invalidate cache for instances when creating workspace * dev: update email templates for test email * dev: fix build errors * fix: auth fixes and improvement (#4452) * chore: change password api updated and missing password error code added * chore: auth helper updated * chore: disable send code input suggestion * chore: change password function updated * fix: application error on sign in page * chore: change password validation added and enhancement * dev: space base path in web * dev: admin user deactivated * dev: user and instance admin session endpoint * fix: last_workspace_id endpoint updated * fix: magic sign in and email password check added --------- Co-authored-by: pablohashescobar Co-authored-by: sriram veeraghanta Co-authored-by: guru_sainath --- admin/package.json | 1 + apiserver/plane/app/urls/user.py | 6 + apiserver/plane/app/views/__init__.py | 2 +- apiserver/plane/app/views/user/base.py | 20 + apiserver/plane/app/views/workspace/base.py | 9 +- .../plane/authentication/adapter/base.py | 6 + .../plane/authentication/adapter/error.py | 5 + .../provider/credentials/email.py | 19 + .../provider/credentials/magic_code.py | 40 +- .../plane/authentication/views/app/check.py | 189 +++++---- .../plane/authentication/views/app/email.py | 41 +- .../plane/authentication/views/app/magic.py | 40 +- .../views/app/password_management.py | 24 +- .../plane/authentication/views/common.py | 91 +++-- .../plane/authentication/views/space/check.py | 11 + .../plane/authentication/views/space/email.py | 42 +- .../plane/authentication/views/space/magic.py | 48 ++- .../authentication/views/space/signout.py | 3 - .../db/management/commands/test_email.py | 18 +- apiserver/plane/license/api/views/__init__.py | 1 + apiserver/plane/license/api/views/admin.py | 38 ++ apiserver/plane/license/urls.py | 6 + apiserver/templates/emails/test_email.html | 6 + nginx/nginx.conf.dev | 4 +- package.json | 5 +- packages/constants/package.json | 10 + packages/constants/src/auth.ts | 371 ++++++++++++++++++ packages/constants/src/index.ts | 1 + space/components/accounts/auth-forms/root.tsx | 6 +- .../accounts/auth-forms/unique-code.tsx | 1 + space/components/issues/navbar/index.tsx | 28 +- space/helpers/authentication.helper.tsx | 14 +- space/package.json | 1 + .../account/auth-forms/auth-root.tsx | 16 +- .../onboarding/create-workspace.tsx | 7 +- .../project/publish-project/modal.tsx | 2 +- web/components/workspace/sidebar-dropdown.tsx | 8 +- web/helpers/authentication.helper.tsx | 12 + web/package.json | 1 + web/pages/create-workspace.tsx | 6 +- web/pages/invitations/index.tsx | 9 +- web/pages/profile/change-password.tsx | 234 +++++++---- web/services/user.service.ts | 8 +- yarn.lock | 23 +- 44 files changed, 1114 insertions(+), 319 deletions(-) create mode 100644 apiserver/templates/emails/test_email.html create mode 100644 packages/constants/package.json create mode 100644 packages/constants/src/auth.ts create mode 100644 packages/constants/src/index.ts 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) => ( +
+ Your account is already registered.  + + Sign In + +  now. +
+ ), + }, + [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + + // sign in + [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () =>
Your account is deactivated. Contact support@plane.so.
, + }, + [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { + title: `User does not exist`, + message: (email = undefined) => ( +
+ No account found.  + + Create one + +  to get started. +
+ ), + }, + [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + + // Both Sign in and Sign up + [EAuthenticationErrorCodes.INVALID_MAGIC_CODE]: { + title: `Authentication failed`, + message: () => `Invalid magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + + // Oauth + [EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: { + title: `Google not configured`, + message: () => `Google not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED]: { + title: `GitHub not configured`, + message: () => `GitHub not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: { + title: `Google OAuth provider error`, + message: () => `Google OAuth provider error. Please try again.`, + }, + [EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: { + title: `GitHub OAuth provider error`, + message: () => `GitHub OAuth provider error. Please try again.`, + }, + + // Reset Password + [EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: { + title: `Invalid password token`, + message: () => `Invalid password token. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN]: { + title: `Expired password token`, + message: () => `Expired password token. Please try again.`, + }, + + // Change password + + [EAuthenticationErrorCodes.MISSING_PASSWORD]: { + title: `Password required`, + message: () => `Password required. Please try again.`, + }, + [EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: { + title: `Incorrect old password`, + message: () => `Incorrect old password. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_NEW_PASSWORD]: { + title: `Invalid new password`, + message: () => `Invalid new password. Please try again.`, + }, + + // set password + [EAuthenticationErrorCodes.PASSWORD_ALREADY_SET]: { + title: `Password already set`, + message: () => `Password already set. Please try again.`, + }, + + // admin + [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: { + title: `Admin already exists`, + message: () => `Admin already exists. Please try again.`, + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { + title: `Email, password and first name required`, + message: () => `Email, password and first name required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: { + title: `Invalid admin email`, + message: () => `Invalid admin email. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: { + title: `Invalid admin password`, + message: () => `Invalid admin password. Please try again.`, + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: { + title: `Admin user already exists`, + message: () => ( +
+ Admin user already exists.  + + Sign In + +  now. +
+ ), + }, + [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { + title: `Admin user does not exist`, + message: () => ( +
+ Admin user does not exist.  + + Sign In + +  now. +
+ ), + }, +}; + +export const authErrorHandler = ( + errorCode: EAuthenticationErrorCodes, + email?: string | undefined +): TAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED, + EAuthenticationErrorCodes.INVALID_EMAIL, + EAuthenticationErrorCodes.EMAIL_REQUIRED, + EAuthenticationErrorCodes.SIGNUP_DISABLED, + EAuthenticationErrorCodes.INVALID_PASSWORD, + EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED, + EAuthenticationErrorCodes.USER_ALREADY_EXIST, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, + EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, + EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, + EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, + EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.INVALID_MAGIC_CODE, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED, + EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED, + EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED, + EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN, + EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN, + EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD, + EAuthenticationErrorCodes.INVALID_NEW_PASSWORD, + EAuthenticationErrorCodes.PASSWORD_ALREADY_SET, + EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, + EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL, + EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, + EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, + EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, + EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + ]; + + if (bannerAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.BANNER_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + return undefined; +}; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts new file mode 100644 index 000000000..97ccf7649 --- /dev/null +++ b/packages/constants/src/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/space/components/accounts/auth-forms/root.tsx b/space/components/accounts/auth-forms/root.tsx index 273a438bf..e51cac92f 100644 --- a/space/components/accounts/auth-forms/root.tsx +++ b/space/components/accounts/auth-forms/root.tsx @@ -65,6 +65,8 @@ export const AuthRoot = observer(() => { const { config: instanceConfig } = useInstance(); // derived values const isSmtpConfigured = instanceConfig?.is_smtp_configured; + const isMagicLoginEnabled = instanceConfig?.is_magic_login_enabled; + const isEmailPasswordEnabled = instanceConfig?.is_email_password_enabled; const { header, subHeader } = getHeaderSubHeader(authMode); @@ -87,9 +89,9 @@ export const AuthRoot = observer(() => { setAuthStep(EAuthSteps.PASSWORD); } else { // Else if SMTP is configured, move to unique code sign-in/ sign-up. - if (isSmtpConfigured) { + if (isSmtpConfigured && isMagicLoginEnabled) { setAuthStep(EAuthSteps.UNIQUE_CODE); - } else { + } else if (isEmailPasswordEnabled) { // Else show error message if SMTP is not configured and password is not set. if (res.existing) { setAuthMode(null); diff --git a/space/components/accounts/auth-forms/unique-code.tsx b/space/components/accounts/auth-forms/unique-code.tsx index fc04938bc..785d921a6 100644 --- a/space/components/accounts/auth-forms/unique-code.tsx +++ b/space/components/accounts/auth-forms/unique-code.tsx @@ -151,6 +151,7 @@ export const UniqueCodeForm: React.FC = (props) => { placeholder="gets-sets-flys" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" autoFocus + autoComplete="off" />

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: FC = observer((props) => { useEffect(() => { if (workspaceSlug && projectId && settings) { const viewsAcceptable: string[] = []; - let currentBoard: TIssueBoardKeys | null = null; + const currentBoard: TIssueBoardKeys | null = null; if (settings?.views?.list) viewsAcceptable.push("list"); if (settings?.views?.kanban) viewsAcceptable.push("kanban"); @@ -48,19 +48,19 @@ const IssueNavbar: FC = observer((props) => { if (settings?.views?.gantt) viewsAcceptable.push("gantt"); if (settings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); - if (board) { - if (viewsAcceptable.includes(board.toString())) { - currentBoard = board.toString() as TIssueBoardKeys; - } else { - if (viewsAcceptable && viewsAcceptable.length > 0) { - currentBoard = viewsAcceptable[0] as TIssueBoardKeys; - } - } - } else { - if (viewsAcceptable && viewsAcceptable.length > 0) { - currentBoard = viewsAcceptable[0] as TIssueBoardKeys; - } - } + // if (board) { + // if (viewsAcceptable.includes(board.toString())) { + // currentBoard = board.toString() as TIssueBoardKeys; + // } else { + // if (viewsAcceptable && viewsAcceptable.length > 0) { + // currentBoard = viewsAcceptable[0] as TIssueBoardKeys; + // } + // } + // } else { + // if (viewsAcceptable && viewsAcceptable.length > 0) { + // currentBoard = viewsAcceptable[0] as TIssueBoardKeys; + // } + // } if (currentBoard) { if (activeLayout === null || activeLayout !== currentBoard) { diff --git a/space/helpers/authentication.helper.tsx b/space/helpers/authentication.helper.tsx index 579fe0496..2626b35c2 100644 --- a/space/helpers/authentication.helper.tsx +++ b/space/helpers/authentication.helper.tsx @@ -39,12 +39,12 @@ export enum EAuthenticationErrorCodes { INVALID_EMAIL = "5012", EMAIL_REQUIRED = "5013", // Sign Up + USER_ACCOUNT_DEACTIVATED = "5019", USER_ALREADY_EXIST = "5003", REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5015", AUTHENTICATION_FAILED_SIGN_UP = "5006", INVALID_EMAIL_SIGN_UP = "5017", MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5023", - INVALID_EMAIL_MAGIC_SIGN_UP = "5019", // Sign In USER_DOES_NOT_EXIST = "5004", REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5014", @@ -140,12 +140,14 @@ const errorCodeMessages: { title: `Email and code required`, message: () => `Email and code required. Please try again.`, }, - [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, // sign in + + [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () =>

Your account is deactivated. Please reach out to support@plane.so
, + }, + [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { title: `User does not exist`, message: (email = undefined) => ( @@ -250,7 +252,6 @@ export const authErrorHandler = ( EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP, EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, - EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN, EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, @@ -273,6 +274,7 @@ export const authErrorHandler = ( EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED, ]; if (toastAlertErrorCodes.includes(errorCode)) diff --git a/space/package.json b/space/package.json index a10d190d2..48fe001b7 100644 --- a/space/package.json +++ b/space/package.json @@ -22,6 +22,7 @@ "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", + "@plane/constants": "*", "@sentry/nextjs": "^7.108.0", "axios": "^1.3.4", "clsx": "^2.0.0", diff --git a/web/components/account/auth-forms/auth-root.tsx b/web/components/account/auth-forms/auth-root.tsx index 4c479af28..0c0eb4755 100644 --- a/web/components/account/auth-forms/auth-root.tsx +++ b/web/components/account/auth-forms/auth-root.tsx @@ -68,6 +68,10 @@ export const AuthRoot: FC = observer((props) => { } }, [error_code, authMode]); + const isSMTPConfigured = instance?.config?.is_smtp_configured || false; + const isMagicLoginEnabled = instance?.config?.is_magic_login_enabled || false; + const isEmailPasswordEnabled = instance?.config?.is_email_password_enabled || false; + // submit handler- email verification const handleEmailVerification = async (data: IEmailCheckData) => { setEmail(data.email); @@ -80,19 +84,21 @@ export const AuthRoot: FC = observer((props) => { if (response.is_password_autoset) { setAuthStep(EAuthSteps.UNIQUE_CODE); generateEmailUniqueCode(data.email); - } else { + } else if (isEmailPasswordEnabled) { setIsPasswordAutoset(false); setAuthStep(EAuthSteps.PASSWORD); } } else { - if (instance && instance?.config?.is_smtp_configured) { + if (isSMTPConfigured && isMagicLoginEnabled) { setAuthStep(EAuthSteps.UNIQUE_CODE); generateEmailUniqueCode(data.email); - } else setAuthStep(EAuthSteps.PASSWORD); + } else if (isEmailPasswordEnabled) { + setAuthStep(EAuthSteps.PASSWORD); + } } }) .catch((error) => { - const errorhandler = authErrorHandler(error?.error_code.toString(), data?.email || undefined); + const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); if (errorhandler?.type) setErrorInfo(errorhandler); }); }; @@ -113,8 +119,6 @@ export const AuthRoot: FC = observer((props) => { const isOAuthEnabled = (instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled)) || false; - const isSMTPConfigured = (instance?.config && instance?.config?.is_smtp_configured) || false; - return (
= (props) => { const [slugError, setSlugError] = useState(false); const [invalidSlug, setInvalidSlug] = useState(false); // store hooks - const { updateCurrentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + const { createWorkspace, fetchWorkspaces, workspaces } = useWorkspace(); const { captureWorkspaceEvent } = useEventTracker(); // form info @@ -111,7 +112,7 @@ export const CreateWorkspace: React.FC = (props) => { }; await stepChange(payload); - await updateCurrentUser({ + await updateUserProfile({ last_workspace_id: firstWorkspace?.id, }); }; diff --git a/web/components/project/publish-project/modal.tsx b/web/components/project/publish-project/modal.tsx index 6f2f68fad..c9534781e 100644 --- a/web/components/project/publish-project/modal.tsx +++ b/web/components/project/publish-project/modal.tsx @@ -62,7 +62,7 @@ export const PublishProjectModal: React.FC = observer((props) => { const [isUpdateRequired, setIsUpdateRequired] = useState(false); // const plane_deploy_url = instance?.config?.space_base_url || ""; - const SPACE_URL = SPACE_BASE_URL + SPACE_BASE_PATH; + const SPACE_URL = (SPACE_BASE_URL === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH; // router const router = useRouter(); diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index fd26d1248..348eb9832 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -12,7 +12,7 @@ import { IWorkspace } from "@plane/types"; // plane ui import { Avatar, Loader, TOAST_TYPE, setToast } from "@plane/ui"; // hooks -import { useAppTheme, useUser, useWorkspace } from "@/hooks/store"; +import { useAppTheme, useUser, useUserProfile, useWorkspace } from "@/hooks/store"; import { WorkspaceLogo } from "./logo"; // types // Static Data @@ -56,10 +56,12 @@ export const WorkspaceSidebarDropdown = observer(() => { const { sidebarCollapsed, toggleSidebar } = useAppTheme(); const { data: currentUser } = useUser(); const { - updateCurrentUser, + // updateCurrentUser, // isUserInstanceAdmin, signOut, } = useUser(); + const { updateUserProfile } = useUserProfile(); + const isUserInstanceAdmin = false; const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace(); // popper-js refs @@ -78,7 +80,7 @@ export const WorkspaceSidebarDropdown = observer(() => { ], }); const handleWorkspaceNavigation = (workspace: IWorkspace) => - updateCurrentUser({ + updateUserProfile({ last_workspace_id: workspace?.id, }); const handleSignOut = async () => { diff --git a/web/helpers/authentication.helper.tsx b/web/helpers/authentication.helper.tsx index 88a40dd57..0f331394b 100644 --- a/web/helpers/authentication.helper.tsx +++ b/web/helpers/authentication.helper.tsx @@ -45,6 +45,7 @@ export enum EAuthenticationErrorCodes { INVALID_EMAIL_MAGIC_SIGN_UP = "5050", MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", // Sign In + USER_ACCOUNT_DEACTIVATED = "5019", USER_DOES_NOT_EXIST = "5060", AUTHENTICATION_FAILED_SIGN_IN = "5065", REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", @@ -65,6 +66,7 @@ export enum EAuthenticationErrorCodes { EXPIRED_PASSWORD_TOKEN = "5130", // Change password INCORRECT_OLD_PASSWORD = "5135", + MISSING_PASSWORD = "5138", INVALID_NEW_PASSWORD = "5140", // set passowrd PASSWORD_ALREADY_SET = "5145", @@ -155,6 +157,11 @@ const errorCodeMessages: { }, // sign in + [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () =>
Your account is deactivated. Contact support@plane.so.
, + }, + [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { title: `User does not exist`, message: (email = undefined) => ( @@ -234,6 +241,10 @@ const errorCodeMessages: { }, // Change password + [EAuthenticationErrorCodes.MISSING_PASSWORD]: { + title: `Password required`, + message: () => `Password required. Please try again.`, + }, [EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: { title: `Incorrect old password`, message: () => `Incorrect old password. Please try again.`, @@ -343,6 +354,7 @@ export const authErrorHandler = ( EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED, ]; if (bannerAlertErrorCodes.includes(errorCode)) diff --git a/web/package.json b/web/package.json index 06efa5a84..c3de0c238 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,7 @@ "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", + "@plane/constants": "*", "@popperjs/core": "^2.11.8", "@sentry/nextjs": "^7.108.0", "axios": "^1.1.3", diff --git a/web/pages/create-workspace.tsx b/web/pages/create-workspace.tsx index 174d7f344..9365716ba 100644 --- a/web/pages/create-workspace.tsx +++ b/web/pages/create-workspace.tsx @@ -8,7 +8,7 @@ import { IWorkspace } from "@plane/types"; // hooks import { PageHead } from "@/components/core"; import { CreateWorkspaceForm } from "@/components/workspace"; -import { useUser } from "@/hooks/store"; +import { useUser, useUserProfile } from "@/hooks/store"; // layouts import DefaultLayout from "@/layouts/default-layout"; // components @@ -25,7 +25,7 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => { const router = useRouter(); // store hooks const { data: currentUser } = useUser(); - const { updateCurrentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); // states const [defaultValues, setDefaultValues] = useState({ name: "", @@ -36,7 +36,7 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => { const { theme } = useTheme(); const onSubmit = async (workspace: IWorkspace) => { - await updateCurrentUser({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); + await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); }; return ( diff --git a/web/pages/invitations/index.tsx b/web/pages/invitations/index.tsx index 67465f34b..7e14d2d74 100644 --- a/web/pages/invitations/index.tsx +++ b/web/pages/invitations/index.tsx @@ -21,14 +21,13 @@ import { ROLE } from "@/constants/workspace"; // helpers import { truncateText } from "@/helpers/string.helper"; import { getUserRole } from "@/helpers/user.helper"; -import { useEventTracker, useUser, useWorkspace } from "@/hooks/store"; +import { useEventTracker, useUser, useUserProfile, useWorkspace } from "@/hooks/store"; import DefaultLayout from "@/layouts/default-layout"; // types import { NextPageWithLayout } from "@/lib/types"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; // services -import { UserService } from "@/services/user.service"; import { WorkspaceService } from "@/services/workspace.service"; // images import emptyInvitation from "public/empty-state/invitation.svg"; @@ -36,7 +35,6 @@ import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-l import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg"; const workspaceService = new WorkspaceService(); -const userService = new UserService(); const UserInvitationsPage: NextPageWithLayout = observer(() => { // states @@ -47,6 +45,8 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { // store hooks const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { data: currentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + const { fetchWorkspaces } = useWorkspace(); // next-themes const { theme } = useTheme(); @@ -95,8 +95,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { state: "SUCCESS", element: "Workspace invitations page", }); - userService - .updateUser({ last_workspace_id: redirectWorkspace?.id }) + updateUserProfile({ last_workspace_id: redirectWorkspace?.id }) .then(() => { setIsJoiningWorkspaces(false); fetchWorkspaces().then(() => { diff --git a/web/pages/profile/change-password.tsx b/web/pages/profile/change-password.tsx index ae4e7be70..b8979ea14 100644 --- a/web/pages/profile/change-password.tsx +++ b/web/pages/profile/change-password.tsx @@ -1,12 +1,15 @@ -import { ReactElement, useEffect, useState } from "react"; +import { ReactElement, useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; +import { Eye, EyeOff } from "lucide-react"; // ui import { Button, Input, Spinner, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; // components +import { PasswordStrengthMeter } from "@/components/account"; import { PageHead } from "@/components/core"; import { SidebarHamburgerToggle } from "@/components/core/sidebar"; +import { getPasswordStrength } from "@/helpers/password.helper"; // hooks import { useAppTheme, useUser } from "@/hooks/store"; // layout @@ -14,6 +17,7 @@ import { ProfileSettingsLayout } from "@/layouts/settings-layout"; // types import { NextPageWithLayout } from "@/lib/types"; // services +import { AuthService } from "@/services/auth.service"; import { UserService } from "@/services/user.service"; export interface FormValues { @@ -29,9 +33,18 @@ const defaultValues: FormValues = { }; export const userService = new UserService(); +export const authService = new AuthService(); const ChangePasswordPage: NextPageWithLayout = observer(() => { + const [csrfToken, setCsrfToken] = useState(undefined); const [isPageLoading, setIsPageLoading] = useState(true); + const [showPassword, setShowPassword] = useState({ + oldPassword: false, + password: false, + retypePassword: false, + }); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + // router const router = useRouter(); // store hooks @@ -42,32 +55,54 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => { const { control, handleSubmit, + watch, formState: { errors, isSubmitting }, + reset, } = useForm({ defaultValues }); + const oldPassword = watch("old_password"); + const password = watch("new_password"); + const retypePassword = watch("confirm_password"); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + const handleChangePassword = async (formData: FormValues) => { - if (formData.new_password !== formData.confirm_password) { + try { + if (!csrfToken) throw new Error("csrf token not found"); + const changePasswordPromise = userService + .changePassword(csrfToken, { + old_password: formData.old_password, + new_password: formData.new_password, + }) + .then(() => { + reset(defaultValues); + }); + setPromiseToast(changePasswordPromise, { + loading: "Changing password...", + success: { + title: "Success!", + message: () => "Password changed successfully.", + }, + error: { + title: "Error!", + message: () => "Something went wrong. Please try again 1.", + }, + }); + } catch (err: any) { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "The new password and the confirm password don't match.", + message: err?.error ?? "Something went wrong. Please try again 2.", }); - return; } - const changePasswordPromise = userService.changePassword(formData); - setPromiseToast(changePasswordPromise, { - loading: "Changing password...", - success: { - title: "Success!", - message: () => "Password changed successfully.", - }, - error: { - title: "Error!", - message: () => "Something went wrong. Please try again.", - }, - }); }; + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + useEffect(() => { if (!currentUser) return; @@ -75,6 +110,25 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => { else setIsPageLoading(false); }, [currentUser, router]); + const isButtonDisabled = useMemo( + () => + !isSubmitting && + !!oldPassword && + !!password && + !!retypePassword && + getPasswordStrength(password) >= 3 && + password === retypePassword && + password !== oldPassword + ? false + : true, + + [isSubmitting, oldPassword, password, retypePassword] + ); + + const passwordSupport = password.length > 0 && (getPasswordStrength(password) < 3 || isPasswordInputFocused) && ( + + ); + if (isPageLoading) return (
@@ -93,82 +147,126 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => { onSubmit={handleSubmit(handleChangePassword)} className="mx-auto md:mt-16 mt-10 flex h-full w-full flex-col gap-8 px-4 md:px-8 pb-8 lg:w-3/5" > + +

Change password

Current password

- ( - + ( + + )} + /> + {showPassword?.oldPassword ? ( + handleShowPassword("oldPassword")} + /> + ) : ( + handleShowPassword("oldPassword")} /> )} - /> +
+ {errors.old_password && {errors.old_password.message}}

New password

- ( - + ( + setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} /> )} - /> - {errors.new_password && {errors.new_password.message}} +
+ {passwordSupport}

Confirm password

- ( - + ( + + )} + /> + {showPassword?.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} /> )} - /> - {errors.confirm_password && ( - {errors.confirm_password.message} +
+ {!!retypePassword && password !== retypePassword && ( + Passwords don{"'"}t match )}
-
diff --git a/web/services/user.service.ts b/web/services/user.service.ts index b9f9ce0fa..fa8a06542 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -143,8 +143,12 @@ export class UserService extends APIService { }); } - async changePassword(data: { old_password: string; new_password: string; confirm_password: string }): Promise { - return this.post(`/api/users/me/change-password/`, data) + async changePassword(token: string, data: { old_password: string; new_password: string }): Promise { + return this.post(`/auth/change-password/`, data, { + headers: { + "X-CSRFTOKEN": token, + }, + }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/yarn.lock b/yarn.lock index ecd59ae01..d1b91f8cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6917,7 +6917,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.4.23, postcss@^8.4.38: +postcss@^8.4.23, postcss@^8.4.29, postcss@^8.4.38: version "8.4.38" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== @@ -7945,8 +7945,16 @@ streamx@^2.15.0, streamx@^2.16.1: optionalDependencies: bare-events "^2.2.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8026,7 +8034,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==