[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:
guru_sainath
2024-09-24 14:16:26 +05:30
committed by GitHub
parent 57aff09fbb
commit 9f85aeb8e4
21 changed files with 1279 additions and 9 deletions

View File

@@ -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 = {}

View File

@@ -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",
),
]

View 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

View File

@@ -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

View File

@@ -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

View 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)

View 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)

View 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,
)

View 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,
)

View File

@@ -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
View 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;

View File

@@ -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>
);
});
});

View File

@@ -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>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./email-confirmation";
export * from "./unique-code";
export * from "./password";

View 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>
);
});

View 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>
);
});

View File

@@ -0,0 +1,5 @@
export * from "./root";
export * from "./forms";
export * from "./terms-and-conditions";

View 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>
);
});

View File

@@ -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>
&nbsp;and&nbsp;
<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>
);

View File

@@ -0,0 +1 @@
export * from "./authentication";

View File

@@ -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))