From be2cf2e842be3ef67309e926e97a10002053e887 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 6 Dec 2023 14:22:59 +0530 Subject: [PATCH] chore: updated sign-in workflows for cloud and self-hosted instances (#2994) * chore: update onboarding workflow * dev: update user count tasks * fix: forgot password endpoint * dev: instance and onboarding updates * chore: update sign-in workflow for cloud and self-hosted instances (#2993) * chore: updated auth services * chore: new signin workflow updated * chore: updated content * chore: instance admin setup * dev: update instance verification task * dev: run the instance verification task every 4 hours * dev: update migrations * chore: update latest features image --------- Co-authored-by: pablohashescobar --- apiserver/plane/app/urls/authentication.py | 2 + apiserver/plane/app/views/__init__.py | 1 + apiserver/plane/app/views/auth_extended.py | 234 ++++++++---- apiserver/plane/app/views/authentication.py | 66 ++-- apiserver/plane/app/views/config.py | 4 +- apiserver/plane/app/views/oauth.py | 3 - .../plane/bgtasks/analytic_plot_export.py | 24 -- .../plane/bgtasks/forgot_password_task.py | 26 -- apiserver/plane/bgtasks/importer_task.py | 4 - .../plane/bgtasks/magic_link_code_task.py | 24 -- .../plane/bgtasks/project_invitation_task.py | 47 +-- apiserver/plane/bgtasks/user_count_task.py | 45 --- .../bgtasks/workspace_invitation_task.py | 25 -- apiserver/plane/celery.py | 6 +- .../plane/license/api/serializers/instance.py | 4 +- apiserver/plane/license/api/views/__init__.py | 4 +- apiserver/plane/license/api/views/instance.py | 332 +++--------------- apiserver/plane/license/bgtasks/__init__.py | 0 .../bgtasks/instance_verification_task.py | 136 +++++++ .../management/commands/configure_instance.py | 37 +- .../management/commands/register_instance.py | 58 +-- .../plane/license/migrations/0001_initial.py | 5 +- apiserver/plane/license/models/instance.py | 3 + apiserver/plane/license/urls.py | 20 +- apiserver/plane/settings/common.py | 13 +- .../account/github-login-button.tsx | 2 +- .../account/sign-in-forms/create-password.tsx | 53 ++- .../account/sign-in-forms/email-form.tsx | 73 +--- web/components/account/sign-in-forms/index.ts | 3 +- .../account/sign-in-forms/o-auth-options.tsx | 32 +- .../sign-in-forms/optional-set-password.tsx | 15 +- .../account/sign-in-forms/password.tsx | 102 +++--- web/components/account/sign-in-forms/root.tsx | 115 ++++-- .../sign-in-forms/self-hosted-sign-in.tsx | 139 ++++++++ .../sign-in-forms/set-password-link.tsx | 67 +--- .../account/sign-in-forms/unique-code.tsx | 69 ++-- .../common/latest-feature-block.tsx | 2 +- web/components/instance/not-ready-view.tsx | 4 +- web/components/instance/setup-done-view.tsx | 2 +- .../instance/setup-form/email-code-form.tsx | 160 --------- web/components/instance/setup-form/index.ts | 3 +- .../instance/setup-form/password-form.tsx | 126 ------- web/components/instance/setup-form/root.tsx | 50 +-- .../{email-form.tsx => sign-in-form.tsx} | 62 +++- web/components/page-views/signin.tsx | 31 +- web/layouts/instance-layout/index.tsx | 10 +- web/pages/accounts/password.tsx | 4 +- web/public/onboarding/onboarding-pages.svg | 49 ++- web/services/auth.service.ts | 54 ++- web/services/instance.service.ts | 8 - web/store/instance/instance.store.ts | 18 - web/types/app.d.ts | 1 + web/types/auth.d.ts | 8 +- 53 files changed, 1017 insertions(+), 1368 deletions(-) delete mode 100644 apiserver/plane/bgtasks/user_count_task.py create mode 100644 apiserver/plane/license/bgtasks/__init__.py create mode 100644 apiserver/plane/license/bgtasks/instance_verification_task.py create mode 100644 web/components/account/sign-in-forms/self-hosted-sign-in.tsx delete mode 100644 web/components/instance/setup-form/email-code-form.tsx delete mode 100644 web/components/instance/setup-form/password-form.tsx rename web/components/instance/setup-form/{email-form.tsx => sign-in-form.tsx} (58%) diff --git a/apiserver/plane/app/urls/authentication.py b/apiserver/plane/app/urls/authentication.py index ec3fa78ed..39986f791 100644 --- a/apiserver/plane/app/urls/authentication.py +++ b/apiserver/plane/app/urls/authentication.py @@ -7,6 +7,7 @@ from plane.app.views import ( # Authentication SignInEndpoint, SignOutEndpoint, + MagicGenerateEndpoint, MagicSignInEndpoint, OauthEndpoint, EmailCheckEndpoint, @@ -30,6 +31,7 @@ urlpatterns = [ path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), # magic sign in + path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"), path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), # Password Manipulation diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 6453f1dc6..2bfe27715 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -87,6 +87,7 @@ from .auth_extended import ( ChangePasswordEndpoint, SetUserPasswordEndpoint, EmailCheckEndpoint, + MagicGenerateEndpoint, ) diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index 2dc0fa983..b169fde65 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -37,9 +37,9 @@ from plane.bgtasks.forgot_password_task import forgot_password from plane.license.models import Instance, InstanceConfiguration from plane.settings.redis import redis_instance from plane.bgtasks.magic_link_code_task import magic_link -from plane.bgtasks.user_count_task import update_user_instance_user_count from plane.bgtasks.event_tracking_task import auth_events + def get_tokens_for_user(user): refresh = RefreshToken.for_user(user) return ( @@ -108,13 +108,16 @@ class ForgotPasswordEndpoint(BaseAPIView): try: validate_email(email) except ValidationError: - return Response({"error": "Please enter a valid email"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Please enter a valid email"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get the user user = User.objects.filter(email=email).first() if user: # Get the reset token for user - uidb64, token = get_tokens_for_user(user=user) + uidb64, token = generate_password_token(user=user) current_site = request.META.get("HTTP_ORIGIN") # send the forgot password email forgot_password.delay( @@ -130,7 +133,9 @@ class ForgotPasswordEndpoint(BaseAPIView): class ResetPasswordEndpoint(BaseAPIView): - permission_classes = [AllowAny,] + permission_classes = [ + AllowAny, + ] def post(self, request, uidb64, token): try: @@ -219,6 +224,89 @@ class SetUserPasswordEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) +class MagicGenerateEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + email = request.data.get("email", False) + + # Check the instance registration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not email: + return Response( + {"error": "Please provide a valid email address"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Clean up the email + email = email.strip().lower() + validate_email(email) + + # check if the email exists not + if not User.objects.filter(email=email).exists(): + # Create a user + _ = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + ) + + ## Generate a random token + token = ( + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + ) + + ri = redis_instance() + + key = "magic_" + str(email) + + # Check if the key already exists in python + if ri.exists(key): + data = json.loads(ri.get(key)) + + current_attempt = data["current_attempt"] + 1 + + if data["current_attempt"] > 2: + return Response( + {"error": "Max attempts exhausted. Please try again later."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + value = { + "current_attempt": current_attempt, + "email": email, + "token": token, + } + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + + else: + value = {"current_attempt": 0, "email": email, "token": token} + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + + # If the smtp is configured send through here + current_site = request.META.get("HTTP_ORIGIN") + magic_link.delay(email, key, token, current_site) + + return Response({"key": key}, status=status.HTTP_200_OK) + + class EmailCheckEndpoint(BaseAPIView): permission_classes = [ AllowAny, @@ -237,16 +325,19 @@ class EmailCheckEndpoint(BaseAPIView): instance_configuration = InstanceConfiguration.objects.values("key", "value") email = request.data.get("email", False) - type = request.data.get("type", "magic_code") if not email: - return Response({"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + ) # validate the email try: validate_email(email) except ValidationError: - return Response({"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST + ) # Check if the user exists user = User.objects.filter(email=email).first() @@ -281,71 +372,59 @@ class EmailCheckEndpoint(BaseAPIView): is_password_autoset=True, ) - # Update instance user count - update_user_instance_user_count.delay() - # Case when the user selects magic code - if type == "magic_code": - if not bool(get_configuration_value( + if not bool( + get_configuration_value( instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", - os.environ.get("ENABLE_MAGIC_LINK_LOGIN")), - ): - return Response( - {"error": "Magic link sign in is disabled."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=True, - ) - key, token, current_attempt = generate_magic_token(email=email) - if not current_attempt: - return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST) - # Trigger the email - magic_link.delay(email, "magic_" + str(email), token, current_site) - return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) - else: - # Get the uidb64 and token for the user - uidb64, token = generate_password_token(user=user) - forgot_password.delay( - user.first_name, user.email, uidb64, token, current_site + os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), + ), + ): + return Response( + {"error": "Magic link sign in is disabled."}, + status=status.HTTP_400_BAD_REQUEST, ) - # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", - first_time=True, - ) - # Automatically send the email - return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) + + # Send event + if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=True, + ) + key, token, current_attempt = generate_magic_token(email=email) + if not current_attempt: + return Response( + {"error": "Max attempts exhausted. Please try again later."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Trigger the email + magic_link.delay(email, "magic_" + str(email), token, current_site) + return Response( + {"is_password_autoset": user.is_password_autoset, "is_existing": False}, + status=status.HTTP_200_OK, + ) + # Existing user else: - if type == "magic_code": + if user.is_password_autoset: ## Generate a random token - if not bool(get_configuration_value( - instance_configuration, - "ENABLE_MAGIC_LINK_LOGIN", - os.environ.get("ENABLE_MAGIC_LINK_LOGIN")), + if not bool( + get_configuration_value( + instance_configuration, + "ENABLE_MAGIC_LINK_LOGIN", + os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), + ), ): return Response( {"error": "Magic link sign in is disabled."}, status=status.HTTP_400_BAD_REQUEST, ) - + if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: auth_events.delay( user=user.id, @@ -356,15 +435,24 @@ class EmailCheckEndpoint(BaseAPIView): medium="MAGIC_LINK", first_time=False, ) - + # Generate magic token key, token, current_attempt = generate_magic_token(email=email) if not current_attempt: - return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Max attempts exhausted. Please try again later."}, + status=status.HTTP_400_BAD_REQUEST, + ) # Trigger the email magic_link.delay(email, key, token, current_site) - return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) + return Response( + { + "is_password_autoset": user.is_password_autoset, + "is_existing": True, + }, + status=status.HTTP_200_OK, + ) else: if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: auth_events.delay( @@ -376,14 +464,12 @@ class EmailCheckEndpoint(BaseAPIView): medium="EMAIL", first_time=False, ) - - if user.is_password_autoset: - # send email - uidb64, token = generate_password_token(user=user) - forgot_password.delay( - user.first_name, user.email, uidb64, token, current_site - ) - return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) - else: - # User should enter password to login - return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) + + # User should enter password to login + return Response( + { + "is_password_autoset": user.is_password_autoset, + "is_existing": True, + }, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index 487cdae48..32b8a34f4 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -1,8 +1,6 @@ # Python imports import os import uuid -import random -import string import json # Django imports @@ -10,6 +8,7 @@ from django.utils import timezone from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.conf import settings +from django.contrib.auth.hashers import make_password # Third party imports from rest_framework.response import Response @@ -31,7 +30,6 @@ from plane.settings.redis import redis_instance from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.event_tracking_task import auth_events -from plane.bgtasks.user_count_task import update_user_instance_user_count def get_tokens_for_user(user): @@ -58,7 +56,6 @@ class SignUpEndpoint(BaseAPIView): email = request.data.get("email", False) password = request.data.get("password", False) - ## Raise exception if any of the above are missing if not email or not password: return Response( @@ -66,8 +63,8 @@ class SignUpEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # Validate the email email = email.strip().lower() - try: validate_email(email) except ValidationError as e: @@ -106,6 +103,7 @@ class SignUpEndpoint(BaseAPIView): user.set_password(password) # settings last actives for the user + user.is_password_autoset = False user.last_active = timezone.now() user.last_login_time = timezone.now() user.last_login_ip = request.META.get("REMOTE_ADDR") @@ -120,9 +118,6 @@ class SignUpEndpoint(BaseAPIView): "refresh_token": refresh_token, } - # Update instance user count - update_user_instance_user_count.delay() - return Response(data, status=status.HTTP_200_OK) @@ -148,8 +143,8 @@ class SignInEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # Validate email email = email.strip().lower() - try: validate_email(email) except ValidationError as e: @@ -161,22 +156,45 @@ class SignInEndpoint(BaseAPIView): # Get the user user = User.objects.filter(email=email).first() - # User is not present in db - if user is None: - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) + # Existing user + if user: + # Check user password + if not user.check_password(password): + return Response( + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + status=status.HTTP_403_FORBIDDEN, + ) - # Check user password - if not user.check_password(password): - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, + # Create the user + else: + # Get the configurations + instance_configuration = InstanceConfiguration.objects.values("key", "value") + # Create the user + if ( + get_configuration_value( + instance_configuration, + "ENABLE_SIGNUP", + os.environ.get("ENABLE_SIGNUP", "0"), + ) + == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + return Response( + { + "error": "New account creation is disabled. Please contact your site administrator" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(password), + is_password_autoset=False, ) # settings last active for the user diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index f42c853e2..cef68d6d0 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -11,7 +11,7 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView -from plane.license.models import Instance, InstanceConfiguration +from plane.license.models import InstanceConfiguration from plane.license.utils.instance_value import get_configuration_value @@ -104,4 +104,6 @@ class ConfigurationEndpoint(BaseAPIView): data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1"))) + return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index 826ec4b05..85e6ac957 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -32,7 +32,6 @@ from plane.bgtasks.event_tracking_task import auth_events from .base import BaseAPIView from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_configuration_value -from plane.bgtasks.user_count_task import update_user_instance_user_count def get_tokens_for_user(user): @@ -439,6 +438,4 @@ class OauthEndpoint(BaseAPIView): "refresh_token": refresh_token, } - # Update the user count - update_user_instance_user_count.delay() return Response(data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index 3d5724605..5d4f58eba 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -66,30 +66,6 @@ def send_export_email(email, slug, csv_buffer, rows): EMAIL_FROM, ) = get_email_configuration(instance_configuration=instance_configuration) - # Send the email if the users don't have smtp configured - if EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD: - # Check the instance registration - instance = Instance.objects.first() - - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - payload = { - "email": email, - "slug": slug, - "rows": rows, - } - - _ = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/analytics/", - headers=headers, - json=payload, - ) - return - connection = get_connection( host=EMAIL_HOST, port=int(EMAIL_PORT), diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 33cd40dc8..b24d81d93 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -39,32 +39,6 @@ def forgot_password(first_name, email, uidb64, token, current_site): EMAIL_FROM, ) = get_email_configuration(instance_configuration=instance_configuration) - # Send the email if the users don't have smtp configured - if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): - # Check the instance registration - instance = Instance.objects.first() - - # headers - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - payload = { - "abs_url": abs_url, - "first_name": first_name, - "email": email, - } - - _ = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/forgot-password/", - headers=headers, - data=json.dumps(payload), - ) - - return - subject = "A new password to your Plane account has been requested" context = { diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index beb012b9f..84d10ecd3 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -26,7 +26,6 @@ from plane.db.models import ( IssueProperty, ) from plane.bgtasks.user_welcome_task import send_welcome_slack -from plane.bgtasks.user_count_task import update_user_instance_user_count @shared_task @@ -121,9 +120,6 @@ def service_importer(service, importer_id): batch_size=100, ignore_conflicts=True, ) - - # Update instance user count - update_user_instance_user_count.delay() # Check if sync config is on for github importers if service == "github" and importer.config.get("sync", False): diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index a152b4c7f..2e8a7de16 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -34,30 +34,6 @@ def magic_link(email, key, token, current_site): EMAIL_FROM, ) = get_email_configuration(instance_configuration=instance_configuration) - # Send the email if the users don't have smtp configured - if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): - # Check the instance registration - instance = Instance.objects.first() - - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - payload = { - "token": token, - "email": email, - } - - _ = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/magic-code/", - headers=headers, - data=json.dumps(payload), - ) - - return - # Send the mail subject = f"Your unique Plane login code is {token}" context = {"code": token, "email": email} diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index fc740afc5..1ff160afc 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -14,7 +14,7 @@ from sentry_sdk import capture_exception # Module imports from plane.db.models import Project, User, ProjectMemberInvite from plane.license.models import InstanceConfiguration -from plane.license.utils.instance_value import get_configuration_value +from plane.license.utils.instance_value import get_email_configuration @shared_task def project_invitation(email, project_id, token, current_site, invitor): @@ -48,42 +48,27 @@ def project_invitation(email, project_id, token, current_site, invitor): # Configure email connection from the database instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration(instance_configuration=instance_configuration) + connection = get_connection( - host=get_configuration_value( - instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") - ), - port=int( - get_configuration_value( - instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") - ) - ), - username=get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER"), - ), - password=get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD"), - ), - use_tls=bool( - get_configuration_value( - instance_configuration, - "EMAIL_USE_TLS", - os.environ.get("EMAIL_USE_TLS", "1"), - ) - ), + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), ) msg = EmailMultiAlternatives( subject=subject, body=text_content, - from_email=get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", "Team Plane "), - ), + from_email=EMAIL_FROM, to=[email], connection=connection, ) diff --git a/apiserver/plane/bgtasks/user_count_task.py b/apiserver/plane/bgtasks/user_count_task.py deleted file mode 100644 index dd8b19e7d..000000000 --- a/apiserver/plane/bgtasks/user_count_task.py +++ /dev/null @@ -1,45 +0,0 @@ -# Python imports -import json -import requests -import os - -# django imports -from django.conf import settings - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception - -# Module imports -from plane.db.models import User -from plane.license.models import Instance - -@shared_task -def update_user_instance_user_count(): - try: - instance_users = User.objects.filter(is_bot=False).count() - instance = Instance.objects.update(user_count=instance_users) - - # Update the count in the license engine - payload = { - "user_count": User.objects.count(), - } - - # Save the user in control center - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - # Update the license engine - _ = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps(payload), - ) - - except Exception as e: - if settings.DEBUG: - print(e) - capture_exception(e) diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index fe8708ed7..08fbaea62 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -50,31 +50,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): EMAIL_FROM, ) = get_email_configuration(instance_configuration=instance_configuration) - # Send the email if the users don't have smtp configured - if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): - # Check the instance registration - instance = Instance.objects.first() - - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - payload = { - "user": user.first_name or user.display_name or user.email, - "workspace_name": workspace.name, - "invitation_url": abs_url, - "email": email, - } - _ = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/workspace-invitation/", - headers=headers, - data=json.dumps(payload), - ) - - return - # Subject of the email subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 4d4e3475b..eb90c6205 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -28,9 +28,13 @@ app.conf.beat_schedule = { "task": "plane.bgtasks.file_asset_task.delete_file_asset", "schedule": crontab(hour=0, minute=0), }, + "check-instance-verification": { + "task": "plane.license.bgtasks.instance_verification_task.instance_verification_task", + "schedule": crontab(minute=0, hour='*/4'), + }, } # Load task modules from all registered Django app configs. app.autodiscover_tasks() -app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler' +app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler" diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index 077d90eb8..173d718d9 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -43,7 +43,7 @@ class InstanceConfigurationSerializer(BaseSerializer): def to_representation(self, instance): data = super().to_representation(instance) # Decrypt secrets value - if instance.key in ["OPENAI_API_KEY", "GITHUB_CLIENT_SECRET", "EMAIL_HOST_PASSWORD", "UNSPLASH_ACESS_KEY"] and instance.value is not None: + if instance.is_encrypted and instance.value is not None: data["value"] = decrypt_data(instance.value) - return data \ No newline at end of file + return data diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index 591bb059f..3a66c94c5 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -2,8 +2,6 @@ from .instance import ( InstanceEndpoint, InstanceAdminEndpoint, InstanceConfigurationEndpoint, - AdminSetupMagicSignInEndpoint, + InstanceAdminSignInEndpoint, SignUpScreenVisitedEndpoint, - AdminMagicSignInGenerateEndpoint, - AdminSetUserPasswordEndpoint, ) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 7dbc5e4b4..1e2d34bd0 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -27,14 +27,11 @@ from plane.license.api.serializers import ( InstanceAdminSerializer, InstanceConfigurationSerializer, ) -from plane.app.serializers import UserSerializer from plane.license.api.permissions import ( InstanceAdminPermission, ) from plane.db.models import User from plane.license.utils.encryption import encrypt_data -from plane.settings.redis import redis_instance -from plane.bgtasks.magic_link_code_task import magic_link class InstanceEndpoint(BaseAPIView): @@ -47,61 +44,6 @@ class InstanceEndpoint(BaseAPIView): AllowAny(), ] - def post(self, request): - # Check if the instance is registered - instance = Instance.objects.first() - - # If instance is None then register this instance - if instance is None: - with open("package.json", "r") as file: - # Load JSON content from the file - data = json.load(file) - - headers = {"Content-Type": "application/json"} - - payload = { - "instance_key":settings.INSTANCE_KEY, - "version": data.get("version", 0.1), - "machine_signature": os.environ.get("MACHINE_SIGNATURE"), - "user_count": User.objects.filter(is_bot=False).count(), - } - - response = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps(payload), - ) - - if response.status_code == 201: - data = response.json() - # Create instance - instance = Instance.objects.create( - instance_name="Plane Free", - instance_id=data.get("id"), - license_key=data.get("license_key"), - api_key=data.get("api_key"), - version=data.get("version"), - last_checked_at=timezone.now(), - user_count=data.get("user_count", 0), - ) - - serializer = InstanceSerializer(instance) - data = serializer.data - data["is_activated"] = True - return Response( - data, - status=status.HTTP_201_CREATED, - ) - return Response( - {"error": "Instance could not be registered"}, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - return Response( - {"message": "Instance already registered"}, - status=status.HTTP_200_OK, - ) - def get(self, request): instance = Instance.objects.first() # get the instance @@ -122,24 +64,6 @@ class InstanceEndpoint(BaseAPIView): serializer = InstanceSerializer(instance, data=request.data, partial=True) if serializer.is_valid(): serializer.save() - # Save the user in control center - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - # Update instance settings in the license engine - _ = requests.patch( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps( - { - "is_support_required": serializer.data["is_support_required"], - "is_telemetry_enabled": serializer.data["is_telemetry_enabled"], - "version": serializer.data["version"], - } - ), - ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -212,12 +136,7 @@ class InstanceConfigurationEndpoint(BaseAPIView): bulk_configurations = [] for configuration in configurations: value = request.data.get(configuration.key, configuration.value) - if value is not None and configuration.key in [ - "OPENAI_API_KEY", - "GITHUB_CLIENT_SECRET", - "EMAIL_HOST_PASSWORD", - "UNSPLASH_ACESS_KEY", - ]: + if configuration.is_encrypted: configuration.value = encrypt_data(value) else: configuration.value = value @@ -239,15 +158,13 @@ def get_tokens_for_user(user): ) -class AdminMagicSignInGenerateEndpoint(BaseAPIView): +class InstanceAdminSignInEndpoint(BaseAPIView): permission_classes = [ AllowAny, ] def post(self, request): - email = request.data.get("email", False) - - # Check the instance registration + # Check instance first instance = Instance.objects.first() if instance is None: return Response( @@ -255,193 +172,63 @@ class AdminMagicSignInGenerateEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # check if the instance is already activated if InstanceAdmin.objects.first(): return Response( {"error": "Admin for this instance is already registered"}, status=status.HTTP_400_BAD_REQUEST, ) - if not email: - return Response( - {"error": "Please provide a valid email address"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Clean up - email = email.strip().lower() - validate_email(email) - - # check if the email exists - if not User.objects.filter(email=email).exists(): - # Create a user - _ = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - ) - - ## Generate a random token - token = ( - "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - ) - - ri = redis_instance() - - key = "magic_" + str(email) - - # Check if the key already exists in python - if ri.exists(key): - data = json.loads(ri.get(key)) - - current_attempt = data["current_attempt"] + 1 - - if data["current_attempt"] > 2: - return Response( - {"error": "Max attempts exhausted. Please try again later."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - value = { - "current_attempt": current_attempt, - "email": email, - "token": token, - } - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - else: - value = {"current_attempt": 0, "email": email, "token": token} - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - # If the smtp is configured send through here - current_site = request.META.get("HTTP_ORIGIN") - magic_link.delay(email, key, token, current_site) - - return Response({"key": key}, status=status.HTTP_200_OK) - - -class AdminSetupMagicSignInEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - user_token = request.data.get("token", "").strip() - key = request.data.get("key", "").strip().lower() - - if not key or user_token == "": - return Response( - {"error": "User token and key are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if InstanceAdmin.objects.first(): - return Response( - {"error": "Admin for this instance is already registered"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - ri = redis_instance() - - if ri.exists(key): - data = json.loads(ri.get(key)) - - token = data["token"] - email = data["email"] - - if str(token) == str(user_token): - # get the user - user = User.objects.get(email=email) - # get the email - user.is_active = True - user.is_email_verified = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_200_OK) - - else: - return Response( - {"error": "Your login code was incorrect. Please try again."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - else: - return Response( - {"error": "The magic code/link has expired please try again"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - -class AdminSetUserPasswordEndpoint(BaseAPIView): - def post(self, request): - user = User.objects.get(pk=request.user.id) + # Get the email and password from all the user + email = request.data.get("email", False) password = request.data.get("password", False) - # If the user password is not autoset then return error - if not user.is_password_autoset: + # return error if the email and password is not present + if not email or not password: return Response( - { - "error": "Your password is already set please change your password from profile" - }, + {"error": "Email and password are required"}, status=status.HTTP_400_BAD_REQUEST, ) - # Check password validation - if not password and len(str(password)) < 8: + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError as e: return Response( - {"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST - ) - - instance = Instance.objects.first() - if instance is None: - return Response( - {"error": "Instance is not configured"}, + {"error": "Please provide a valid email address."}, status=status.HTTP_400_BAD_REQUEST, ) - # Save the user in control center - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - _ = requests.patch( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps({"is_setup_done": True}), - ) + # Check if already a user exists or not + user = User.objects.filter(email=email).first() - # Also register the user as admin - _ = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/", - headers=headers, - data=json.dumps( - { - "email": str(user.email), - "signup_mode": "MAGIC_CODE", - "is_admin": True, - } - ), - ) + # Existing user + if user: + # Check user password + if not user.check_password(password): + return Response( + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + status=status.HTTP_403_FORBIDDEN, + ) + else: + user = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(password), + is_password_autoset=False, + ) + + # settings last active for the user + user.is_active = True + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() # Register the user as an instance admin _ = InstanceAdmin.objects.create( @@ -452,12 +239,13 @@ class AdminSetUserPasswordEndpoint(BaseAPIView): instance.is_setup_done = True instance.save() - # Set the user password - user.set_password(password) - user.is_password_autoset = False - user.save() - serializer = UserSerializer(user) - return Response(serializer.data, status=status.HTTP_200_OK) + # get tokens for user + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + return Response(data, status=status.HTTP_200_OK) class SignUpScreenVisitedEndpoint(BaseAPIView): @@ -467,27 +255,11 @@ class SignUpScreenVisitedEndpoint(BaseAPIView): def post(self, request): instance = Instance.objects.first() - if instance is None: return Response( {"error": "Instance is not configured"}, status=status.HTTP_400_BAD_REQUEST, ) - - if not instance.is_signup_screen_visited: - instance.is_signup_screen_visited = True - instance.save() - # set the headers - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - # create the payload - payload = {"is_signup_screen_visited": True} - _ = requests.patch( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps(payload), - ) + instance.is_signup_screen_visited = True + instance.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/license/bgtasks/__init__.py b/apiserver/plane/license/bgtasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/bgtasks/instance_verification_task.py b/apiserver/plane/license/bgtasks/instance_verification_task.py new file mode 100644 index 000000000..3ffaa6ba8 --- /dev/null +++ b/apiserver/plane/license/bgtasks/instance_verification_task.py @@ -0,0 +1,136 @@ +# Python imports +import os +import json +import requests + +# Django imports +from django.conf import settings + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import User +from plane.license.models import Instance, InstanceAdmin + + +def instance_verification(instance): + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + headers = {"Content-Type": "application/json"} + payload = { + "instance_key": settings.INSTANCE_KEY, + "version": data.get("version", 0.1), + "machine_signature": os.environ.get("MACHINE_SIGNATURE", "machine-signature"), + "user_count": User.objects.filter(is_bot=False).count(), + } + # Register the instance + response = requests.post( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", + headers=headers, + data=json.dumps(payload), + timeout=30, + ) + + # check response status + if response.status_code == 201: + data = response.json() + # Update instance + instance.instance_id = data.get("id") + instance.license_key = data.get("license_key") + instance.api_key = data.get("api_key") + instance.version = data.get("version") + instance.user_count = data.get("user_count", 0) + instance.is_verified = True + instance.save() + else: + return + + +def admin_verification(instance): + # Save the user in control center + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + # Get all the unverified instance admins + instance_admins = InstanceAdmin.objects.filter(is_verified=False).select_related( + "user" + ) + updated_instance_admin = [] + + # Verify the instance admin + for instance_admin in instance_admins: + instance_admin.is_verified = True + # Create the admin + response = requests.post( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/", + headers=headers, + data=json.dumps( + { + "email": str(instance_admin.user.email), + "signup_mode": "EMAIL", + "is_admin": True, + } + ), + timeout=30, + ) + updated_instance_admin.append(instance_admin) + + # update all the instance admins + InstanceAdmin.objects.bulk_update( + updated_instance_admin, ["is_verified"], batch_size=10 + ) + return + +def instance_user_count(instance): + try: + instance_users = User.objects.filter(is_bot=False).count() + + # Update the count in the license engine + payload = { + "user_count": instance_users, + } + + # Save the user in control center + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + # Update the license engine + _ = requests.post( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", + headers=headers, + data=json.dumps(payload), + timeout=30, + ) + return + except requests.RequestException: + return + + +@shared_task +def instance_verification_task(): + try: + # Get the first instance + instance = Instance.objects.first() + + # Only register instance if it is not verified + if not instance.is_verified: + instance_verification(instance=instance) + + # Admin verifications + admin_verification(instance=instance) + + # Update user count + instance_user_count(instance=instance) + + return + except requests.RequestException: + return diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 60d8ce237..67137d0d9 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -21,84 +21,91 @@ class Command(BaseCommand): "key": "ENABLE_SIGNUP", "value": os.environ.get("ENABLE_SIGNUP", "1"), "category": "AUTHENTICATION", + "is_encrypted": False, }, { "key": "ENABLE_EMAIL_PASSWORD", "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), "category": "AUTHENTICATION", + "is_encrypted": False, }, { "key": "ENABLE_MAGIC_LINK_LOGIN", "value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), "category": "AUTHENTICATION", + "is_encrypted": False, }, { "key": "GOOGLE_CLIENT_ID", "value": os.environ.get("GOOGLE_CLIENT_ID"), "category": "GOOGLE", + "is_encrypted": False, }, { "key": "GITHUB_CLIENT_ID", "value": os.environ.get("GITHUB_CLIENT_ID"), "category": "GITHUB", + "is_encrypted": False, }, { "key": "GITHUB_CLIENT_SECRET", - "value": encrypt_data(os.environ.get("GITHUB_CLIENT_SECRET")) - if os.environ.get("GITHUB_CLIENT_SECRET") - else None, + "value": os.environ.get("GITHUB_CLIENT_SECRET"), "category": "GITHUB", + "is_encrypted": True, }, { "key": "EMAIL_HOST", "value": os.environ.get("EMAIL_HOST", ""), "category": "SMTP", + "is_encrypted": False, }, { "key": "EMAIL_HOST_USER", "value": os.environ.get("EMAIL_HOST_USER", ""), "category": "SMTP", + "is_encrypted": False, }, { "key": "EMAIL_HOST_PASSWORD", - "value": encrypt_data(os.environ.get("EMAIL_HOST_PASSWORD")) - if os.environ.get("EMAIL_HOST_PASSWORD") - else None, + "value": os.environ.get("EMAIL_HOST_PASSWORD", ""), "category": "SMTP", + "is_encrypted": True, }, { "key": "EMAIL_PORT", "value": os.environ.get("EMAIL_PORT", "587"), "category": "SMTP", + "is_encrypted": False, }, { "key": "EMAIL_FROM", "value": os.environ.get("EMAIL_FROM", ""), "category": "SMTP", + "is_encrypted": False, }, { "key": "EMAIL_USE_TLS", "value": os.environ.get("EMAIL_USE_TLS", "1"), "category": "SMTP", + "is_encrypted": False, }, { "key": "OPENAI_API_KEY", - "value": encrypt_data(os.environ.get("OPENAI_API_KEY")) - if os.environ.get("OPENAI_API_KEY") - else None, + "value": os.environ.get("OPENAI_API_KEY"), "category": "OPENAI", + "is_encrypted": True, }, { "key": "GPT_ENGINE", "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), "category": "SMTP", + "is_encrypted": False, }, { "key": "UNSPLASH_ACCESS_KEY", - "value": encrypt_data(os.environ.get("UNSPLASH_ACESS_KEY", "")) - if os.environ.get("UNSPLASH_ACESS_KEY") - else None, + "value": os.environ.get("UNSPLASH_ACESS_KEY", ""), "category": "UNSPLASH", + "is_encrypted": True, }, ] @@ -107,8 +114,12 @@ class Command(BaseCommand): key=item.get("key") ) if created: - obj.value = item.get("value") obj.category = item.get("category") + obj.is_encrypted = item.get("is_encrypted", False) + if item.get("is_encrypted", False): + obj.value = encrypt_data(item.get("value")) + else: + obj.value = item.get("value") obj.save() self.stdout.write( self.style.SUCCESS( diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 0970d8093..9d467b9dd 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -1,7 +1,7 @@ # Python imports import json -import os import requests +import secrets # Django imports from django.core.management.base import BaseCommand, CommandError @@ -31,13 +31,12 @@ class Command(BaseCommand): data = json.load(file) machine_signature = options.get("machine_signature", False) - if not machine_signature: raise CommandError("Machine signature is required") + # Check if machine is online headers = {"Content-Type": "application/json"} - payload = { "instance_key": settings.INSTANCE_KEY, "version": data.get("version", 0.1), @@ -45,25 +44,44 @@ class Command(BaseCommand): "user_count": User.objects.filter(is_bot=False).count(), } - response = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps(payload), - ) - - if response.status_code == 201: - data = response.json() - # Create instance - instance = Instance.objects.create( - instance_name="Plane Free", - instance_id=data.get("id"), - license_key=data.get("license_key"), - api_key=data.get("api_key"), - version=data.get("version"), - last_checked_at=timezone.now(), - user_count=data.get("user_count", 0), + try: + response = requests.post( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", + headers=headers, + data=json.dumps(payload), + timeout=30 ) + if response.status_code == 201: + data = response.json() + # Create instance + instance = Instance.objects.create( + instance_name="Plane Free", + instance_id=data.get("id"), + license_key=data.get("license_key"), + api_key=data.get("api_key"), + version=data.get("version"), + last_checked_at=timezone.now(), + user_count=data.get("user_count", 0), + is_verified=True, + ) + + self.stdout.write( + self.style.SUCCESS( + f"Instance successfully registered and verified" + ) + ) + return + except requests.RequestException as _e: + instance = Instance.objects.create( + instance_name="Plane Free", + instance_id=secrets.token_hex(12), + license_key=None, + api_key=secrets.token_hex(8), + version=payload.get("version"), + last_checked_at=timezone.now(), + user_count=payload.get("user_count", 0), + ) self.stdout.write( self.style.SUCCESS( f"Instance successfully registered" diff --git a/apiserver/plane/license/migrations/0001_initial.py b/apiserver/plane/license/migrations/0001_initial.py index 884691434..c8b5f1f02 100644 --- a/apiserver/plane/license/migrations/0001_initial.py +++ b/apiserver/plane/license/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.7 on 2023-11-29 14:39 +# Generated by Django 4.2.7 on 2023-12-06 06:49 from django.conf import settings from django.db import migrations, models @@ -34,6 +34,7 @@ class Migration(migrations.Migration): ('is_setup_done', models.BooleanField(default=False)), ('is_signup_screen_visited', models.BooleanField(default=False)), ('user_count', models.PositiveBigIntegerField(default=0)), + ('is_verified', models.BooleanField(default=False)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), ], @@ -53,6 +54,7 @@ class Migration(migrations.Migration): ('key', models.CharField(max_length=100, unique=True)), ('value', models.TextField(blank=True, default=None, null=True)), ('category', models.TextField()), + ('is_encrypted', models.BooleanField(default=False)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), ], @@ -70,6 +72,7 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), ('role', models.PositiveIntegerField(choices=[(20, 'Admin')], default=20)), + ('is_verified', models.BooleanField(default=False)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py index 5216a7221..86845c34b 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -30,6 +30,7 @@ class Instance(BaseModel): is_signup_screen_visited = models.BooleanField(default=False) # users user_count = models.PositiveBigIntegerField(default=0) + is_verified = models.BooleanField(default=False) class Meta: verbose_name = "Instance" @@ -47,6 +48,7 @@ class InstanceAdmin(BaseModel): ) instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20) + is_verified = models.BooleanField(default=False) class Meta: unique_together = ["instance", "user"] @@ -61,6 +63,7 @@ class InstanceConfiguration(BaseModel): key = models.CharField(max_length=100, unique=True) value = models.TextField(null=True, blank=True, default=None) category = models.TextField() + is_encrypted = models.BooleanField(default=False) class Meta: verbose_name = "Instance Configuration" diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index 5e416c40b..807833a7e 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -4,9 +4,7 @@ from plane.license.api.views import ( InstanceEndpoint, InstanceAdminEndpoint, InstanceConfigurationEndpoint, - AdminMagicSignInGenerateEndpoint, - AdminSetupMagicSignInEndpoint, - AdminSetUserPasswordEndpoint, + InstanceAdminSignInEndpoint, SignUpScreenVisitedEndpoint, ) @@ -32,19 +30,9 @@ urlpatterns = [ name="instance-configuration", ), path( - "instances/admins/magic-generate/", - AdminMagicSignInGenerateEndpoint.as_view(), - name="instance-admins", - ), - path( - "instances/admins/magic-sign-in/", - AdminSetupMagicSignInEndpoint.as_view(), - name="instance-admins", - ), - path( - "instances/admins/set-password/", - AdminSetUserPasswordEndpoint.as_view(), - name="instance-admins", + "instances/admins/sign-in/", + InstanceAdminSignInEndpoint.as_view(), + name="instance-admin-sign-in", ), path( "instances/admins/sign-up-screen-visited/", diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index fff6b9e90..886339266 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -110,7 +110,9 @@ CSRF_COOKIE_SECURE = True CORS_ALLOW_CREDENTIALS = True cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "") # filter out empty strings -cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()] +cors_allowed_origins = [ + origin.strip() for origin in cors_origins_raw.split(",") if origin.strip() +] if cors_allowed_origins: CORS_ALLOWED_ORIGINS = cors_allowed_origins else: @@ -286,6 +288,7 @@ CELERY_IMPORTS = ( "plane.bgtasks.issue_automation_task", "plane.bgtasks.exporter_expired_task", "plane.bgtasks.file_asset_task", + "plane.license.bgtasks.instance_verification_task", ) # Sentry Settings @@ -327,7 +330,11 @@ POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) # License engine base url -LICENSE_ENGINE_BASE_URL = os.environ.get("LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so") +LICENSE_ENGINE_BASE_URL = os.environ.get( + "LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so" +) # instance key -INSTANCE_KEY = os.environ.get("INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3") +INSTANCE_KEY = os.environ.get( + "INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3" +) diff --git a/web/components/account/github-login-button.tsx b/web/components/account/github-login-button.tsx index b49d06f2b..dbb75243d 100644 --- a/web/components/account/github-login-button.tsx +++ b/web/components/account/github-login-button.tsx @@ -38,7 +38,7 @@ export const GithubLoginButton: FC = (props) => { setLoginCallBackURL(`${origin}/` as any); }, []); return ( -
+
diff --git a/web/components/account/sign-in-forms/create-password.tsx b/web/components/account/sign-in-forms/create-password.tsx index bc8fb300c..5d6b7da61 100644 --- a/web/components/account/sign-in-forms/create-password.tsx +++ b/web/components/account/sign-in-forms/create-password.tsx @@ -16,6 +16,7 @@ type Props = { email: string; handleStepChange: (step: ESignInSteps) => void; handleSignInRedirection: () => Promise; + isOnboarded: boolean; }; type TCreatePasswordFormValues = { @@ -32,7 +33,7 @@ const defaultValues: TCreatePasswordFormValues = { const authService = new AuthService(); export const CreatePasswordForm: React.FC = (props) => { - const { email, handleSignInRedirection } = props; + const { email, handleSignInRedirection, isOnboarded } = props; // toast alert const { setToastAlert } = useToast(); // form info @@ -76,9 +77,8 @@ export const CreatePasswordForm: React.FC = (props) => { return ( <>

- Let{"'"}s get a new password + Get on your flight deck

-
= (props) => { ref={ref} hasError={Boolean(errors.email)} placeholder="orville.wright@firstflight.com" - className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12" + className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200" disabled /> )} /> -
- ( - - )} - /> -

- Whatever you choose now will be your account{"'"}s password until you change it. -

-
+ ( + + )} + />

When you click the button above, you agree with our{" "} diff --git a/web/components/account/sign-in-forms/email-form.tsx b/web/components/account/sign-in-forms/email-form.tsx index d9dc1c396..abbb7bc74 100644 --- a/web/components/account/sign-in-forms/email-form.tsx +++ b/web/components/account/sign-in-forms/email-form.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React from "react"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; // services @@ -10,7 +10,7 @@ import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types -import { IEmailCheckData, TEmailCheckTypes } from "types/auth"; +import { IEmailCheckData } from "types/auth"; // constants import { ESignInSteps } from "components/account"; @@ -19,7 +19,7 @@ type Props = { updateEmail: (email: string) => void; }; -type TEmailCodeFormValues = { +type TEmailFormValues = { email: string; }; @@ -27,17 +27,14 @@ const authService = new AuthService(); export const EmailForm: React.FC = (props) => { const { handleStepChange, updateEmail } = props; - // states - const [isCheckingEmail, setIsCheckingEmail] = useState(null); const { setToastAlert } = useToast(); const { control, - formState: { errors, isValid }, + formState: { errors, isSubmitting, isValid }, handleSubmit, - watch, - } = useForm({ + } = useForm({ defaultValues: { email: "", }, @@ -45,31 +42,21 @@ export const EmailForm: React.FC = (props) => { reValidateMode: "onChange", }); - const handleEmailCheck = async (type: TEmailCheckTypes) => { - setIsCheckingEmail(type); - - const email = watch("email"); - + const handleFormSubmit = async (data: TEmailFormValues) => { const payload: IEmailCheckData = { - email, - type, + email: data.email, }; // update the global email state - updateEmail(email); + updateEmail(data.email); await authService .emailCheck(payload) .then((res) => { - // if type is magic_code, send the user to magic sign in - if (type === "magic_code") handleStepChange(ESignInSteps.UNIQUE_CODE); - // if type is password, check if the user has a password set - if (type === "password") { - // if password is autoset, send them to set new password link - if (res.is_password_autoset) handleStepChange(ESignInSteps.SET_PASSWORD_LINK); - // if password is not autoset, send them to password form - else handleStepChange(ESignInSteps.PASSWORD); - } + // if the password has been autoset, send the user to magic sign-in + if (res.is_password_autoset) handleStepChange(ESignInSteps.UNIQUE_CODE); + // if the password has not been autoset, send them to password sign-in + else handleStepChange(ESignInSteps.PASSWORD); }) .catch((err) => setToastAlert({ @@ -77,8 +64,7 @@ export const EmailForm: React.FC = (props) => { title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) - ) - .finally(() => setIsCheckingEmail(null)); + ); }; return ( @@ -86,11 +72,11 @@ export const EmailForm: React.FC = (props) => {

Get on your flight deck

-

- Sign in with the email you used to sign up for Plane +

+ Create or join a workspace. Start with your e-mail.

- {})} className="mt-5 sm:w-96 mx-auto"> +
= (props) => { )} />
-
- - -
+ ); diff --git a/web/components/account/sign-in-forms/index.ts b/web/components/account/sign-in-forms/index.ts index 694fb3ef0..1150a071c 100644 --- a/web/components/account/sign-in-forms/index.ts +++ b/web/components/account/sign-in-forms/index.ts @@ -2,7 +2,8 @@ export * from "./create-password"; export * from "./email-form"; export * from "./o-auth-options"; export * from "./optional-set-password"; -export * from "./unique-code"; export * from "./password"; export * from "./root"; +export * from "./self-hosted-sign-in"; export * from "./set-password-link"; +export * from "./unique-code"; diff --git a/web/components/account/sign-in-forms/o-auth-options.tsx b/web/components/account/sign-in-forms/o-auth-options.tsx index 690dc7697..5937b7a3c 100644 --- a/web/components/account/sign-in-forms/o-auth-options.tsx +++ b/web/components/account/sign-in-forms/o-auth-options.tsx @@ -3,24 +3,20 @@ import { observer } from "mobx-react-lite"; import { useMobxStore } from "lib/mobx/store-provider"; // services import { AuthService } from "services/auth.service"; -import { UserService } from "services/user.service"; // hooks import useToast from "hooks/use-toast"; // components -import { ESignInSteps, GithubLoginButton, GoogleLoginButton } from "components/account"; +import { GithubLoginButton, GoogleLoginButton } from "components/account"; type Props = { - updateEmail: (email: string) => void; - handleStepChange: (step: ESignInSteps) => void; handleSignInRedirection: () => Promise; }; // services const authService = new AuthService(); -const userService = new UserService(); export const OAuthOptions: React.FC = observer((props) => { - const { updateEmail, handleStepChange, handleSignInRedirection } = props; + const { handleSignInRedirection } = props; // toast alert const { setToastAlert } = useToast(); // mobx store @@ -38,14 +34,7 @@ export const OAuthOptions: React.FC = observer((props) => { }; const response = await authService.socialAuth(socialAuthPayload); - if (response) { - const currentUser = await userService.currentUser(); - - updateEmail(currentUser.email); - - if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD); - else handleSignInRedirection(); - } + if (response) handleSignInRedirection(); } else throw Error("Cant find credentials"); } catch (err: any) { setToastAlert({ @@ -66,14 +55,7 @@ export const OAuthOptions: React.FC = observer((props) => { }; const response = await authService.socialAuth(socialAuthPayload); - if (response) { - const currentUser = await userService.currentUser(); - - updateEmail(currentUser.email); - - if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD); - else handleSignInRedirection(); - } + if (response) handleSignInRedirection(); } else throw Error("Cant find credentials"); } catch (err: any) { setToastAlert({ @@ -87,11 +69,11 @@ export const OAuthOptions: React.FC = observer((props) => { return ( <>
-
+

