From bab52a26727ee924aed0f30302675e890275d4d3 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Thu, 16 May 2024 17:17:04 +0530 Subject: [PATCH] [WEB-1319] chore: New authentication workflow (#4481) * chore: New authentication workflow * chore: resolved build erros and updated imports in auth * chore: code optimisation for query param util * chore: added client for auth forms --- .../[workspace_slug]/[project_id]/layout.tsx | 2 +- space/app/layout.tsx | 2 +- space/app/page.tsx | 2 +- .../account/auth-forms/auth-banner.tsx | 31 +++ .../account/auth-forms/auth-header.tsx | 57 +++++ .../account/auth-forms/auth-root.tsx | 172 ++++++++++++++ space/components/account/auth-forms/email.tsx | 86 +++++++ space/components/account/auth-forms/index.ts | 8 + .../auth-forms/password.tsx | 119 +++++----- .../auth-forms/unique-code.tsx | 126 ++++------ space/components/account/helpers/index.ts | 1 + .../helpers}/password-strength-meter.tsx | 0 .../components/{accounts => account}/index.ts | 8 +- .../oauth/github-button.tsx | 2 - .../oauth/google-button.tsx | 2 - .../{accounts => account}/oauth/index.ts | 0 .../oauth/oauth-options.tsx | 16 +- .../terms-and-conditions.tsx | 12 +- .../{accounts => account}/user-logged-in.tsx | 4 +- .../components/accounts/auth-forms/email.tsx | 94 -------- .../auth-forms/forgot-password-popover.tsx | 55 ----- space/components/accounts/auth-forms/index.ts | 5 - space/components/accounts/auth-forms/root.tsx | 160 ------------- space/components/accounts/onboarding-form.tsx | 216 ------------------ .../accounts/user-image-upload-modal.tsx | 186 --------------- .../issues/board-views/kanban/block.tsx | 25 +- .../issues/board-views/list/block.tsx | 13 +- space/components/issues/filters/root.tsx | 17 +- space/components/issues/navbar/controls.tsx | 33 +-- .../issues/navbar/issue-board-view.tsx | 12 +- space/components/views/auth.tsx | 11 +- space/helpers/authentication.helper.tsx | 11 - space/helpers/query-param-generator.ts | 24 ++ space/services/auth.service.ts | 14 +- space/store/instance.store.ts | 24 +- space/store/root.store.ts | 2 +- space/types/auth.d.ts | 26 --- space/types/auth.ts | 25 ++ 38 files changed, 584 insertions(+), 1019 deletions(-) create mode 100644 space/components/account/auth-forms/auth-banner.tsx create mode 100644 space/components/account/auth-forms/auth-header.tsx create mode 100644 space/components/account/auth-forms/auth-root.tsx create mode 100644 space/components/account/auth-forms/email.tsx create mode 100644 space/components/account/auth-forms/index.ts rename space/components/{accounts => account}/auth-forms/password.tsx (66%) rename space/components/{accounts => account}/auth-forms/unique-code.tsx (57%) create mode 100644 space/components/account/helpers/index.ts rename space/components/{accounts => account/helpers}/password-strength-meter.tsx (100%) rename space/components/{accounts => account}/index.ts (51%) rename space/components/{accounts => account}/oauth/github-button.tsx (98%) rename space/components/{accounts => account}/oauth/google-button.tsx (98%) rename space/components/{accounts => account}/oauth/index.ts (100%) rename space/components/{accounts => account}/oauth/oauth-options.tsx (61%) rename space/components/{accounts => account}/terms-and-conditions.tsx (73%) rename space/components/{accounts => account}/user-logged-in.tsx (92%) delete mode 100644 space/components/accounts/auth-forms/email.tsx delete mode 100644 space/components/accounts/auth-forms/forgot-password-popover.tsx delete mode 100644 space/components/accounts/auth-forms/index.ts delete mode 100644 space/components/accounts/auth-forms/root.tsx delete mode 100644 space/components/accounts/onboarding-form.tsx delete mode 100644 space/components/accounts/user-image-upload-modal.tsx create mode 100644 space/helpers/query-param-generator.ts delete mode 100644 space/types/auth.d.ts create mode 100644 space/types/auth.ts diff --git a/space/app/[workspace_slug]/[project_id]/layout.tsx b/space/app/[workspace_slug]/[project_id]/layout.tsx index aabff72ba..b1e134ea6 100644 --- a/space/app/[workspace_slug]/[project_id]/layout.tsx +++ b/space/app/[workspace_slug]/[project_id]/layout.tsx @@ -19,7 +19,7 @@ export default async function ProjectLayout({ return (
- +
{children}
- + {!instanceDetails ? ( ) : ( diff --git a/space/app/page.tsx b/space/app/page.tsx index c8a0b7920..1dbadcf60 100644 --- a/space/app/page.tsx +++ b/space/app/page.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite"; import useSWR from "swr"; // components -import { UserLoggedIn } from "@/components/accounts"; +import { UserLoggedIn } from "@/components/account"; import { LogoSpinner } from "@/components/common"; import { AuthView } from "@/components/views"; // hooks diff --git a/space/components/account/auth-forms/auth-banner.tsx b/space/components/account/auth-forms/auth-banner.tsx new file mode 100644 index 000000000..20b9ca819 --- /dev/null +++ b/space/components/account/auth-forms/auth-banner.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { FC } from "react"; +import { Info, X } from "lucide-react"; +// helpers +import { TAuthErrorInfo } from "@/helpers/authentication.helper"; + +type TAuthBanner = { + bannerData: TAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; +}; + +export const AuthBanner: FC = (props) => { + const { bannerData, handleBannerData } = props; + + if (!bannerData) return <>; + return ( +
+
+ +
+
{bannerData?.message}
+
handleBannerData && handleBannerData(undefined)} + > + +
+
+ ); +}; diff --git a/space/components/account/auth-forms/auth-header.tsx b/space/components/account/auth-forms/auth-header.tsx new file mode 100644 index 000000000..c3fff468e --- /dev/null +++ b/space/components/account/auth-forms/auth-header.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { FC, ReactNode } from "react"; +// helpers +import { EAuthModes } from "@/types/auth"; + +type TAuthHeader = { + authMode: EAuthModes; + children: ReactNode; +}; + +type TAuthHeaderContent = { + header: string; + subHeader: string; +}; + +type TAuthHeaderDetails = { + [mode in EAuthModes]: TAuthHeaderContent; +}; + +const Titles: TAuthHeaderDetails = { + [EAuthModes.SIGN_IN]: { + header: "Sign in to upvote or comment", + subHeader: "Contribute in nudging the features you want to get built.", + }, + [EAuthModes.SIGN_UP]: { + header: "Comment or react to issues", + subHeader: "Use plane to add your valuable inputs to features.", + }, +}; + +export const AuthHeader: FC = (props) => { + const { authMode, children } = props; + + const getHeaderSubHeader = (mode: EAuthModes | null): TAuthHeaderContent => { + if (mode) { + return Titles[mode]; + } + + return { + header: "Comment or react to issues", + subHeader: "Use plane to add your valuable inputs to features.", + }; + }; + + const { header, subHeader } = getHeaderSubHeader(authMode); + + return ( + <> +
+

