From 9f85aeb8e4b46abc9be37cc823cb8a0de4ed3336 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Tue, 24 Sep 2024 14:16:26 +0530 Subject: [PATCH] [MOBIL-183] Web view authentication workflow for mobile (#1134) * chore: mobile auth root * mobile authentication screens * chore: authentication endpoint updates and code clean up * chore: updated auth default state * chore: error handling in mobile auth screens * chore: mobile auth token check * chore: mobile auth token check * chore: mobile auth token check * chore: removed logs * updated unique code validation in mobile auth * passing session key in token check * chore: implemented signout for mobile * chore: updated signout * chore: updated signout permission classes * chore: updated csrf_token validation on token validation * chore: token error handling * chore: build error --- .../plane/authentication/adapter/error.py | 4 +- apiserver/plane/authentication/urls.py | 26 +++ .../authentication/utils/mobile/login.py | 96 ++++++++++ .../plane/authentication/views/__init__.py | 9 + .../plane/authentication/views/app/email.py | 2 - .../authentication/views/app/mobile/email.py | 163 ++++++++++++++++ .../authentication/views/app/mobile/magic.py | 147 ++++++++++++++ .../views/app/mobile/signout.py | 34 ++++ .../authentication/views/app/mobile/token.py | 61 ++++++ .../plane/authentication/views/app/signout.py | 1 - web/app/m/auth/page.tsx | 42 ++++ .../components/account/auth-forms/email.tsx | 14 +- .../forms/email-confirmation.tsx | 136 +++++++++++++ .../mobile/authentication/forms/index.ts | 3 + .../mobile/authentication/forms/password.tsx | 171 +++++++++++++++++ .../authentication/forms/unique-code.tsx | 179 ++++++++++++++++++ .../components/mobile/authentication/index.ts | 5 + .../components/mobile/authentication/root.tsx | 158 ++++++++++++++++ .../authentication/terms-and-conditions.tsx | 21 ++ web/ee/components/mobile/index.ts | 1 + web/helpers/authentication.helper.tsx | 15 ++ 21 files changed, 1279 insertions(+), 9 deletions(-) create mode 100644 apiserver/plane/authentication/utils/mobile/login.py create mode 100644 apiserver/plane/authentication/views/app/mobile/email.py create mode 100644 apiserver/plane/authentication/views/app/mobile/magic.py create mode 100644 apiserver/plane/authentication/views/app/mobile/signout.py create mode 100644 apiserver/plane/authentication/views/app/mobile/token.py create mode 100644 web/app/m/auth/page.tsx create mode 100644 web/ee/components/mobile/authentication/forms/email-confirmation.tsx create mode 100644 web/ee/components/mobile/authentication/forms/index.ts create mode 100644 web/ee/components/mobile/authentication/forms/password.tsx create mode 100644 web/ee/components/mobile/authentication/forms/unique-code.tsx create mode 100644 web/ee/components/mobile/authentication/index.ts create mode 100644 web/ee/components/mobile/authentication/root.tsx create mode 100644 web/ee/components/mobile/authentication/terms-and-conditions.tsx create mode 100644 web/ee/components/mobile/index.ts diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index e349e0a308..04d68792c4 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -69,11 +69,13 @@ AUTHENTICATION_ERROR_CODES = { "RATE_LIMIT_EXCEEDED": 5900, # Unknown "AUTHENTICATION_FAILED": 5999, + # user not onboarded + "USER_NOT_ONBOARDED": 6000, + "TOKEN_NOT_SET": 6005, } class AuthenticationException(Exception): - error_code = None error_message = None payload = {} diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index 26908491f0..c3ed657fa3 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -45,6 +45,11 @@ from .views import ( SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint, SignOutAuthSpaceEndpoint, + # mobile web view authentication + MobileSignInAuthEndpoint, + MobileMagicSignInEndpoint, + MobileSessionTokenCheckEndpoint, + MobileSignOutAuthEndpoint, ) urlpatterns = [ @@ -264,4 +269,25 @@ urlpatterns = [ SAMLLogoutEndpoint.as_view(), name="saml", ), + # mobile web view authentication + path( + "mobile/sign-in/", + MobileSignInAuthEndpoint.as_view(), + name="mobile-sign-in", + ), + path( + "mobile/magic-sign-in/", + MobileMagicSignInEndpoint.as_view(), + name="mobile-magic-sign-in", + ), + path( + "mobile/token-check/", + MobileSessionTokenCheckEndpoint.as_view(), + name="mobile-token-check", + ), + path( + "mobile/sign-out/", + MobileSignOutAuthEndpoint.as_view(), + name="mobile-sign-out", + ), ] diff --git a/apiserver/plane/authentication/utils/mobile/login.py b/apiserver/plane/authentication/utils/mobile/login.py new file mode 100644 index 0000000000..eda125e9a2 --- /dev/null +++ b/apiserver/plane/authentication/utils/mobile/login.py @@ -0,0 +1,96 @@ +import json +import random +import string + +# Django imports +from django.contrib.auth import login +from django.conf import settings + +# Module imports +from plane.db.models import Profile +from plane.settings.redis import redis_instance +from plane.authentication.utils.host import base_host + + +def generate_random_string(length=32): + return "".join( + random.choices( + string.ascii_uppercase + string.ascii_lowercase + string.digits, + k=length, + ) + ) + + +class ValidateAuthToken: + ri = None + expiry = 6000 + token = None + + def __init__(self, token=None): + self.ri = redis_instance() + if token: + self.token = token + else: + self.token = generate_random_string() + + def token_exists(self): + if self.token and self.ri: + token_details = self.ri.get(self.token) + if token_details: + return True + return False + + def set_value(self, session_key): + if self.token and self.ri: + self.ri.set( + self.token, + json.dumps({"session_id": session_key}), + ex=self.expiry, + ) + else: + raise ValueError("Token or Redis instance not set") + + def get_value(self): + if self.token and self.ri: + token_details = self.ri.get(self.token) + if token_details: + return json.loads(token_details) or None + return None + + def remove_token(self): + if self.token and self.ri: + self.ri.delete(self.token) + else: + raise ValueError("Token or Redis instance not set") + + +def mobile_validate_user_onboarding(user): + profile, _ = Profile.objects.get_or_create(user=user) + + if not profile.is_onboarded: + return False + return True + + +def mobile_user_login( + request, user, is_app=False, is_admin=False, is_space=False +): + login(request=request, user=user) + + # If is admin cookie set the custom age + if is_admin: + request.session.set_expiry(settings.ADMIN_SESSION_COOKIE_AGE) + + device_info = { + "user_agent": request.META.get("HTTP_USER_AGENT", ""), + "ip_address": request.META.get("REMOTE_ADDR", ""), + "domain": base_host( + request=request, + is_app=is_app, + is_admin=is_admin, + is_space=is_space, + ), + } + request.session["device_info"] = device_info + request.session.save() + return request.session diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py index 0d4537ae39..7652e8eadf 100644 --- a/apiserver/plane/authentication/views/__init__.py +++ b/apiserver/plane/authentication/views/__init__.py @@ -28,6 +28,7 @@ from .app.magic import ( MagicSignUpEndpoint, ) + from .app.oidc import ( OIDCAuthInitiateEndpoint, OIDCallbackEndpoint, @@ -44,6 +45,7 @@ from .app.saml import ( from .app.signout import SignOutAuthEndpoint + from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint from .space.github import ( @@ -79,3 +81,10 @@ from .app.password_management import ( ForgotPasswordEndpoint, ResetPasswordEndpoint, ) + + +# Mobile web view authentication exports +from .app.mobile.email import MobileSignInAuthEndpoint +from .app.mobile.magic import MobileMagicSignInEndpoint +from .app.mobile.token import MobileSessionTokenCheckEndpoint +from .app.mobile.signout import MobileSignOutAuthEndpoint \ No newline at end of file diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 08a3e8b01e..80abbe0978 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -24,7 +24,6 @@ from plane.authentication.adapter.error import ( class SignInAuthEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") # Check instance configuration @@ -139,7 +138,6 @@ class SignInAuthEndpoint(View): class SignUpAuthEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") # Check instance configuration diff --git a/apiserver/plane/authentication/views/app/mobile/email.py b/apiserver/plane/authentication/views/app/mobile/email.py new file mode 100644 index 0000000000..b4c78a342a --- /dev/null +++ b/apiserver/plane/authentication/views/app/mobile/email.py @@ -0,0 +1,163 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.utils.mobile.login import ( + ValidateAuthToken, + mobile_user_login, + mobile_validate_user_onboarding, +) +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.utils.user_auth_workflow import ( + post_user_auth_workflow, +) +from plane.db.models import User +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.exception_logger import log_exception + + +class MobileSignInAuthEndpoint(View): + def post(self, request): + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + # Base URL join + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # set the referrer as session to redirect after login + email = request.POST.get("email", False) + password = request.POST.get("password", False) + + # Raise exception if any of the above are missing + if not email or not password: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "REQUIRED_EMAIL_PASSWORD_SIGN_IN" + ], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Validate email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"], + error_message="INVALID_EMAIL_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + existing_user = User.objects.filter(email=email).first() + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, + key=email, + code=password, + is_signup=False, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + + # validating the user can be redirected to the referrer path + is_onboarded = mobile_validate_user_onboarding(user=user) + if not is_onboarded: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_NOT_ONBOARDED" + ], + error_message="USER_NOT_ONBOARDED", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Login the user and record his device info + session = mobile_user_login( + request=request, user=user, is_app=True + ) + session_key = session.session_key + + session_token = ValidateAuthToken() + session_token.set_value(session_key) + + # redirect to referrer path + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode({"token": session_token.token}), + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + except ValueError as e: + log_exception(e) + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["TOKEN_NOT_SET"], + error_message="TOKEN_NOT_SET", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/mobile/magic.py b/apiserver/plane/authentication/views/app/mobile/magic.py new file mode 100644 index 0000000000..cbf866cdf5 --- /dev/null +++ b/apiserver/plane/authentication/views/app/mobile/magic.py @@ -0,0 +1,147 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# Django imports +from django.http import HttpResponseRedirect +from django.views import View + + +# Module imports +from plane.authentication.provider.credentials.magic_code import ( + MagicCodeProvider, +) +from plane.authentication.utils.mobile.login import ( + ValidateAuthToken, + mobile_user_login, + mobile_validate_user_onboarding, +) +from plane.authentication.utils.user_auth_workflow import ( + post_user_auth_workflow, +) +from plane.authentication.utils.host import base_host +from plane.db.models import User +from plane.license.models import Instance +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.exception_logger import log_exception + + +class MobileMagicSignInEndpoint(View): + def post(self, request): + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + # Base URL join + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # set the referrer as session to redirect after login + email = request.POST.get("email", False) + code = request.POST.get("code", False) + + # Raise exception if any of the above are missing + if not email or not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" + ], + error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Existing User + email = email.strip().lower() + code = code.strip() + + existing_user = User.objects.filter(email=email).first() + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = MagicCodeProvider( + request=request, + key=f"magic_{email}", + code=code, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + + is_onboarded = mobile_validate_user_onboarding(user=user) + if not is_onboarded: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_NOT_ONBOARDED" + ], + error_message="USER_NOT_ONBOARDED", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Login the user and record his device info + session = mobile_user_login( + request=request, user=user, is_app=True + ) + session_key = session.session_key + + session_token = ValidateAuthToken() + session_token.set_value(session_key) + + # redirect to referrer path + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode({"token": session_token.token}), + ) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + except ValueError as e: + log_exception(e) + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["TOKEN_NOT_SET"], + error_message="TOKEN_NOT_SET", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/mobile/signout.py b/apiserver/plane/authentication/views/app/mobile/signout.py new file mode 100644 index 0000000000..28f0b50305 --- /dev/null +++ b/apiserver/plane/authentication/views/app/mobile/signout.py @@ -0,0 +1,34 @@ +# Django imports +from django.contrib.auth import logout +from django.utils import timezone + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.views import APIView + +# Module imports +from plane.authentication.utils.host import user_ip +from plane.db.models import User + + +class MobileSignOutAuthEndpoint(APIView): + def post(self, request): + # Get user + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + + # Logout user + logout(request) + return Response( + {"message": "User sign out successfully"}, + status=status.HTTP_200_OK, + ) + except Exception: + return Response( + {"message": "Something went wrong"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/authentication/views/app/mobile/token.py b/apiserver/plane/authentication/views/app/mobile/token.py new file mode 100644 index 0000000000..c59d0b8c08 --- /dev/null +++ b/apiserver/plane/authentication/views/app/mobile/token.py @@ -0,0 +1,61 @@ +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +# Module imports +from plane.authentication.utils.mobile.login import ( + ValidateAuthToken, +) + + +class MobileSessionTokenCheckEndpoint(APIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + try: + token = request.data.get("token", False) + + # Check if token is empty + if not token or token == "": + return Response( + {"error": "Token is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate the token + session_token = ValidateAuthToken(token) + token_exists = session_token.token_exists() + if not token_exists: + return Response( + {"error": "Invalid token"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Get the session id + session_details = session_token.get_value() + if not session_details: + return Response( + {"error": "Invalid token"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Remove the token + session_token.remove_token() + response_session = { + "session_name": settings.SESSION_COOKIE_NAME, + "session_id": session_details.get("session_id"), + } + + return Response(response_session, status=status.HTTP_200_OK) + except Exception: + return Response( + {"error": "Something went wrong"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/authentication/views/app/signout.py b/apiserver/plane/authentication/views/app/signout.py index fccf557697..075a28a7df 100644 --- a/apiserver/plane/authentication/views/app/signout.py +++ b/apiserver/plane/authentication/views/app/signout.py @@ -12,7 +12,6 @@ from plane.authentication.adapter.saml import SAMLAdapter class SignOutAuthEndpoint(View): - def post(self, request): # Get user try: diff --git a/web/app/m/auth/page.tsx b/web/app/m/auth/page.tsx new file mode 100644 index 0000000000..0d0e01c511 --- /dev/null +++ b/web/app/m/auth/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// components +import { AuthRoot } from "@/plane-web/components/mobile"; +// assets +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +const MobileAuth = observer(() => { + const { resolvedTheme } = useTheme(); + + const pageBackgroundPattern = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; + const brandLogo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( +
+
+ Plane background pattern +
+
+
+
+
+ Plane logo +
+
+
+
+ +
+
+
+ ); +}); + +export default MobileAuth; diff --git a/web/core/components/account/auth-forms/email.tsx b/web/core/components/account/auth-forms/email.tsx index 1e96ed3f2f..f313fb426a 100644 --- a/web/core/components/account/auth-forms/email.tsx +++ b/web/core/components/account/auth-forms/email.tsx @@ -40,7 +40,7 @@ export const AuthEmailForm: FC = observer((props) => { const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; - const [isFocused, setIsFocused] = useState(true) + const [isFocused, setIsFocused] = useState(true); const inputRef = useRef(null); return ( @@ -55,8 +55,12 @@ export const AuthEmailForm: FC = observer((props) => { !isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100` )} tabIndex={-1} - onFocus={() => {setIsFocused(true)}} - onBlur={() => {setIsFocused(false)}} + onFocus={() => { + setIsFocused(true); + }} + onBlur={() => { + setIsFocused(false); + }} > = observer((props) => { autoFocus ref={inputRef} /> - {email.length > 0 && ( + {email.length > 0 && ( { @@ -92,4 +96,4 @@ export const AuthEmailForm: FC = observer((props) => { ); -}); \ No newline at end of file +}); diff --git a/web/ee/components/mobile/authentication/forms/email-confirmation.tsx b/web/ee/components/mobile/authentication/forms/email-confirmation.tsx new file mode 100644 index 0000000000..5877d7e23e --- /dev/null +++ b/web/ee/components/mobile/authentication/forms/email-confirmation.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { FC, FormEvent, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { CircleAlert, XCircle } from "lucide-react"; +import { IEmailCheckData } from "@plane/types"; +import { Button, Input, Spinner } from "@plane/ui"; +// helpers +import { + authErrorHandler, + EAuthenticationErrorCodes, + EAuthSteps, + TAuthErrorInfo, +} from "@/helpers/authentication.helper"; +import { cn } from "@/helpers/common.helper"; +import { checkEmailValidity } from "@/helpers/string.helper"; +// services +import { AuthService } from "@/services/auth.service"; + +const authService = new AuthService(); + +type TMobileAuthEmailValidationForm = { + email: string; + handleEmail: (value: string) => void; + handleAuthStep: (value: EAuthSteps) => void; + handleErrorInfo: (value: TAuthErrorInfo | undefined) => void; + generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>; +}; + +export const MobileAuthEmailValidationForm: FC = observer((props) => { + const { email: defaultEmail, handleEmail, handleAuthStep, handleErrorInfo, generateEmailUniqueCode } = props; + // ref + const inputRef = useRef(null); + + // state + const [isFocused, setIsFocused] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [email, setEmail] = useState(defaultEmail); + + // derived values + const emailError = useMemo( + () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), + [email] + ); + const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + + handleEmail(email); + setIsSubmitting(true); + + const payload: IEmailCheckData = { + email: email, + }; + + await authService + .emailCheck(payload) + .then(async (response) => { + if (response.existing) { + if (response.status === "MAGIC_CODE") { + handleAuthStep(EAuthSteps.UNIQUE_CODE); + // generating unique code + generateEmailUniqueCode(email); + } else if (response.status === "CREDENTIAL") { + handleAuthStep(EAuthSteps.PASSWORD); + } + } else { + handleEmail(""); + setEmail(""); + handleAuthStep(EAuthSteps.EMAIL); + const errorhandler = authErrorHandler(EAuthenticationErrorCodes.USER_DOES_NOT_EXIST, undefined); + if (errorhandler?.type) handleErrorInfo(errorhandler); + } + }) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code?.toString(), email || undefined); + if (errorhandler?.type) handleErrorInfo(errorhandler); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + return ( +
+
+
+ +
setIsFocused(true)} + onBlur={() => setIsFocused(false)} + > + setEmail(e.target.value)} + placeholder="name@example.com" + className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} + autoComplete="on" + autoFocus + ref={inputRef} + /> + {email.length > 0 && ( + { + setEmail(""); + inputRef.current?.focus(); + }} + /> + )} +
+ {emailError?.email && !isFocused && ( +

+ + {emailError.email} +

+ )} +
+ +
+
+ ); +}); diff --git a/web/ee/components/mobile/authentication/forms/index.ts b/web/ee/components/mobile/authentication/forms/index.ts new file mode 100644 index 0000000000..5c13e3cc0c --- /dev/null +++ b/web/ee/components/mobile/authentication/forms/index.ts @@ -0,0 +1,3 @@ +export * from "./email-confirmation"; +export * from "./unique-code"; +export * from "./password"; diff --git a/web/ee/components/mobile/authentication/forms/password.tsx b/web/ee/components/mobile/authentication/forms/password.tsx new file mode 100644 index 0000000000..39879e6eff --- /dev/null +++ b/web/ee/components/mobile/authentication/forms/password.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { FC, useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Eye, EyeOff, XCircle } from "lucide-react"; +import { Button, Input, Spinner } from "@plane/ui"; +// hooks +import { useInstance } from "@/hooks/store"; +// helpers +import { EAuthSteps } from "@/helpers/authentication.helper"; +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { AuthService } from "@/services/auth.service"; + +const authService = new AuthService(); + +type TMobileAuthPasswordForm = { + email: string; + handleEmail: (value: string) => void; + handleAuthStep: (value: EAuthSteps) => void; + generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>; +}; + +type TFormValues = { + email: string; + password: string; +}; + +const defaultFormValues: TFormValues = { + email: "", + password: "", +}; + +export const MobileAuthPasswordForm: FC = observer((props) => { + const { email, handleEmail, handleAuthStep, generateEmailUniqueCode } = props; + // ref + const authFormRef = useRef(null); + // hooks + const { config } = useInstance(); + // states + const [csrfPromise, setCsrfPromise] = useState | undefined>(undefined); + const [formData, setFormData] = useState({ ...defaultFormValues, email }); + const [showPassword, setShowPassword] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + // derived values + const isSMTPConfigured = config?.is_smtp_configured || false; + const isButtonDisabled = formData.password.length === 0 || isSubmitting; + + useEffect(() => { + if (csrfPromise === undefined) { + const promise = authService.requestCSRFToken(); + setCsrfPromise(promise); + } + }, [csrfPromise]); + + const handleFormChange = (key: keyof TFormValues, value: string) => + setFormData((prev) => ({ ...prev, [key]: value })); + + const handleShowPassword = () => setShowPassword((prev) => !prev); + + const handleCSRFToken = async () => { + if (!authFormRef || !authFormRef.current) return; + const token = await csrfPromise; + if (!token?.csrf_token) return; + const csrfElement = authFormRef.current.querySelector("input[name=csrfmiddlewaretoken]"); + csrfElement?.setAttribute("value", token?.csrf_token); + }; + + const handleEmailClear = () => { + handleEmail(""); + handleAuthStep(EAuthSteps.EMAIL); + }; + + const redirectToUniqueCodeSignIn = () => { + handleAuthStep(EAuthSteps.UNIQUE_CODE); + // generate unique code + generateEmailUniqueCode(email); + }; + + return ( +
{ + event.preventDefault(); // Prevent form from submitting by default + setIsSubmitting(true); + await handleCSRFToken(); + authFormRef.current && authFormRef.current.submit(); + }} + onError={() => setIsSubmitting(false)} + > + + + +
+ +
+ handleFormChange("email", e.target.value)} + placeholder="name@company.com" + className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`} + disabled + /> + {formData.email.length > 0 && ( + + )} +
+
+ +
+ +
+ handleFormChange("password", e.target.value)} + placeholder="Enter password" + className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoComplete="on" + autoFocus + /> + {showPassword ? ( + handleShowPassword()} + /> + ) : ( + handleShowPassword()} + /> + )} +
+
+ +
+ + + {isSMTPConfigured && ( + + )} +
+
+ ); +}); diff --git a/web/ee/components/mobile/authentication/forms/unique-code.tsx b/web/ee/components/mobile/authentication/forms/unique-code.tsx new file mode 100644 index 0000000000..fae5b9502e --- /dev/null +++ b/web/ee/components/mobile/authentication/forms/unique-code.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { FC, useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { CircleCheck, Eye, EyeOff, XCircle } from "lucide-react"; +import { Button, Input, Spinner } from "@plane/ui"; +// hooks +import { useInstance } from "@/hooks/store"; +// helpers +import { EAuthSteps } from "@/helpers/authentication.helper"; +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { AuthService } from "@/services/auth.service"; +import useTimer from "@/hooks/use-timer"; + +const authService = new AuthService(); + +type TMobileAuthUniqueCodeForm = { + email: string; + handleEmail: (value: string) => void; + handleAuthStep: (value: EAuthSteps) => void; + generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>; +}; + +type TFormValues = { + email: string; + code: string; +}; + +const defaultFormValues: TFormValues = { + email: "", + code: "", +}; + +const defaultResetTimerValue = 5; + +export const MobileAuthUniqueCodeForm: FC = observer((props) => { + const { email, handleEmail, handleAuthStep, generateEmailUniqueCode } = props; + // ref + const authFormRef = useRef(null); + // hooks + const { config } = useInstance(); + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + // states + const [csrfPromise, setCsrfPromise] = useState | undefined>(undefined); + const [formData, setFormData] = useState({ ...defaultFormValues, email }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + // derived values + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const isButtonDisabled = isRequestingNewCode || !formData.code || isSubmitting; + + useEffect(() => { + if (csrfPromise === undefined) { + const promise = authService.requestCSRFToken(); + setCsrfPromise(promise); + } + }, [csrfPromise]); + + const handleFormChange = (key: keyof TFormValues, value: string) => + setFormData((prev) => ({ ...prev, [key]: value })); + + const handleCSRFToken = async () => { + if (!authFormRef || !authFormRef.current) return; + const token = await csrfPromise; + if (!token?.csrf_token) return; + const csrfElement = authFormRef.current.querySelector("input[name=csrfmiddlewaretoken]"); + csrfElement?.setAttribute("value", token?.csrf_token); + }; + + const generateNewEmailUniqueCode = async (email: string) => { + try { + setIsRequestingNewCode(true); + const uniqueCode = await generateEmailUniqueCode(email); + setResendCodeTimer(defaultResetTimerValue); + handleFormChange("code", uniqueCode?.code || ""); + setIsRequestingNewCode(false); + } catch { + setResendCodeTimer(0); + console.error("Error while requesting new code"); + setIsRequestingNewCode(false); + } + }; + + const handleEmailClear = () => { + handleEmail(""); + handleAuthStep(EAuthSteps.EMAIL); + }; + + return ( +
{ + event.preventDefault(); // Prevent form from submitting by default + setIsSubmitting(true); + await handleCSRFToken(); + authFormRef.current && authFormRef.current.submit(); + }} + > + + + +
+ +
+ handleFormChange("email", e.target.value)} + placeholder="name@company.com" + className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`} + disabled + /> + {formData.email.length > 0 && ( + + )} +
+
+ +
+ +
+ handleFormChange("code", e.target.value)} + placeholder="gets-sets-flys" + className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoComplete="on" + autoFocus + /> +
+
+

+ + Paste the code sent to your email +

+ +
+
+ +
+ +
+
+ ); +}); diff --git a/web/ee/components/mobile/authentication/index.ts b/web/ee/components/mobile/authentication/index.ts new file mode 100644 index 0000000000..813e035b2e --- /dev/null +++ b/web/ee/components/mobile/authentication/index.ts @@ -0,0 +1,5 @@ +export * from "./root"; + +export * from "./forms"; + +export * from "./terms-and-conditions"; diff --git a/web/ee/components/mobile/authentication/root.tsx b/web/ee/components/mobile/authentication/root.tsx new file mode 100644 index 0000000000..32c6c49bff --- /dev/null +++ b/web/ee/components/mobile/authentication/root.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// helpers +import { + authErrorHandler, + EAuthenticationErrorCodes, + EAuthSteps, + EErrorAlertType, + TAuthErrorInfo, +} from "@/helpers/authentication.helper"; +// components +import { AuthBanner } from "@/components/account"; +// hooks +import { useInstance } from "@/hooks/store"; +// services +import { AuthService } from "@/services/auth.service"; +// plane web components +import { + MobileTermsAndConditions, + MobileAuthEmailValidationForm, + MobileAuthUniqueCodeForm, + MobileAuthPasswordForm, +} from "@/plane-web/components/mobile"; + +// service initialization +const authService = new AuthService(); + +// constants +const AUTH_HEADER_CONTENT_OPTIONS = { + [EAuthSteps.EMAIL]: { + title: "Log in", + description: undefined, + }, + [EAuthSteps.UNIQUE_CODE]: { + title: "Log in", + description: "Log in using your unique code.", + }, + [EAuthSteps.PASSWORD]: { + title: "Log in", + description: "Log in using your password.", + }, +}; + +const UNIQUE_CODE_ERROR_CODES = [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, +]; + +const PASSWORD_ERROR_CODES = [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]; + +export const AuthRoot: FC = observer((props) => { + // router + const searchParams = useSearchParams(); + // query params + const emailParam = searchParams.get("email"); + const errorCodeParam = searchParams.get("error_code"); + const errorMessageParam = searchParams.get("error_message"); + const sessionToken = searchParams.get("token"); + // hooks + const { config } = useInstance(); + // states + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [errorInfo, setErrorInfo] = useState(undefined); + + const handleErrorInfo = (value: TAuthErrorInfo | undefined) => { + setErrorInfo(value); + }; + + // derived values + const isSMTPConfigured = config?.is_smtp_configured || false; + + // generating unique email code + const generateEmailUniqueCode = async (email: string) => { + if (!isSMTPConfigured || !email || email === "") return; + const payload = { email: email }; + return await authService + .generateUniqueCode(payload) + .then(() => ({ code: "" })) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code.toString()); + if (errorhandler?.type) setErrorInfo(errorhandler); + throw error; + }); + }; + + // validating and defining the errors + useEffect(() => { + if (!errorCodeParam) return; + const errorhandler = authErrorHandler(errorCodeParam?.toString() as EAuthenticationErrorCodes); + if (!errorhandler) return; + // password handler + if (PASSWORD_ERROR_CODES.includes(errorhandler.code)) setAuthStep(EAuthSteps.PASSWORD); + // unique code handler + if (UNIQUE_CODE_ERROR_CODES.includes(errorhandler.code)) setAuthStep(EAuthSteps.UNIQUE_CODE); + setErrorInfo(errorhandler); + }, [errorCodeParam, errorMessageParam]); + + useEffect(() => { + if (sessionToken) { + window.location.replace(`app.plane.so://?token=${sessionToken}`); + } + }, [sessionToken]); + + return ( +
+ {/* heading */} +
+

{AUTH_HEADER_CONTENT_OPTIONS[authStep]?.title}

+ {AUTH_HEADER_CONTENT_OPTIONS[authStep]?.description && ( +

{AUTH_HEADER_CONTENT_OPTIONS[authStep]?.description}

+ )} +
+ + {/* alert banner */} + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + + )} + + {/* auth content */} +
+ {authStep === EAuthSteps.EMAIL && ( + setEmail(value)} + handleAuthStep={(value) => setAuthStep(value)} + handleErrorInfo={handleErrorInfo} + generateEmailUniqueCode={generateEmailUniqueCode} + /> + )} + {authStep === EAuthSteps.UNIQUE_CODE && ( + setEmail(value)} + handleAuthStep={(value) => setAuthStep(value)} + generateEmailUniqueCode={generateEmailUniqueCode} + /> + )} + {authStep === EAuthSteps.PASSWORD && ( + setEmail(value)} + handleAuthStep={(value) => setAuthStep(value)} + generateEmailUniqueCode={generateEmailUniqueCode} + /> + )} +
+ + {/* terms and conditions */} + +
+ ); +}); diff --git a/web/ee/components/mobile/authentication/terms-and-conditions.tsx b/web/ee/components/mobile/authentication/terms-and-conditions.tsx new file mode 100644 index 0000000000..66f31ed9b4 --- /dev/null +++ b/web/ee/components/mobile/authentication/terms-and-conditions.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { FC } from "react"; +import Link from "next/link"; + +export const MobileTermsAndConditions: FC = () => ( +
+
+ By signing in, you agree to our +
+ + Terms of Service + +  and  + + Privacy Policy + + . +
+
+); diff --git a/web/ee/components/mobile/index.ts b/web/ee/components/mobile/index.ts new file mode 100644 index 0000000000..947dc27329 --- /dev/null +++ b/web/ee/components/mobile/index.ts @@ -0,0 +1 @@ +export * from "./authentication"; diff --git a/web/helpers/authentication.helper.tsx b/web/helpers/authentication.helper.tsx index 0cb2c80a76..dbbf9bd93b 100644 --- a/web/helpers/authentication.helper.tsx +++ b/web/helpers/authentication.helper.tsx @@ -92,6 +92,9 @@ export enum EAuthenticationErrorCodes { ADMIN_USER_DEACTIVATED = "5190", // Rate limit RATE_LIMIT_EXCEEDED = "5900", + // mobile specific codes + USER_NOT_ONBOARDED = "6000", + TOKEN_NOT_SET = "6005", } export type TAuthErrorInfo = { @@ -358,6 +361,16 @@ const errorCodeMessages: { title: "", message: () => `Rate limit exceeded. Please try again later.`, }, + + // mobile specific codes + [EAuthenticationErrorCodes.USER_NOT_ONBOARDED]: { + title: `User not onboarded`, + message: () => `User not onboarded. Please try again.`, + }, + [EAuthenticationErrorCodes.TOKEN_NOT_SET]: { + title: `Token not set`, + message: () => `Token not set. Please try again.`, + }, }; export const authErrorHandler = ( @@ -415,6 +428,8 @@ export const authErrorHandler = ( EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED, EAuthenticationErrorCodes.RATE_LIMIT_EXCEEDED, + EAuthenticationErrorCodes.USER_NOT_ONBOARDED, + EAuthenticationErrorCodes.TOKEN_NOT_SET, ]; if (bannerAlertErrorCodes.includes(errorCode))