Or continue with

-
+
-
+
{envConfig?.google_client_id && ( )} diff --git a/web/components/account/sign-in-forms/optional-set-password.tsx b/web/components/account/sign-in-forms/optional-set-password.tsx index 57161054b..2c6e7d2d3 100644 --- a/web/components/account/sign-in-forms/optional-set-password.tsx +++ b/web/components/account/sign-in-forms/optional-set-password.tsx @@ -12,13 +12,14 @@ type Props = { email: string; handleStepChange: (step: ESignInSteps) => void; handleSignInRedirection: () => Promise; + isOnboarded: boolean; }; export const OptionalSetPasswordForm: React.FC = (props) => { - const { email, handleStepChange, handleSignInRedirection } = props; + const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props; // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); - + // form info const { control, formState: { errors, isValid }, @@ -39,8 +40,8 @@ export const OptionalSetPasswordForm: React.FC = (props) => { return ( <>

Set a password

-

- If you{"'"}d to do away with codes, set a password here +

+ If you{"'"}d like to do away with codes, set a password here.

@@ -86,11 +87,13 @@ export const OptionalSetPasswordForm: React.FC = (props) => { disabled={!isValid} loading={isGoingToWorkspace} > - {isGoingToWorkspace ? "Going to app..." : "Go to workspace"} + {isOnboarded ? "Go to workspace" : "Set up workspace"}

- When you click Go to workspace above, you agree with our{" "} + When you click{" "} + {isOnboarded ? "Go to workspace" : "Set up workspace"} above, + you agree with our{" "} terms and conditions of service. diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index 44c91a51f..5412948bc 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -11,7 +11,7 @@ import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types -import { IEmailCheckData, IPasswordSignInData } from "types/auth"; +import { IPasswordSignInData } from "types/auth"; // constants import { ESignInSteps } from "components/account"; @@ -37,6 +37,7 @@ const authService = new AuthService(); export const PasswordForm: React.FC = (props) => { const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; // states + const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); // toast alert const { setToastAlert } = useToast(); @@ -46,7 +47,6 @@ export const PasswordForm: React.FC = (props) => { formState: { dirtyFields, errors, isSubmitting, isValid }, getValues, handleSubmit, - reset, setError, } = useForm({ defaultValues: { @@ -57,7 +57,9 @@ export const PasswordForm: React.FC = (props) => { reValidateMode: "onChange", }); - const handlePasswordSignIn = async (formData: TPasswordFormValues) => { + const handleFormSubmit = async (formData: TPasswordFormValues) => { + updateEmail(formData.email); + const payload: IPasswordSignInData = { email: formData.email, password: formData.password, @@ -75,36 +77,6 @@ export const PasswordForm: React.FC = (props) => { ); }; - const handleEmailCheck = async (formData: TPasswordFormValues) => { - const payload: IEmailCheckData = { - email: formData.email, - type: "password", - }; - - await authService - .emailCheck(payload) - .then((res) => { - if (res.is_password_autoset) handleStepChange(ESignInSteps.SET_PASSWORD_LINK); - else - reset({ - email: formData.email, - password: "", - }); - }) - .catch((err) => - setToastAlert({ - type: "error", - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ); - }; - - const handleFormSubmit = async (formData: TPasswordFormValues) => { - if (dirtyFields.email) await handleEmailCheck(formData); - else await handlePasswordSignIn(formData); - }; - const handleForgotPassword = async () => { const emailFormValue = getValues("email"); @@ -130,6 +102,31 @@ export const PasswordForm: React.FC = (props) => { .finally(() => setIsSendingResetPasswordLink(false)); }; + const handleSendUniqueCode = async () => { + const emailFormValue = getValues("email"); + + const isEmailValid = checkEmailValidity(emailFormValue); + + if (!isEmailValid) { + setError("email", { message: "Email is invalid" }); + return; + } + + setIsSendingUniqueCode(true); + + await authService + .generateUniqueCode({ email: emailFormValue }) + .then(() => handleStepChange(ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD)) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ) + .finally(() => setIsSendingUniqueCode(false)); + }; + return ( <>

@@ -151,13 +148,7 @@ export const PasswordForm: React.FC = (props) => { name="email" type="email" value={value} - onChange={(e) => { - updateEmail(e.target.value); - onChange(e.target.value); - }} - onBlur={() => { - if (dirtyFields.email) handleEmailCheck(getValues()); - }} + onChange={onChange} hasError={Boolean(errors.email)} placeholder="orville.wright@firstflight.com" className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" @@ -186,7 +177,7 @@ export const PasswordForm: React.FC = (props) => { onChange={onChange} hasError={Boolean(errors.password)} placeholder="Enter password" - className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" + className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200" /> )} /> @@ -199,15 +190,34 @@ export const PasswordForm: React.FC = (props) => { }`} disabled={isSendingResetPasswordLink} > - {isSendingResetPasswordLink ? "Sending link..." : "Forgot your password?"} + {isSendingResetPasswordLink ? "Sending link" : "Forgot your password?"}

- +
+ + +

- When you click the button above, you agree with our{" "} + When you click Go to workspace above, you agree with our{" "} terms and conditions of service. diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index 90e60e2b1..f7ec6b593 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -1,7 +1,11 @@ import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useSignInRedirection from "hooks/use-sign-in-redirection"; // components +import { LatestFeatureBlock } from "components/common"; import { EmailForm, UniqueCodeForm, @@ -10,6 +14,7 @@ import { OAuthOptions, OptionalSetPasswordForm, CreatePasswordForm, + SelfHostedSignInForm, } from "components/account"; export enum ESignInSteps { @@ -19,64 +24,96 @@ export enum ESignInSteps { UNIQUE_CODE = "UNIQUE_CODE", OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", CREATE_PASSWORD = "CREATE_PASSWORD", + USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD", } const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; -export const SignInRoot = () => { +export const SignInRoot = observer(() => { // states const [signInStep, setSignInStep] = useState(ESignInSteps.EMAIL); const [email, setEmail] = useState(""); + const [isOnboarded, setIsOnboarded] = useState(false); // sign in redirection hook const { handleRedirection } = useSignInRedirection(); + // mobx store + const { + appConfig: { envConfig }, + } = useMobxStore(); + + const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id); return ( <>

- {signInStep === ESignInSteps.EMAIL && ( - setSignInStep(step)} updateEmail={(newEmail) => setEmail(newEmail)} /> - )} - {signInStep === ESignInSteps.PASSWORD && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - /> - )} - {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( - setEmail(newEmail)} /> - )} - {signInStep === ESignInSteps.UNIQUE_CODE && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - /> - )} - {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( - setSignInStep(step)} - handleSignInRedirection={handleRedirection} - /> - )} - {signInStep === ESignInSteps.CREATE_PASSWORD && ( - setSignInStep(step)} handleSignInRedirection={handleRedirection} /> + ) : ( + <> + {signInStep === ESignInSteps.EMAIL && ( + setSignInStep(step)} + updateEmail={(newEmail) => setEmail(newEmail)} + /> + )} + {signInStep === ESignInSteps.PASSWORD && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + /> + )} + {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( + setEmail(newEmail)} /> + )} + {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + submitButtonLabel="Go to workspace" + showTermsAndConditions + updateUserOnboardingStatus={(value) => setIsOnboarded(value)} + /> + )} + {signInStep === ESignInSteps.UNIQUE_CODE && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + updateUserOnboardingStatus={(value) => setIsOnboarded(value)} + /> + )} + {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( + setSignInStep(step)} + handleSignInRedirection={handleRedirection} + isOnboarded={isOnboarded} + /> + )} + {signInStep === ESignInSteps.CREATE_PASSWORD && ( + setSignInStep(step)} + handleSignInRedirection={handleRedirection} + isOnboarded={isOnboarded} + /> + )} + )}
- {!OAUTH_HIDDEN_STEPS.includes(signInStep) && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - /> + {isOAuthEnabled && !OAUTH_HIDDEN_STEPS.includes(signInStep) && ( + )} + ); -}; +}); diff --git a/web/components/account/sign-in-forms/self-hosted-sign-in.tsx b/web/components/account/sign-in-forms/self-hosted-sign-in.tsx new file mode 100644 index 000000000..e4001ab41 --- /dev/null +++ b/web/components/account/sign-in-forms/self-hosted-sign-in.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/auth.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IPasswordSignInData } from "types/auth"; + +type Props = { + email: string; + updateEmail: (email: string) => void; + handleSignInRedirection: () => Promise; +}; + +type TPasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const SelfHostedSignInForm: React.FC = (props) => { + const { email, updateEmail, handleSignInRedirection } = props; + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + control, + formState: { dirtyFields, errors, isSubmitting }, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (formData: TPasswordFormValues) => { + const payload: IPasswordSignInData = { + email: formData.email, + password: formData.password, + }; + + updateEmail(formData.email); + + await authService + .passwordSignIn(payload) + .then(async () => await handleSignInRedirection()) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + return ( + <> +

+ Get on your flight deck +

+ +
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange } }) => ( +
+ + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> +
+
+ ( + + )} + /> +
+ +

