mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 21:40:18 +02:00
[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
This commit is contained in:
@@ -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 = {}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
96
apiserver/plane/authentication/utils/mobile/login.py
Normal file
96
apiserver/plane/authentication/utils/mobile/login.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
163
apiserver/plane/authentication/views/app/mobile/email.py
Normal file
163
apiserver/plane/authentication/views/app/mobile/email.py
Normal file
@@ -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)
|
||||
147
apiserver/plane/authentication/views/app/mobile/magic.py
Normal file
147
apiserver/plane/authentication/views/app/mobile/magic.py
Normal file
@@ -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)
|
||||
34
apiserver/plane/authentication/views/app/mobile/signout.py
Normal file
34
apiserver/plane/authentication/views/app/mobile/signout.py
Normal file
@@ -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,
|
||||
)
|
||||
61
apiserver/plane/authentication/views/app/mobile/token.py
Normal file
61
apiserver/plane/authentication/views/app/mobile/token.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -12,7 +12,6 @@ from plane.authentication.adapter.saml import SAMLAdapter
|
||||
|
||||
|
||||
class SignOutAuthEndpoint(View):
|
||||
|
||||
def post(self, request):
|
||||
# Get user
|
||||
try:
|
||||
|
||||
42
web/app/m/auth/page.tsx
Normal file
42
web/app/m/auth/page.tsx
Normal file
@@ -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 (
|
||||
<div className="relative w-screen h-screen overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image src={pageBackgroundPattern} className="w-full h-full object-cover" alt="Plane background pattern" />
|
||||
</div>
|
||||
<div className="relative z-10 w-screen h-screen overflow-hidden overflow-y-auto flex flex-col">
|
||||
<div className="container min-w-full px-10 lg:px-20 xl:px-36 flex-shrink-0 relative flex items-center justify-between pb-4 transition-all">
|
||||
<div className="flex items-center gap-x-2 py-10">
|
||||
<div className="h-[30px] w-[133px]">
|
||||
<Image src={brandLogo} alt="Plane logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center flex-grow container h-[100vh-60px] mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 transition-all">
|
||||
<AuthRoot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MobileAuth;
|
||||
@@ -40,7 +40,7 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
|
||||
const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(true)
|
||||
const [isFocused, setIsFocused] = useState(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
@@ -55,8 +55,12 @@ export const AuthEmailForm: FC<TAuthEmailForm> = 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);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -70,7 +74,7 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
{email.length > 0 && (
|
||||
<XCircle
|
||||
className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs"
|
||||
onClick={() => {
|
||||
@@ -92,4 +96,4 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TMobileAuthEmailValidationForm> = observer((props) => {
|
||||
const { email: defaultEmail, handleEmail, handleAuthStep, handleErrorInfo, generateEmailUniqueCode } = props;
|
||||
// ref
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div>
|
||||
<form onSubmit={handleFormSubmit} className="mt-5 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
`relative flex items-center rounded-md bg-onboarding-background-200 border`,
|
||||
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100`
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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 && (
|
||||
<XCircle
|
||||
className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs"
|
||||
onClick={() => {
|
||||
setEmail("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{emailError?.email && !isFocused && (
|
||||
<p className="flex items-center gap-1 text-xs text-red-600 px-0.5">
|
||||
<CircleAlert height={12} width={12} />
|
||||
{emailError.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
3
web/ee/components/mobile/authentication/forms/index.ts
Normal file
3
web/ee/components/mobile/authentication/forms/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./email-confirmation";
|
||||
export * from "./unique-code";
|
||||
export * from "./password";
|
||||
171
web/ee/components/mobile/authentication/forms/password.tsx
Normal file
171
web/ee/components/mobile/authentication/forms/password.tsx
Normal file
@@ -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<TMobileAuthPasswordForm> = observer((props) => {
|
||||
const { email, handleEmail, handleAuthStep, generateEmailUniqueCode } = props;
|
||||
// ref
|
||||
const authFormRef = useRef<HTMLFormElement>(null);
|
||||
// hooks
|
||||
const { config } = useInstance();
|
||||
// states
|
||||
const [csrfPromise, setCsrfPromise] = useState<Promise<{ csrf_token: string }> | undefined>(undefined);
|
||||
const [formData, setFormData] = useState<TFormValues>({ ...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 (
|
||||
<form
|
||||
ref={authFormRef}
|
||||
className="mt-5 space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/mobile/sign-in/`}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault(); // Prevent form from submitting by default
|
||||
setIsSubmitting(true);
|
||||
await handleCSRFToken();
|
||||
authFormRef.current && authFormRef.current.submit();
|
||||
}}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" />
|
||||
<input type="hidden" value={formData.email} name="email" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<div
|
||||
className={`relative flex items-center rounded-md bg-onboarding-background-200 border border-onboarding-border-100`}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => 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 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => 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 ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword()}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : isSMTPConfigured ? "Continue" : "Go to workspace"}
|
||||
</Button>
|
||||
|
||||
{isSMTPConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={redirectToUniqueCodeSignIn}
|
||||
variant="outline-primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Sign in with unique code
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
179
web/ee/components/mobile/authentication/forms/unique-code.tsx
Normal file
179
web/ee/components/mobile/authentication/forms/unique-code.tsx
Normal file
@@ -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<TMobileAuthUniqueCodeForm> = observer((props) => {
|
||||
const { email, handleEmail, handleAuthStep, generateEmailUniqueCode } = props;
|
||||
// ref
|
||||
const authFormRef = useRef<HTMLFormElement>(null);
|
||||
// hooks
|
||||
const { config } = useInstance();
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
|
||||
// states
|
||||
const [csrfPromise, setCsrfPromise] = useState<Promise<{ csrf_token: string }> | undefined>(undefined);
|
||||
const [formData, setFormData] = useState<TFormValues>({ ...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 (
|
||||
<form
|
||||
ref={authFormRef}
|
||||
className="mt-5 space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/mobile/magic-sign-in/`}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault(); // Prevent form from submitting by default
|
||||
setIsSubmitting(true);
|
||||
await handleCSRFToken();
|
||||
authFormRef.current && authFormRef.current.submit();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" />
|
||||
<input type="hidden" value={formData.email} name="email" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<div
|
||||
className={`relative flex items-center rounded-md bg-onboarding-background-200 border border-onboarding-border-100`}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => 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 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="code">
|
||||
Unique code
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={"text"}
|
||||
name="code"
|
||||
value={formData.code}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between px-1 text-xs pt-1">
|
||||
<p className="flex items-center gap-1 font-medium text-green-700">
|
||||
<CircleCheck height={12} width={12} />
|
||||
Paste the code sent to your email
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generateNewEmailUniqueCode(email)}
|
||||
className={`${
|
||||
isRequestNewCodeDisabled
|
||||
? "text-onboarding-text-400"
|
||||
: "font-medium text-custom-primary-300 hover:text-custom-primary-200"
|
||||
}`}
|
||||
disabled={isRequestNewCodeDisabled}
|
||||
>
|
||||
{resendTimerCode > 0
|
||||
? `Resend in ${resendTimerCode}s`
|
||||
: isRequestingNewCode
|
||||
? "Requesting new code"
|
||||
: "Resend"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isRequestingNewCode ? "Sending code" : isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
5
web/ee/components/mobile/authentication/index.ts
Normal file
5
web/ee/components/mobile/authentication/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./forms";
|
||||
|
||||
export * from "./terms-and-conditions";
|
||||
158
web/ee/components/mobile/authentication/root.tsx
Normal file
158
web/ee/components/mobile/authentication/root.tsx
Normal file
@@ -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>(EAuthSteps.EMAIL);
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(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 (
|
||||
<div className="relative flex flex-col space-y-6">
|
||||
{/* heading */}
|
||||
<div className="space-y-1 text-center">
|
||||
<h3 className="text-3xl font-bold text-onboarding-text-100">{AUTH_HEADER_CONTENT_OPTIONS[authStep]?.title}</h3>
|
||||
{AUTH_HEADER_CONTENT_OPTIONS[authStep]?.description && (
|
||||
<p className="font-medium text-onboarding-text-400">{AUTH_HEADER_CONTENT_OPTIONS[authStep]?.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* alert banner */}
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner bannerData={errorInfo} handleBannerData={handleErrorInfo} />
|
||||
)}
|
||||
|
||||
{/* auth content */}
|
||||
<div>
|
||||
{authStep === EAuthSteps.EMAIL && (
|
||||
<MobileAuthEmailValidationForm
|
||||
email={email}
|
||||
handleEmail={(value) => setEmail(value)}
|
||||
handleAuthStep={(value) => setAuthStep(value)}
|
||||
handleErrorInfo={handleErrorInfo}
|
||||
generateEmailUniqueCode={generateEmailUniqueCode}
|
||||
/>
|
||||
)}
|
||||
{authStep === EAuthSteps.UNIQUE_CODE && (
|
||||
<MobileAuthUniqueCodeForm
|
||||
email={email}
|
||||
handleEmail={(value) => setEmail(value)}
|
||||
handleAuthStep={(value) => setAuthStep(value)}
|
||||
generateEmailUniqueCode={generateEmailUniqueCode}
|
||||
/>
|
||||
)}
|
||||
{authStep === EAuthSteps.PASSWORD && (
|
||||
<MobileAuthPasswordForm
|
||||
email={email}
|
||||
handleEmail={(value) => setEmail(value)}
|
||||
handleAuthStep={(value) => setAuthStep(value)}
|
||||
generateEmailUniqueCode={generateEmailUniqueCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* terms and conditions */}
|
||||
<MobileTermsAndConditions />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const MobileTermsAndConditions: FC = () => (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="text-center text-sm text-onboarding-text-200 whitespace-pre-line">
|
||||
By signing in, you agree to our
|
||||
<br />
|
||||
<Link href="https://plane.so/legals/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
||||
<span className="text-sm font-medium underline hover:cursor-pointer">Terms of Service</span>
|
||||
</Link>
|
||||
and
|
||||
<Link href="https://plane.so/legals/privacy-policy" target="_blank" rel="noopener noreferrer">
|
||||
<span className="text-sm font-medium underline hover:cursor-pointer">Privacy Policy</span>
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
1
web/ee/components/mobile/index.ts
Normal file
1
web/ee/components/mobile/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./authentication";
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user