{header}

+

{subHeader}

+
+ {children} + + ); +}; diff --git a/space/components/account/auth-forms/auth-root.tsx b/space/components/account/auth-forms/auth-root.tsx new file mode 100644 index 000000000..95c90605a --- /dev/null +++ b/space/components/account/auth-forms/auth-root.tsx @@ -0,0 +1,172 @@ +"use client"; + +import React, { FC, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { IEmailCheckData } from "@plane/types"; +// components +import { + AuthHeader, + AuthBanner, + AuthEmailForm, + AuthUniqueCodeForm, + AuthPasswordForm, + OAuthOptions, + TermsAndConditions, +} from "@/components/account"; +// helpers +import { + EAuthenticationErrorCodes, + EErrorAlertType, + TAuthErrorInfo, + authErrorHandler, +} from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store"; +// services +import { AuthService } from "@/services/auth.service"; +// types +import { EAuthModes, EAuthSteps } from "@/types/auth"; + +const authService = new AuthService(); + +export const AuthRoot: FC = observer(() => { + // router params + const searchParams = useSearchParams(); + const emailParam = searchParams.get("email") || undefined; + const error_code = searchParams.get("error_code") || undefined; + // states + const [authMode, setAuthMode] = useState(EAuthModes.SIGN_UP); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [errorInfo, setErrorInfo] = useState(undefined); + const [isPasswordAutoset, setIsPasswordAutoset] = useState(true); + // hooks + const { instance } = useInstance(); + + useEffect(() => { + if (error_code) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + if ( + [ + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, + ].includes(errorhandler.code) + ) + setAuthStep(EAuthSteps.PASSWORD); + if ( + [EAuthenticationErrorCodes.INVALID_MAGIC_CODE, EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE].includes( + errorhandler.code + ) + ) + setAuthStep(EAuthSteps.UNIQUE_CODE); + setErrorInfo(errorhandler); + } + } + }, [error_code]); + + const isSMTPConfigured = instance?.config?.is_smtp_configured || false; + const isMagicLoginEnabled = instance?.config?.is_magic_login_enabled || false; + const isEmailPasswordEnabled = instance?.config?.is_email_password_enabled || false; + const isOAuthEnabled = + (instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled)) || false; + + // submit handler- email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + + await authService + .emailCheck(data) + .then(async (response) => { + let currentAuthMode: EAuthModes = EAuthModes.SIGN_UP; + if (response.existing) { + currentAuthMode = EAuthModes.SIGN_IN; + setAuthMode(() => EAuthModes.SIGN_IN); + } else { + currentAuthMode = EAuthModes.SIGN_UP; + setAuthMode(() => EAuthModes.SIGN_UP); + } + + if (currentAuthMode === EAuthModes.SIGN_IN) { + if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (isEmailPasswordEnabled) { + setIsPasswordAutoset(false); + setAuthStep(EAuthSteps.PASSWORD); + } else { + const errorhandler = authErrorHandler("5005" as EAuthenticationErrorCodes); + setErrorInfo(errorhandler); + } + } else { + if (isSMTPConfigured && isMagicLoginEnabled) { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (isEmailPasswordEnabled) { + setAuthStep(EAuthSteps.PASSWORD); + } else { + const errorhandler = authErrorHandler("5006" as EAuthenticationErrorCodes); + setErrorInfo(errorhandler); + } + } + }) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); + if (errorhandler?.type) setErrorInfo(errorhandler); + }); + }; + + // generating the unique code + const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => { + 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; + }); + }; + + return ( +
+ + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + {authStep === EAuthSteps.EMAIL && } + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + generateEmailUniqueCode={generateEmailUniqueCode} + /> + )} + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + handleAuthStep={(step: EAuthSteps) => { + if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email); + setAuthStep(step); + }} + /> + )} + {isOAuthEnabled && } + + +
+ ); +}); diff --git a/space/components/account/auth-forms/email.tsx b/space/components/account/auth-forms/email.tsx new file mode 100644 index 000000000..946e51916 --- /dev/null +++ b/space/components/account/auth-forms/email.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { FC, FormEvent, useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +// icons +import { CircleAlert, XCircle } from "lucide-react"; +// types +import { IEmailCheckData } from "@plane/types"; +// ui +import { Button, Input, Spinner } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { checkEmailValidity } from "@/helpers/string.helper"; + +type TAuthEmailForm = { + defaultEmail: string; + onSubmit: (data: IEmailCheckData) => Promise; +}; + +export const AuthEmailForm: FC = observer((props) => { + const { onSubmit, defaultEmail } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + const [email, setEmail] = useState(defaultEmail); + const [isFocused, setFocused] = useState(false); + + const emailError = useMemo( + () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), + [email] + ); + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + const payload: IEmailCheckData = { + email: email, + }; + await onSubmit(payload); + setIsSubmitting(false); + }; + + const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; + + return ( +
+
+ +
+ setEmail(e.target.value)} + placeholder="name@company.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`} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + autoFocus + /> + {email.length > 0 && ( +
+ setEmail("")} /> +
+ )} +
+ {emailError?.email && !isFocused && ( +

+ + {emailError.email} +

+ )} +
+ +
+ ); +}); diff --git a/space/components/account/auth-forms/index.ts b/space/components/account/auth-forms/index.ts new file mode 100644 index 000000000..673181f27 --- /dev/null +++ b/space/components/account/auth-forms/index.ts @@ -0,0 +1,8 @@ +export * from "./auth-root"; + +export * from "./auth-header"; +export * from "./auth-banner"; + +export * from "./email"; +export * from "./password"; +export * from "./unique-code"; diff --git a/space/components/accounts/auth-forms/password.tsx b/space/components/account/auth-forms/password.tsx similarity index 66% rename from space/components/accounts/auth-forms/password.tsx rename to space/components/account/auth-forms/password.tsx index a4b22f0a0..6ca80b07c 100644 --- a/space/components/accounts/auth-forms/password.tsx +++ b/space/components/account/auth-forms/password.tsx @@ -1,26 +1,26 @@ "use client"; import React, { useEffect, useMemo, useState } from "react"; -// icons -import Link from "next/link"; -import { useParams } from "next/navigation"; +import { observer } from "mobx-react"; import { Eye, EyeOff, XCircle } from "lucide-react"; -// ui import { Button, Input, Spinner } from "@plane/ui"; -import { EAuthModes, EAuthSteps, ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/accounts"; +// components +import { PasswordStrengthMeter } from "@/components/account"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; -// services import { getPasswordStrength } from "@/helpers/password.helper"; -// hooks -import { useInstance } from "@/hooks/store"; +// services import { AuthService } from "@/services/auth.service"; +// types +import { EAuthModes, EAuthSteps } from "@/types/auth"; type Props = { email: string; + isPasswordAutoset: boolean; + isSMTPConfigured: boolean; mode: EAuthModes; handleEmailClear: () => void; - handleStepChange: (step: EAuthSteps) => void; + handleAuthStep: (step: EAuthSteps) => void; }; type TPasswordFormValues = { @@ -36,21 +36,21 @@ const defaultValues: TPasswordFormValues = { const authService = new AuthService(); -export const PasswordForm: React.FC = (props) => { - const { email, mode, handleEmailClear, handleStepChange } = props; +export const AuthPasswordForm: React.FC = observer((props: Props) => { + const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode } = props; // states - const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); - const [showPassword, setShowPassword] = useState(false); const [csrfToken, setCsrfToken] = useState(undefined); - const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); const [isSubmitting, setIsSubmitting] = useState(false); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); - // hooks - const { data: instance, config: instanceConfig } = useInstance(); - // router - const { next_path } = useParams(); - // derived values - const isSmtpConfigured = instanceConfig?.is_smtp_configured; + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); const handleFormChange = (key: keyof TPasswordFormValues, value: string) => setPasswordFormData((prev) => ({ ...prev, [key]: value })); @@ -60,26 +60,13 @@ export const PasswordForm: React.FC = (props) => { authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); }, [csrfToken]); - const redirectToUniqueCodeLogin = () => { - handleStepChange(EAuthSteps.UNIQUE_CODE); + const redirectToUniqueCodeSignIn = async () => { + handleAuthStep(EAuthSteps.UNIQUE_CODE); }; - const passwordSupport = - mode === EAuthModes.SIGN_IN ? ( -
- {isSmtpConfigured ? ( - - Forgot your password? - - ) : ( - - )} -
- ) : ( - isPasswordInputFocused && + const passwordSupport = passwordFormData.password.length > 0 && + (getPasswordStrength(passwordFormData.password) < 3 || isPasswordInputFocused) && ( + ); const isButtonDisabled = useMemo( @@ -104,60 +91,63 @@ export const PasswordForm: React.FC = (props) => { onError={() => setIsSubmitting(false)} > - +
-