+ When you click the button above, you agree with our{" "} + + terms and conditions of service. + +

+ + + ); +}; diff --git a/web/components/account/sign-in-forms/set-password-link.tsx b/web/components/account/sign-in-forms/set-password-link.tsx index 21cc8db17..ed39aaed9 100644 --- a/web/components/account/sign-in-forms/set-password-link.tsx +++ b/web/components/account/sign-in-forms/set-password-link.tsx @@ -1,7 +1,5 @@ -import React, { useState } from "react"; -import Link from "next/link"; +import React from "react"; import { Controller, useForm } from "react-hook-form"; -import { XCircle } from "lucide-react"; // services import { AuthService } from "services/auth.service"; // hooks @@ -22,14 +20,12 @@ const authService = new AuthService(); export const SetPasswordLink: React.FC = (props) => { const { email, updateEmail } = props; - // states - const [isSendingNewLink, setIsSendingNewLink] = useState(false); const { setToastAlert } = useToast(); const { control, - formState: { errors, isValid }, + formState: { errors, isSubmitting, isValid }, handleSubmit, } = useForm({ defaultValues: { @@ -40,17 +36,14 @@ export const SetPasswordLink: React.FC = (props) => { }); const handleSendNewLink = async (formData: { email: string }) => { - setIsSendingNewLink(true); - updateEmail(formData.email); const payload: IEmailCheckData = { email: formData.email, - type: "password", }; await authService - .emailCheck(payload) + .sendResetPasswordLink(payload) .then(() => setToastAlert({ type: "success", @@ -64,8 +57,7 @@ export const SetPasswordLink: React.FC = (props) => { title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) - ) - .finally(() => setIsSendingNewLink(false)); + ); }; return ( @@ -73,7 +65,7 @@ export const SetPasswordLink: React.FC = (props) => {

Get on your flight deck

-

+

We have sent a link to {email}, so you can set a password

@@ -87,45 +79,24 @@ export const SetPasswordLink: React.FC = (props) => { required: "Email is required", validate: (value) => checkEmailValidity(value) || "Email is invalid", }} - render={({ field: { value, onChange, ref } }) => ( -
- - {value.length > 0 && ( - onChange("")} - /> - )} -
+ render={({ field: { value, onChange } }) => ( + )} />
- -

- When you click the button above, you agree with our{" "} - - terms and conditions of service. - -

); diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 43e183fd5..51eb46845 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -22,6 +22,9 @@ type Props = { updateEmail: (email: string) => void; handleStepChange: (step: ESignInSteps) => void; handleSignInRedirection: () => Promise; + submitButtonLabel?: string; + showTermsAndConditions?: boolean; + updateUserOnboardingStatus: (value: boolean) => void; }; type TUniqueCodeFormValues = { @@ -39,7 +42,15 @@ const authService = new AuthService(); const userService = new UserService(); export const UniqueCodeForm: React.FC = (props) => { - const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; + const { + email, + updateEmail, + handleStepChange, + handleSignInRedirection, + submitButtonLabel = "Continue", + showTermsAndConditions = false, + updateUserOnboardingStatus, + } = props; // states const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); // toast alert @@ -74,6 +85,8 @@ export const UniqueCodeForm: React.FC = (props) => { .then(async () => { const currentUser = await userService.currentUser(); + updateUserOnboardingStatus(currentUser.is_onboarded); + if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD); else await handleSignInRedirection(); }) @@ -86,15 +99,15 @@ export const UniqueCodeForm: React.FC = (props) => { ); }; - const handleSendNewLink = async (formData: TUniqueCodeFormValues) => { + const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { const payload: IEmailCheckData = { email: formData.email, - type: "magic_code", }; await authService - .emailCheck(payload) + .generateUniqueCode(payload) .then(() => { + setResendCodeTimer(30); setToastAlert({ type: "success", title: "Success!", @@ -116,25 +129,30 @@ export const UniqueCodeForm: React.FC = (props) => { }; const handleFormSubmit = async (formData: TUniqueCodeFormValues) => { - if (dirtyFields.email) await handleSendNewLink(formData); + updateEmail(formData.email); + + if (dirtyFields.email) await handleSendNewCode(formData); else await handleUniqueCodeSignIn(formData); }; const handleRequestNewCode = async () => { setIsRequestingNewCode(true); - await handleSendNewLink(getValues()) + await handleSendNewCode(getValues()) .then(() => setResendCodeTimer(30)) .finally(() => setIsRequestingNewCode(false)); }; const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const hasEmailChanged = dirtyFields.email; return ( <> -

Moving to the runway

-

- Paste the code you got at {email} below +

+ Get on your flight deck +

+

+ Paste the code you got at {email} below.

@@ -153,12 +171,9 @@ export const UniqueCodeForm: React.FC = (props) => { name="email" type="email" value={value} - onChange={(e) => { - updateEmail(e.target.value); - onChange(e.target.value); - }} + onChange={onChange} onBlur={() => { - if (dirtyFields.email) handleSendNewLink(getValues()); + if (hasEmailChanged) handleSendNewCode(getValues()); }} ref={ref} hasError={Boolean(errors.email)} @@ -174,7 +189,7 @@ export const UniqueCodeForm: React.FC = (props) => { )} /> - {dirtyFields.email && ( + {hasEmailChanged && ( @@ -224,17 +239,19 @@ export const UniqueCodeForm: React.FC = (props) => { variant="primary" className="w-full" size="xl" - disabled={!isValid || dirtyFields.email} + disabled={!isValid || hasEmailChanged} loading={isSubmitting} > - {isSubmitting ? "Signing in..." : "Confirm"} + {submitButtonLabel} -

- When you click Confirm above, you agree with our{" "} - - terms and conditions of service. - -

+ {showTermsAndConditions && ( +

+ When you click the button above, you agree with our{" "} + + terms and conditions of service. + +

+ )} ); diff --git a/web/components/common/latest-feature-block.tsx b/web/components/common/latest-feature-block.tsx index bade52d62..5abbbf603 100644 --- a/web/components/common/latest-feature-block.tsx +++ b/web/components/common/latest-feature-block.tsx @@ -25,7 +25,7 @@ export const LatestFeatureBlock = () => { Plane Issues diff --git a/web/components/instance/not-ready-view.tsx b/web/components/instance/not-ready-view.tsx index 1333d65a6..dae2842aa 100644 --- a/web/components/instance/not-ready-view.tsx +++ b/web/components/instance/not-ready-view.tsx @@ -17,10 +17,10 @@ export const InstanceNotReady = () => {
- Plane logo + Plane logo
- Instance not ready + Instance not ready

Your Plane instance isn{"'"}t ready yet

diff --git a/web/components/instance/setup-done-view.tsx b/web/components/instance/setup-done-view.tsx index 4eab4013f..a560cf9d4 100644 --- a/web/components/instance/setup-done-view.tsx +++ b/web/components/instance/setup-done-view.tsx @@ -55,7 +55,7 @@ export const InstanceSetupDone = () => {

diff --git a/web/components/instance/setup-form/email-code-form.tsx b/web/components/instance/setup-form/email-code-form.tsx deleted file mode 100644 index 24643d833..000000000 --- a/web/components/instance/setup-form/email-code-form.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { FC, useState } from "react"; -import { useForm, Controller } from "react-hook-form"; -// ui -import { Input, Button } from "@plane/ui"; -// icons -import { XCircle } from "lucide-react"; -// services -import { AuthService } from "services/auth.service"; -const authService = new AuthService(); -// hooks -import useToast from "hooks/use-toast"; -import useTimer from "hooks/use-timer"; - -export interface InstanceSetupEmailCodeFormValues { - email: string; - token: string; -} - -export interface IInstanceSetupEmailCodeForm { - email: string; - handleNextStep: () => void; - moveBack: () => void; -} - -export const InstanceSetupEmailCodeForm: FC = (props) => { - const { handleNextStep, email, moveBack } = props; - // states - const [isResendingCode, setIsResendingCode] = useState(false); - // form info - const { - control, - handleSubmit, - reset, - formState: { isSubmitting }, - } = useForm({ - defaultValues: { - email, - token: "", - }, - }); - // hooks - const { setToastAlert } = useToast(); - const { timer, setTimer } = useTimer(30); - // computed - const isResendDisabled = timer > 0 || isResendingCode; - - const handleEmailCodeFormSubmit = async (formValues: InstanceSetupEmailCodeFormValues) => - await authService - .instanceMagicSignIn({ key: `magic_${formValues.email}`, token: formValues.token }) - .then(() => { - reset(); - handleNextStep(); - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }); - }); - - const resendMagicCode = async () => { - setIsResendingCode(true); - - await authService - .instanceAdminEmailCode({ email }) - .then(() => setTimer(30)) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }); - }) - .finally(() => setIsResendingCode(false)); - }; - - return ( -
-

- Let{"'"}s secure your instance -

-

- Paste the code you got at -
- {email} below. -

-
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - }} - render={({ field: { value, onChange } }) => ( -
- - moveBack()} - /> -
- )} - /> -
- -
-
- ( -
- -
- )} - /> - -
-
- ); -}; diff --git a/web/components/instance/setup-form/index.ts b/web/components/instance/setup-form/index.ts index f7a18d5b6..e9a965d6d 100644 --- a/web/components/instance/setup-form/index.ts +++ b/web/components/instance/setup-form/index.ts @@ -1,3 +1,2 @@ -export * from "./email-code-form"; -export * from "./email-form"; export * from "./root"; +export * from "./sign-in-form"; diff --git a/web/components/instance/setup-form/password-form.tsx b/web/components/instance/setup-form/password-form.tsx deleted file mode 100644 index 86053cfd7..000000000 --- a/web/components/instance/setup-form/password-form.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from "react"; -import Link from "next/link"; -import { useForm, Controller } from "react-hook-form"; -// ui -import { Input, Button } from "@plane/ui"; -// icons -import { XCircle } from "lucide-react"; -// services -import { AuthService } from "services/auth.service"; -const authService = new AuthService(); - -export interface InstanceSetupPasswordFormValues { - email: string; - password: string; -} - -export interface IInstanceSetupPasswordForm { - email: string; - onNextStep: () => void; - resetSteps: () => void; -} - -export const InstanceSetupPasswordForm: React.FC = (props) => { - const { onNextStep, email, resetSteps } = props; - // form info - const { - control, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - defaultValues: { - email, - password: "", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - const handlePasswordSubmit = (formData: InstanceSetupPasswordFormValues) => - authService.setInstanceAdminPassword({ password: formData.password }).then(() => { - onNextStep(); - }); - - return ( -
-
-

- Moving to the runway -

-

- Let{"'"}s set a password so you can do away with codes. -

- -
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - }} - render={({ field: { value, onChange } }) => ( -
- - resetSteps()} - /> -
- )} - /> -
- ( -
- -
- )} - /> -

- Whatever you choose now will be your account{"'"}s password -

-
- -

- When you click the button above, you agree with our{" "} - - terms and conditions of service. - -

-
-
-
- ); -}; diff --git a/web/components/instance/setup-form/root.tsx b/web/components/instance/setup-form/root.tsx index 46f5493ba..0e8f0d50b 100644 --- a/web/components/instance/setup-form/root.tsx +++ b/web/components/instance/setup-form/root.tsx @@ -1,63 +1,25 @@ import { useState } from "react"; // components -import { InstanceSetupEmailCodeForm } from "./email-code-form"; -import { InstanceSetupEmailForm } from "./email-form"; -import { InstanceSetupPasswordForm } from "./password-form"; import { LatestFeatureBlock } from "components/common"; -import { InstanceSetupDone } from "components/instance"; +import { InstanceSetupDone, InstanceSetupSignInForm } from "components/instance"; export enum EInstanceSetupSteps { - EMAIL = "EMAIL", - VERIFY_CODE = "VERIFY_CODE", - PASSWORD = "PASSWORD", + SIGN_IN = "SIGN_IN", DONE = "DONE", } export const InstanceSetupFormRoot = () => { // states - const [setupStep, setSetupStep] = useState(EInstanceSetupSteps.EMAIL); - const [email, setEmail] = useState(""); + const [setupStep, setSetupStep] = useState(EInstanceSetupSteps.SIGN_IN); return ( <> - {setupStep === EInstanceSetupSteps.DONE ? ( - - ) : ( + {setupStep === EInstanceSetupSteps.DONE && } + {setupStep === EInstanceSetupSteps.SIGN_IN && (
- {setupStep === EInstanceSetupSteps.EMAIL && ( - { - setEmail(email); - setSetupStep(EInstanceSetupSteps.VERIFY_CODE); - }} - /> - )} - - {setupStep === EInstanceSetupSteps.VERIFY_CODE && ( - { - setSetupStep(EInstanceSetupSteps.PASSWORD); - }} - moveBack={() => { - setSetupStep(EInstanceSetupSteps.EMAIL); - }} - /> - )} - - {setupStep === EInstanceSetupSteps.PASSWORD && ( - { - setSetupStep(EInstanceSetupSteps.DONE); - }} - resetSteps={() => { - setSetupStep(EInstanceSetupSteps.EMAIL); - }} - /> - )} + setSetupStep(EInstanceSetupSteps.DONE)} />
diff --git a/web/components/instance/setup-form/email-form.tsx b/web/components/instance/setup-form/sign-in-form.tsx similarity index 58% rename from web/components/instance/setup-form/email-form.tsx rename to web/components/instance/setup-form/sign-in-form.tsx index 7305095a9..42cacd70a 100644 --- a/web/components/instance/setup-form/email-form.tsx +++ b/web/components/instance/setup-form/sign-in-form.tsx @@ -1,5 +1,7 @@ import { FC } from "react"; import { useForm, Controller } from "react-hook-form"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // ui import { Input, Button } from "@plane/ui"; // icons @@ -9,37 +11,48 @@ import { AuthService } from "services/auth.service"; const authService = new AuthService(); // hooks import useToast from "hooks/use-toast"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; -export interface InstanceSetupEmailFormValues { +interface InstanceSetupEmailFormValues { email: string; + password: string; } export interface IInstanceSetupEmailForm { handleNextStep: (email: string) => void; } -export const InstanceSetupEmailForm: FC = (props) => { +export const InstanceSetupSignInForm: FC = (props) => { const { handleNextStep } = props; + const { + user: { fetchCurrentUser }, + } = useMobxStore(); // form info const { control, + formState: { errors, isSubmitting }, handleSubmit, setValue, - reset, - formState: { isSubmitting }, } = useForm({ defaultValues: { email: "", + password: "", }, }); // hooks const { setToastAlert } = useToast(); - const handleEmailFormSubmit = (formValues: InstanceSetupEmailFormValues) => - authService - .instanceAdminEmailCode({ email: formValues.email }) - .then(() => { - reset(); + const handleFormSubmit = async (formValues: InstanceSetupEmailFormValues) => { + const payload = { + email: formValues.email, + password: formValues.password, + }; + + await authService + .instanceAdminSignIn(payload) + .then(async () => { + await fetchCurrentUser(); handleNextStep(formValues.email); }) .catch((err) => { @@ -49,9 +62,10 @@ export const InstanceSetupEmailForm: FC = (props) => { message: err?.error ?? "Something went wrong. Please try again.", }); }); + }; return ( -
+

Let{"'"}s secure your instance

@@ -66,13 +80,10 @@ export const InstanceSetupEmailForm: FC = (props) => { control={control} rules={{ required: "Email address is required", - validate: (value) => - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", + validate: (value) => checkEmailValidity(value) || "Email is invalid", }} render={({ field: { value, onChange } }) => ( -
+
= (props) => { value={value} onChange={onChange} placeholder="orville.wright@firstflight.com" - className={`w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12`} + className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" /> {value.length > 0 && ( = (props) => {
)} /> + ( + + )} + />

Use your email address if you are the instance admin.
Use your admin’s e-mail if you are not.

diff --git a/web/components/page-views/signin.tsx b/web/components/page-views/signin.tsx index 281952ca9..12c68ed31 100644 --- a/web/components/page-views/signin.tsx +++ b/web/components/page-views/signin.tsx @@ -1,9 +1,6 @@ import { useEffect } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; import Image from "next/image"; -import { useTheme } from "next-themes"; -import { Lightbulb } from "lucide-react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // hooks @@ -14,7 +11,6 @@ import { SignInRoot } from "components/account"; import { Loader, Spinner } from "@plane/ui"; // images import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; -import latestFeatures from "public/onboarding/onboarding-pages.svg"; export type AuthType = "sign-in" | "sign-up"; @@ -24,8 +20,6 @@ export const SignInView = observer(() => { user: { currentUser }, appConfig: { envConfig }, } = useMobxStore(); - // next-themes - const { resolvedTheme } = useTheme(); // sign in redirection hook const { isRedirecting, handleRedirection } = useSignInRedirection(); @@ -66,30 +60,7 @@ export const SignInView = observer(() => {
) : ( - <> - - -
- -

- Pages gets a facelift! Write anything and use Galileo to help you start.{" "} - - Learn more - -

-
-
-
- Plane Issues -
-
- + )} diff --git a/web/layouts/instance-layout/index.tsx b/web/layouts/instance-layout/index.tsx index 858f30c8d..46709ac3e 100644 --- a/web/layouts/instance-layout/index.tsx +++ b/web/layouts/instance-layout/index.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode, useEffect } from "react"; +import { FC, ReactNode } from "react"; import useSWR from "swr"; @@ -18,7 +18,7 @@ type Props = { const InstanceLayout: FC = observer(({ children }) => { // store const { - instance: { fetchInstanceInfo, instance, createInstance }, + instance: { fetchInstanceInfo, instance }, } = useMobxStore(); const router = useRouter(); @@ -28,12 +28,6 @@ const InstanceLayout: FC = observer(({ children }) => { revalidateOnFocus: false, }); - useEffect(() => { - if (instance?.is_activated === false) { - createInstance(); - } - }, [instance?.is_activated, createInstance]); - return (
{instance ? ( diff --git a/web/pages/accounts/password.tsx b/web/pages/accounts/password.tsx index db6c28a7e..c3e35bbe4 100644 --- a/web/pages/accounts/password.tsx +++ b/web/pages/accounts/password.tsx @@ -109,7 +109,7 @@ const HomePage: NextPageWithLayout = () => { ref={ref} hasError={Boolean(errors.email)} placeholder="orville.wright@firstflight.com" - className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12" + className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200" disabled /> )} @@ -128,7 +128,7 @@ const HomePage: NextPageWithLayout = () => { onChange={onChange} hasError={Boolean(errors.password)} placeholder="Choose password" - className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" + className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200" minLength={8} /> )} diff --git a/web/public/onboarding/onboarding-pages.svg b/web/public/onboarding/onboarding-pages.svg index 6b0e77a3c..5ed5d44c2 100644 --- a/web/public/onboarding/onboarding-pages.svg +++ b/web/public/onboarding/onboarding-pages.svg @@ -1,62 +1,73 @@ - - - + + + - - + + + + + - + - + - + - - + + - + - + - + - + - - + + - + - - + + + + + + + + + + diff --git a/web/services/auth.service.ts b/web/services/auth.service.ts index 175fe8a76..969085647 100644 --- a/web/services/auth.service.ts +++ b/web/services/auth.service.ts @@ -3,16 +3,20 @@ import { APIService } from "services/api.service"; // helpers import { API_BASE_URL } from "helpers/common.helper"; // types -import { IEmailCheckData, ILoginTokenResponse, IMagicSignInData, IPasswordSignInData } from "types/auth"; +import { + IEmailCheckData, + IEmailCheckResponse, + ILoginTokenResponse, + IMagicSignInData, + IPasswordSignInData, +} from "types/auth"; export class AuthService extends APIService { constructor() { super(API_BASE_URL); } - async emailCheck(data: IEmailCheckData): Promise<{ - is_password_autoset: boolean; - }> { + async emailCheck(data: IEmailCheckData): Promise { return this.post("/api/email-check/", data, { headers: {} }) .then((response) => response?.data) .catch((error) => { @@ -80,18 +84,6 @@ export class AuthService extends APIService { }); } - async setInstanceAdminPassword(data: any): Promise { - return this.post("/api/licenses/instances/admins/set-password/", data) - .then((response) => { - this.setAccessToken(response?.data?.access_token); - this.setRefreshToken(response?.data?.refresh_token); - return response?.data; - }) - .catch((error) => { - throw error?.response?.data; - }); - } - async socialAuth(data: any): Promise { return this.post("/api/social-auth/", data, { headers: {} }) .then((response) => { @@ -104,7 +96,7 @@ export class AuthService extends APIService { }); } - async emailCode(data: any): Promise { + async generateUniqueCode(data: { email: string }): Promise { return this.post("/api/magic-generate/", data, { headers: {} }) .then((response) => response?.data) .catch((error) => { @@ -112,14 +104,6 @@ export class AuthService extends APIService { }); } - async instanceAdminEmailCode(data: any): Promise { - return this.post("/api/licenses/instances/admins/magic-generate/", data, { headers: {} }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - async magicSignIn(data: IMagicSignInData): Promise { return await this.post("/api/magic-sign-in/", data, { headers: {} }) .then((response) => { @@ -134,14 +118,18 @@ export class AuthService extends APIService { }); } - async instanceMagicSignIn(data: any): Promise { - const response = await this.post("/api/licenses/instances/admins/magic-sign-in/", data, { headers: {} }); - if (response?.status === 200) { - this.setAccessToken(response?.data?.access_token); - this.setRefreshToken(response?.data?.refresh_token); - return response?.data; - } - throw response.response.data; + async instanceAdminSignIn(data: IPasswordSignInData): Promise { + return await this.post("/api/licenses/instances/admins/sign-in/", data, { headers: {} }) + .then((response) => { + if (response?.status === 200) { + this.setAccessToken(response?.data?.access_token); + this.setRefreshToken(response?.data?.refresh_token); + return response?.data; + } + }) + .catch((error) => { + throw error?.response?.data; + }); } async signOut(): Promise { diff --git a/web/services/instance.service.ts b/web/services/instance.service.ts index 4be5311bc..3b015893b 100644 --- a/web/services/instance.service.ts +++ b/web/services/instance.service.ts @@ -22,14 +22,6 @@ export class InstanceService extends APIService { }); } - async createInstance(): Promise { - return this.post("/api/licenses/instances/", {}, { headers: {} }) - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } - async getInstanceAdmins(): Promise { return this.get("/api/licenses/instances/admins/") .then((response) => response.data) diff --git a/web/store/instance/instance.store.ts b/web/store/instance/instance.store.ts index a846b8e2e..c94b19c6e 100644 --- a/web/store/instance/instance.store.ts +++ b/web/store/instance/instance.store.ts @@ -17,7 +17,6 @@ export interface IInstanceStore { formattedConfig: IFormattedInstanceConfiguration | null; // action fetchInstanceInfo: () => Promise; - createInstance: () => Promise; fetchInstanceAdmins: () => Promise; updateInstanceInfo: (data: Partial) => Promise; fetchInstanceConfigurations: () => Promise; @@ -46,7 +45,6 @@ export class InstanceStore implements IInstanceStore { formattedConfig: computed, // actions fetchInstanceInfo: action, - createInstance: action, fetchInstanceAdmins: action, updateInstanceInfo: action, fetchInstanceConfigurations: action, @@ -86,22 +84,6 @@ export class InstanceStore implements IInstanceStore { } }; - /** - * Creating new Instance In case of no instance found - */ - createInstance = async () => { - try { - const instance = await this.instanceService.createInstance(); - runInAction(() => { - this.instance = instance; - }); - return instance; - } catch (error) { - console.log("Error while creating the instance"); - throw error; - } - }; - /** * fetch instance admins from API */ diff --git a/web/types/app.d.ts b/web/types/app.d.ts index 9c95538ff..0122cf73a 100644 --- a/web/types/app.d.ts +++ b/web/types/app.d.ts @@ -14,4 +14,5 @@ export interface IAppConfig { posthog_host: string | null; has_openai_configured: boolean; has_unsplash_configured: boolean; + is_self_managed: boolean; } diff --git a/web/types/auth.d.ts b/web/types/auth.d.ts index 15853802c..b20116c90 100644 --- a/web/types/auth.d.ts +++ b/web/types/auth.d.ts @@ -2,12 +2,16 @@ export type TEmailCheckTypes = "magic_code" | "password"; export interface IEmailCheckData { email: string; - type: TEmailCheckTypes; +} + +export interface IEmailCheckResponse { + is_password_autoset: boolean; + is_existing: boolean; } export interface ILoginTokenResponse { access_token: string; - refresh_toke: string; + refresh_token: string; } export interface IMagicSignInData {