diff --git a/space/components/accounts/email-code-form.tsx b/space/components/accounts/email-code-form.tsx deleted file mode 100644 index 5b1a15434..000000000 --- a/space/components/accounts/email-code-form.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { useEffect, useState, useCallback } from "react"; - -// react hook form -import { useForm } from "react-hook-form"; - -// services -import authenticationService from "services/authentication.service"; - -// hooks -import useToast from "hooks/use-toast"; -import useTimer from "hooks/use-timer"; - -// ui -import { Button, Input } from "@plane/ui"; - -// types -type EmailCodeFormValues = { - email: string; - key?: string; - token?: string; -}; - -export const EmailCodeForm = ({ handleSignIn }: any) => { - const [codeSent, setCodeSent] = useState(false); - const [codeResent, setCodeResent] = useState(false); - const [isCodeResending, setIsCodeResending] = useState(false); - const [errorResendingCode, setErrorResendingCode] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const { setToastAlert } = useToast(); - const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer(); - - const { - register, - handleSubmit, - setError, - setValue, - getValues, - watch, - formState: { errors, isSubmitting, isValid, isDirty }, - } = useForm({ - defaultValues: { - email: "", - key: "", - token: "", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - const isResendDisabled = resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode; - - const onSubmit = useCallback( - async ({ email }: EmailCodeFormValues) => { - setErrorResendingCode(false); - await authenticationService - .emailCode({ email }) - .then((res) => { - setValue("key", res.key); - setCodeSent(true); - }) - .catch((err) => { - setErrorResendingCode(true); - setToastAlert({ - title: "Oops!", - type: "error", - message: err?.error, - }); - }); - }, - [setToastAlert, setValue] - ); - - const handleSignin = async (formData: EmailCodeFormValues) => { - setIsLoading(true); - await authenticationService - .magicSignIn(formData) - .then((response) => { - setIsLoading(false); - handleSignIn(response); - }) - .catch((error) => { - setIsLoading(false); - setToastAlert({ - title: "Oops!", - type: "error", - message: error?.response?.data?.error ?? "Enter the correct code to sign in", - }); - setError("token" as keyof EmailCodeFormValues, { - type: "manual", - message: error?.error, - }); - }); - }; - - const emailOld = getValues("email"); - - useEffect(() => { - setErrorResendingCode(false); - }, [emailOld]); - - useEffect(() => { - const submitForm = (e: KeyboardEvent) => { - if (!codeSent && e.key === "Enter") { - e.preventDefault(); - handleSubmit(onSubmit)().then(() => { - setResendCodeTimer(30); - }); - } - }; - - if (!codeSent) { - window.addEventListener("keydown", submitForm); - } - - return () => { - window.removeEventListener("keydown", submitForm); - }; - }, [handleSubmit, codeSent, onSubmit, setResendCodeTimer]); - - return ( - <> - {(codeSent || codeResent) && ( -

- We have sent the sign in code. -
- Please check your inbox at {watch("email")} -

- )} -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - })} - /> - {errors.email &&
{errors.email.message}
} -
- - {codeSent && ( - <> - - {errors.token &&
{errors.token.message}
} - - - )} - {codeSent ? ( - - ) : ( - - )} -
- - ); -}; diff --git a/space/components/accounts/email-password-form.tsx b/space/components/accounts/email-password-form.tsx deleted file mode 100644 index b07a26956..000000000 --- a/space/components/accounts/email-password-form.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { useState } from "react"; -import { useRouter } from "next/router"; -import Link from "next/link"; -import { useForm } from "react-hook-form"; -// components -import { EmailResetPasswordForm } from "./email-reset-password-form"; -// ui -import { Button, Input } from "@plane/ui"; - -// types -type EmailPasswordFormValues = { - email: string; - password?: string; - medium?: string; -}; - -type Props = { - onSubmit: (formData: EmailPasswordFormValues) => Promise; -}; - -export const EmailPasswordForm: React.FC = ({ onSubmit }) => { - const [isResettingPassword, setIsResettingPassword] = useState(false); - - const router = useRouter(); - const isSignUpPage = router.pathname === "/sign-up"; - - const { - register, - handleSubmit, - formState: { errors, isSubmitting, isValid, isDirty }, - } = useForm({ - defaultValues: { - email: "", - password: "", - medium: "email", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - return ( - <> -

- {isResettingPassword ? "Reset your password" : isSignUpPage ? "Sign up on Plane" : "Sign in to Plane"} -

- {isResettingPassword ? ( - - ) : ( -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - })} - placeholder="Enter your email address..." - className="border-custom-border-300 h-[46px] w-full" - /> - {errors.email &&
{errors.email.message}
} -
-
- - {errors.password &&
{errors.password.message}
} -
-
- {isSignUpPage ? ( - - - Already have an account? Sign in. - - - ) : ( - - )} -
-
- - {!isSignUpPage && ( - - - Don{"'"}t have an account? Sign up. - - - )} -
-
- )} - - ); -}; diff --git a/space/components/accounts/email-reset-password-form.tsx b/space/components/accounts/email-reset-password-form.tsx deleted file mode 100644 index dc4c32775..000000000 --- a/space/components/accounts/email-reset-password-form.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from "react"; -import { useForm } from "react-hook-form"; -// ui -import { Button, Input } from "@plane/ui"; -// types -type Props = { - setIsResettingPassword: React.Dispatch>; -}; - -export const EmailResetPasswordForm: React.FC = ({ setIsResettingPassword }) => { - // const { setToastAlert } = useToast(); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - defaultValues: { - email: "", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - const forgotPassword = async (formData: any) => { - // const payload = { - // email: formData.email, - // }; - // await userService - // .forgotPassword(payload) - // .then(() => - // setToastAlert({ - // type: "success", - // title: "Success!", - // message: "Password reset link has been sent to your email address.", - // }) - // ) - // .catch((err) => { - // if (err.status === 400) - // setToastAlert({ - // type: "error", - // title: "Error!", - // message: "Please check the Email ID entered.", - // }); - // else - // setToastAlert({ - // type: "error", - // title: "Error!", - // message: "Something went wrong. Please try again.", - // }); - // }); - }; - - return ( -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - })} - placeholder="Enter registered email address.." - className="h-[46px] border-custom-border-300 w-full" - /> - {errors.email &&
{errors.email.message}
} -
-
- - -
-
- ); -}; diff --git a/space/components/accounts/github-login-button.tsx b/space/components/accounts/github-login-button.tsx index b1bd586fe..2d64261f8 100644 --- a/space/components/accounts/github-login-button.tsx +++ b/space/components/accounts/github-login-button.tsx @@ -38,7 +38,7 @@ export const GithubLoginButton: FC = (props) => { }, []); return ( -
+
= (props) => { theme: "outline", size: "large", logo_alignment: "center", - width: 360, text: "signin_with", - } as any // customization attributes + } as GsiButtonConfiguration // customization attributes ); } catch (err) { console.log(err); @@ -40,7 +39,7 @@ export const GoogleLoginButton: FC = (props) => { (window as any)?.google?.accounts.id.prompt(); // also display the One Tap dialog setGsiScriptLoaded(true); - }, [handleSignIn, gsiScriptLoaded]); + }, [handleSignIn, gsiScriptLoaded, clientId]); useEffect(() => { if ((window as any)?.google?.accounts?.id) { diff --git a/space/components/accounts/index.ts b/space/components/accounts/index.ts index 03a173766..db170180f 100644 --- a/space/components/accounts/index.ts +++ b/space/components/accounts/index.ts @@ -1,8 +1,5 @@ -export * from "./email-code-form"; -export * from "./email-password-form"; -export * from "./email-reset-password-form"; export * from "./github-login-button"; export * from "./google-login"; export * from "./onboarding-form"; -export * from "./sign-in"; export * from "./user-logged-in"; +export * from "./sign-in-forms"; diff --git a/space/components/accounts/onboarding-form.tsx b/space/components/accounts/onboarding-form.tsx index e372ac1e5..4e647506b 100644 --- a/space/components/accounts/onboarding-form.tsx +++ b/space/components/accounts/onboarding-form.tsx @@ -11,7 +11,7 @@ import { USER_ROLES } from "constants/workspace"; // hooks import useToast from "hooks/use-toast"; // services -import UserService from "services/user.service"; +import { UserService } from "services/user.service"; // ui import { Button, Input } from "@plane/ui"; @@ -93,6 +93,7 @@ export const OnBoardingForm: React.FC = observer(({ user }) => { = observer(({ user }) => { = observer(({ user }) => {
- diff --git a/space/components/accounts/sign-in-forms/create-password.tsx b/space/components/accounts/sign-in-forms/create-password.tsx new file mode 100644 index 000000000..330693e98 --- /dev/null +++ b/space/components/accounts/sign-in-forms/create-password.tsx @@ -0,0 +1,141 @@ +import React, { useEffect } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + email: string; + handleStepChange: (step: ESignInSteps) => void; + handleSignInRedirection: () => Promise; + isOnboarded: boolean; +}; + +type TCreatePasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TCreatePasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const CreatePasswordForm: React.FC = (props) => { + const { email, handleSignInRedirection, isOnboarded } = props; + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + setFocus, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleCreatePassword = async (formData: TCreatePasswordFormValues) => { + const payload = { + password: formData.password, + }; + + await authService + .setPassword(payload) + .then(async () => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Password created successfully.", + }); + await handleSignInRedirection(); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + useEffect(() => { + setFocus("password"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

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

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

+ + + ); +}; diff --git a/space/components/accounts/sign-in-forms/email-form.tsx b/space/components/accounts/sign-in-forms/email-form.tsx new file mode 100644 index 000000000..23967642c --- /dev/null +++ b/space/components/accounts/sign-in-forms/email-form.tsx @@ -0,0 +1,122 @@ +import React, { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IEmailCheckData } from "types/auth"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + handleStepChange: (step: ESignInSteps) => void; + updateEmail: (email: string) => void; +}; + +type TEmailFormValues = { + email: string; +}; + +const authService = new AuthService(); + +export const EmailForm: React.FC = (props) => { + const { handleStepChange, updateEmail } = props; + + const { setToastAlert } = useToast(); + + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + setFocus, + } = useForm({ + defaultValues: { + email: "", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (data: TEmailFormValues) => { + const payload: IEmailCheckData = { + email: data.email, + }; + + // update the global email state + updateEmail(data.email); + + await authService + .emailCheck(payload) + .then((res) => { + // if the password has been autoset, send the user to magic sign-in + if (res.is_password_autoset) handleStepChange(ESignInSteps.UNIQUE_CODE); + // if the password has not been autoset, send them to password sign-in + else handleStepChange(ESignInSteps.PASSWORD); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + useEffect(() => { + setFocus("email"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

+

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

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( +
+ + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> +
+ +
+ + ); +}; diff --git a/space/components/accounts/sign-in-forms/index.ts b/space/components/accounts/sign-in-forms/index.ts new file mode 100644 index 000000000..1150a071c --- /dev/null +++ b/space/components/accounts/sign-in-forms/index.ts @@ -0,0 +1,9 @@ +export * from "./create-password"; +export * from "./email-form"; +export * from "./o-auth-options"; +export * from "./optional-set-password"; +export * from "./password"; +export * from "./root"; +export * from "./self-hosted-sign-in"; +export * from "./set-password-link"; +export * from "./unique-code"; diff --git a/space/components/accounts/sign-in-forms/o-auth-options.tsx b/space/components/accounts/sign-in-forms/o-auth-options.tsx new file mode 100644 index 000000000..601db6721 --- /dev/null +++ b/space/components/accounts/sign-in-forms/o-auth-options.tsx @@ -0,0 +1,86 @@ +import useSWR from "swr"; + +import { observer } from "mobx-react-lite"; +// services +import { AuthService } from "services/authentication.service"; +import { AppConfigService } from "services/app-config.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { GithubLoginButton, GoogleLoginButton } from "components/accounts"; + +type Props = { + handleSignInRedirection: () => Promise; +}; + +// services +const authService = new AuthService(); +const appConfig = new AppConfigService(); + +export const OAuthOptions: React.FC = observer((props) => { + const { handleSignInRedirection } = props; + // toast alert + const { setToastAlert } = useToast(); + + const { data: envConfig } = useSWR("APP_CONFIG", () => appConfig.envConfig()); + + const handleGoogleSignIn = async ({ clientId, credential }: any) => { + try { + if (clientId && credential) { + const socialAuthPayload = { + medium: "google", + credential, + clientId, + }; + const response = await authService.socialAuth(socialAuthPayload); + + if (response) handleSignInRedirection(); + } else throw Error("Cant find credentials"); + } catch (err: any) { + setToastAlert({ + title: "Error signing in!", + type: "error", + message: err?.error || "Something went wrong. Please try again later or contact the support team.", + }); + } + }; + + const handleGitHubSignIn = async (credential: string) => { + try { + if (envConfig && envConfig.github_client_id && credential) { + const socialAuthPayload = { + medium: "github", + credential, + clientId: envConfig.github_client_id, + }; + const response = await authService.socialAuth(socialAuthPayload); + + if (response) handleSignInRedirection(); + } else throw Error("Cant find credentials"); + } catch (err: any) { + setToastAlert({ + title: "Error signing in!", + type: "error", + message: err?.error || "Something went wrong. Please try again later or contact the support team.", + }); + } + }; + + return ( + <> +
+
+

Or continue with

+
+
+
+ {envConfig?.google_client_id && ( + + )} + {envConfig?.github_client_id && ( + + )} +
+ + ); +}); diff --git a/space/components/accounts/sign-in-forms/optional-set-password.tsx b/space/components/accounts/sign-in-forms/optional-set-password.tsx new file mode 100644 index 000000000..ffe8ae5cd --- /dev/null +++ b/space/components/accounts/sign-in-forms/optional-set-password.tsx @@ -0,0 +1,104 @@ +import React, { useState } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + email: string; + handleStepChange: (step: ESignInSteps) => void; + handleSignInRedirection: () => Promise; + isOnboarded: boolean; +}; + +export const OptionalSetPasswordForm: React.FC = (props) => { + const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props; + // states + const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); + // form info + const { + control, + formState: { errors, isValid }, + } = useForm({ + defaultValues: { + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleGoToWorkspace = async () => { + setIsGoingToWorkspace(true); + + await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + }; + + return ( + <> +

Set a password

+

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

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

+ When you click{" "} + {isOnboarded ? "Go to board" : "Set up profile"} above, you + agree with our{" "} + + terms and conditions of service. + +

+ + + ); +}; diff --git a/space/components/accounts/sign-in-forms/password.tsx b/space/components/accounts/sign-in-forms/password.tsx new file mode 100644 index 000000000..9b8c4211c --- /dev/null +++ b/space/components/accounts/sign-in-forms/password.tsx @@ -0,0 +1,233 @@ +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IPasswordSignInData } from "types/auth"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + email: string; + updateEmail: (email: string) => void; + handleStepChange: (step: ESignInSteps) => void; + handleSignInRedirection: () => Promise; +}; + +type TPasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const PasswordForm: React.FC = (props) => { + const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; + // states + const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); + const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + control, + formState: { dirtyFields, errors, isSubmitting, isValid }, + getValues, + handleSubmit, + setError, + setFocus, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (formData: TPasswordFormValues) => { + updateEmail(formData.email); + + const payload: IPasswordSignInData = { + email: formData.email, + password: formData.password, + }; + + await authService + .passwordSignIn(payload) + .then(async () => await handleSignInRedirection()) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleForgotPassword = async () => { + const emailFormValue = getValues("email"); + + const isEmailValid = checkEmailValidity(emailFormValue); + + if (!isEmailValid) { + setError("email", { message: "Email is invalid" }); + return; + } + + setIsSendingResetPasswordLink(true); + + authService + .sendResetPasswordLink({ email: emailFormValue }) + .then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK)) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ) + .finally(() => setIsSendingResetPasswordLink(false)); + }; + + const handleSendUniqueCode = async () => { + const emailFormValue = getValues("email"); + + const isEmailValid = checkEmailValidity(emailFormValue); + + if (!isEmailValid) { + setError("email", { message: "Email is invalid" }); + return; + } + + setIsSendingUniqueCode(true); + + await authService + .generateUniqueCode({ email: emailFormValue }) + .then(() => handleStepChange(ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD)) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ) + .finally(() => setIsSendingUniqueCode(false)); + }; + + useEffect(() => { + setFocus("password"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

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

+ When you click Go to board above, you agree with our{" "} + + terms and conditions of service. + +

+
+ + ); +}; diff --git a/space/components/accounts/sign-in-forms/root.tsx b/space/components/accounts/sign-in-forms/root.tsx new file mode 100644 index 000000000..c36842ce7 --- /dev/null +++ b/space/components/accounts/sign-in-forms/root.tsx @@ -0,0 +1,120 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// hooks +import useSignInRedirection from "hooks/use-sign-in-redirection"; +// services +import { AppConfigService } from "services/app-config.service"; +// components +import { LatestFeatureBlock } from "components/common"; +import { + EmailForm, + UniqueCodeForm, + PasswordForm, + SetPasswordLink, + OAuthOptions, + OptionalSetPasswordForm, + CreatePasswordForm, + SelfHostedSignInForm, +} from "components/accounts"; + +export enum ESignInSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + SET_PASSWORD_LINK = "SET_PASSWORD_LINK", + UNIQUE_CODE = "UNIQUE_CODE", + OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", + CREATE_PASSWORD = "CREATE_PASSWORD", + USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD", +} + +const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; + +const appConfig = new AppConfigService(); + +export const SignInRoot = observer(() => { + // states + const [signInStep, setSignInStep] = useState(ESignInSteps.EMAIL); + const [email, setEmail] = useState(""); + const [isOnboarded, setIsOnboarded] = useState(false); + // sign in redirection hook + const { handleRedirection } = useSignInRedirection(); + + const { data: envConfig } = useSWR("APP_CONFIG", () => appConfig.envConfig()); + + const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id); + return ( + <> +
+ {envConfig?.is_self_managed ? ( + setEmail(newEmail)} + handleSignInRedirection={handleRedirection} + /> + ) : ( + <> + {signInStep === ESignInSteps.EMAIL && ( + setSignInStep(step)} + updateEmail={(newEmail) => setEmail(newEmail)} + /> + )} + {signInStep === ESignInSteps.PASSWORD && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + /> + )} + {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( + setEmail(newEmail)} /> + )} + {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + submitButtonLabel="Go to board" + showTermsAndConditions + updateUserOnboardingStatus={(value) => setIsOnboarded(value)} + /> + )} + {signInStep === ESignInSteps.UNIQUE_CODE && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + updateUserOnboardingStatus={(value) => setIsOnboarded(value)} + /> + )} + {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( + setSignInStep(step)} + handleSignInRedirection={handleRedirection} + isOnboarded={isOnboarded} + /> + )} + {signInStep === ESignInSteps.CREATE_PASSWORD && ( + setSignInStep(step)} + handleSignInRedirection={handleRedirection} + isOnboarded={isOnboarded} + /> + )} + + )} +
+ {isOAuthEnabled && + !OAUTH_HIDDEN_STEPS.includes(signInStep) && + signInStep !== ESignInSteps.CREATE_PASSWORD && + signInStep !== ESignInSteps.PASSWORD && } + + + ); +}); diff --git a/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx b/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx new file mode 100644 index 000000000..6ad0cfd8a --- /dev/null +++ b/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx @@ -0,0 +1,144 @@ +import React, { useEffect } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IPasswordSignInData } from "types/auth"; + +type Props = { + email: string; + updateEmail: (email: string) => void; + handleSignInRedirection: () => Promise; +}; + +type TPasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const SelfHostedSignInForm: React.FC = (props) => { + const { email, updateEmail, handleSignInRedirection } = props; + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + control, + formState: { dirtyFields, errors, isSubmitting }, + handleSubmit, + setFocus, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (formData: TPasswordFormValues) => { + const payload: IPasswordSignInData = { + email: formData.email, + password: formData.password, + }; + + updateEmail(formData.email); + + await authService + .passwordSignIn(payload) + .then(async () => await handleSignInRedirection()) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + useEffect(() => { + setFocus("email"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

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

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

+
+ + ); +}; diff --git a/space/components/accounts/sign-in-forms/set-password-link.tsx b/space/components/accounts/sign-in-forms/set-password-link.tsx new file mode 100644 index 000000000..b0331f7e0 --- /dev/null +++ b/space/components/accounts/sign-in-forms/set-password-link.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IEmailCheckData } from "types/auth"; + +type Props = { + email: string; + updateEmail: (email: string) => void; +}; + +const authService = new AuthService(); + +export const SetPasswordLink: React.FC = (props) => { + const { email, updateEmail } = props; + + const { setToastAlert } = useToast(); + + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleSendNewLink = async (formData: { email: string }) => { + updateEmail(formData.email); + + const payload: IEmailCheckData = { + email: formData.email, + }; + + await authService + .sendResetPasswordLink(payload) + .then(() => + setToastAlert({ + type: "success", + title: "Success!", + message: "We have sent a new link to your email.", + }) + ) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + return ( + <> +

+ Get on your flight deck +

+

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

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange } }) => ( + + )} + /> +
+ +
+ + ); +}; diff --git a/space/components/accounts/sign-in-forms/unique-code.tsx b/space/components/accounts/sign-in-forms/unique-code.tsx new file mode 100644 index 000000000..638023bc7 --- /dev/null +++ b/space/components/accounts/sign-in-forms/unique-code.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { CornerDownLeft, XCircle } from "lucide-react"; +// services +import { AuthService } from "services/authentication.service"; +import { UserService } from "services/user.service"; +// hooks +import useToast from "hooks/use-toast"; +import useTimer from "hooks/use-timer"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IEmailCheckData, IMagicSignInData } from "types/auth"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + email: string; + updateEmail: (email: string) => void; + handleStepChange: (step: ESignInSteps) => void; + handleSignInRedirection: () => Promise; + submitButtonLabel?: string; + showTermsAndConditions?: boolean; + updateUserOnboardingStatus: (value: boolean) => void; +}; + +type TUniqueCodeFormValues = { + email: string; + token: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + token: "", +}; + +// services +const authService = new AuthService(); +const userService = new UserService(); + +export const UniqueCodeForm: React.FC = (props) => { + const { + email, + updateEmail, + handleStepChange, + handleSignInRedirection, + submitButtonLabel = "Continue", + showTermsAndConditions = false, + updateUserOnboardingStatus, + } = props; + // states + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + // toast alert + const { setToastAlert } = useToast(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); + // form info + const { + control, + formState: { dirtyFields, errors, isSubmitting, isValid }, + getValues, + handleSubmit, + reset, + setFocus, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleUniqueCodeSignIn = async (formData: TUniqueCodeFormValues) => { + const payload: IMagicSignInData = { + email: formData.email, + key: `magic_${formData.email}`, + token: formData.token, + }; + + await authService + .magicSignIn(payload) + .then(async () => { + const currentUser = await userService.currentUser(); + + updateUserOnboardingStatus(currentUser.onboarding_step.profile_complete ?? false); + + if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD); + else await handleSignInRedirection(); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { + const payload: IEmailCheckData = { + email: formData.email, + }; + + await authService + .generateUniqueCode(payload) + .then(() => { + setResendCodeTimer(30); + setToastAlert({ + type: "success", + title: "Success!", + message: "A new unique code has been sent to your email.", + }); + + reset({ + email: formData.email, + token: "", + }); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleFormSubmit = async (formData: TUniqueCodeFormValues) => { + updateEmail(formData.email); + + if (dirtyFields.email) await handleSendNewCode(formData); + else await handleUniqueCodeSignIn(formData); + }; + + const handleRequestNewCode = async () => { + setIsRequestingNewCode(true); + + await handleSendNewCode(getValues()) + .then(() => setResendCodeTimer(30)) + .finally(() => setIsRequestingNewCode(false)); + }; + + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const hasEmailChanged = dirtyFields.email; + + useEffect(() => { + setFocus("token"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

+

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

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( +
+ { + if (hasEmailChanged) handleSendNewCode(getValues()); + }} + ref={ref} + hasError={Boolean(errors.email)} + placeholder="orville.wright@firstflight.com" + className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" + /> + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> + {hasEmailChanged && ( + + )} +
+
+ ( + + )} + /> +
+ +
+
+ + {showTermsAndConditions && ( +

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

+ )} +
+ + ); +}; diff --git a/space/components/accounts/sign-in.tsx b/space/components/accounts/sign-in.tsx deleted file mode 100644 index b55824e6c..000000000 --- a/space/components/accounts/sign-in.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from "react"; -import useSWR from "swr"; -import { useRouter } from "next/router"; -// mobx -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; -// services -import authenticationService from "services/authentication.service"; -import { AppConfigService } from "services/app-config.service"; -// hooks -import useToast from "hooks/use-toast"; -// components -import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts"; -// images -const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : ""; - -const appConfig = new AppConfigService(); - -export const SignInView = observer(() => { - const { user: userStore } = useMobxStore(); - // router - const router = useRouter(); - const { next_path } = router.query as { next_path: string }; - // toast - const { setToastAlert } = useToast(); - // fetch app config - const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig()); - - const onSignInError = (error: any) => { - setToastAlert({ - title: "Error signing in!", - type: "error", - message: error?.error || "Something went wrong. Please try again later or contact the support team.", - }); - }; - - const onSignInSuccess = (response: any) => { - userStore.setCurrentUser(response?.user); - - const isOnboard = response?.user?.onboarding_step?.profile_complete || false; - - if (isOnboard) { - if (next_path) router.push(next_path); - else router.push("/login"); - } else { - if (next_path) router.push(`/onboarding?next_path=${next_path}`); - else router.push("/onboarding"); - } - }; - - const handleGoogleSignIn = async ({ clientId, credential }: any) => { - try { - if (clientId && credential) { - const socialAuthPayload = { - medium: "google", - credential, - clientId, - }; - const response = await authenticationService.socialAuth(socialAuthPayload); - - onSignInSuccess(response); - } else { - throw Error("Cant find credentials"); - } - } catch (err: any) { - onSignInError(err); - } - }; - - const handlePasswordSignIn = async (formData: any) => { - await authenticationService - .emailLogin(formData) - .then((response) => { - try { - if (response) { - onSignInSuccess(response); - } - } catch (err: any) { - onSignInError(err); - } - }) - .catch((err) => onSignInError(err)); - }; - - const handleEmailCodeSignIn = async (response: any) => { - try { - if (response) { - onSignInSuccess(response); - } - } catch (err: any) { - onSignInError(err); - } - }; - - return ( -
-
-
-
-
- Plane Logo -
-
-
-
-
-

Sign in to Plane

- {data?.email_password_login && } - - {data?.magic_login && ( -
-
- -
-
- )} - -
- {data?.google_client_id && ( - - )} -
- -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

-
-
-
- ); -}); diff --git a/space/components/common/index.ts b/space/components/common/index.ts new file mode 100644 index 000000000..f1c0b088e --- /dev/null +++ b/space/components/common/index.ts @@ -0,0 +1 @@ +export * from "./latest-feature-block"; diff --git a/space/components/common/latest-feature-block.tsx b/space/components/common/latest-feature-block.tsx new file mode 100644 index 000000000..5abbbf603 --- /dev/null +++ b/space/components/common/latest-feature-block.tsx @@ -0,0 +1,36 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +// icons +import { Lightbulb } from "lucide-react"; +// images +import latestFeatures from "public/onboarding/onboarding-pages.svg"; + +export const LatestFeatureBlock = () => { + const { resolvedTheme } = useTheme(); + + return ( + <> +
+ +

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

+
+
+
+ Plane Issues +
+
+ + ); +}; diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index f04dbbe01..68ac61def 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -157,7 +157,7 @@ const IssueNavbar = observer(() => { {user ? (
- +
{user.display_name}
) : ( diff --git a/space/components/views/login.tsx b/space/components/views/login.tsx index 2f4d0946c..a1f9c5250 100644 --- a/space/components/views/login.tsx +++ b/space/components/views/login.tsx @@ -1,18 +1,61 @@ +import Image from "next/image"; + // mobx import { observer } from "mobx-react-lite"; import { useMobxStore } from "lib/mobx/store-provider"; // components -import { SignInView, UserLoggedIn } from "components/accounts"; +import { SignInRoot, UserLoggedIn } from "components/accounts"; +import { Loader } from "@plane/ui"; +// images +import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; export const LoginView = observer(() => { - const { user: userStore } = useMobxStore(); + // store + const { + user: { currentUser, loader }, + } = useMobxStore(); return ( <> - {userStore?.loader ? ( + {loader ? (
Loading
// TODO: Add spinner instead ) : ( - <>{userStore.currentUser ? : } + <> + {currentUser ? ( + + ) : ( +
+
+
+ Plane Logo + Plane +
+
+ +
+
+ {!true ? ( +
+
+ + + + + + + + + +
+
+ ) : ( + + )} +
+
+
+ )} + )} ); diff --git a/space/google.d.ts b/space/google.d.ts new file mode 100644 index 000000000..c37c83c94 --- /dev/null +++ b/space/google.d.ts @@ -0,0 +1,11 @@ +// google.d.ts +interface GsiButtonConfiguration { + type: "standard" | "icon"; + theme?: "outline" | "filled_blue" | "filled_black"; + size?: "large" | "medium" | "small"; + text?: "signin_with" | "signup_with" | "continue_with" | "signup_with"; + shape?: "rectangular" | "pill" | "circle" | "square"; + logo_alignment?: "left" | "center"; + width?: number; + local?: string; +} diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index 1b676ca57..4a265ba4e 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -29,3 +29,21 @@ export const copyTextToClipboard = async (text: string) => { } await navigator.clipboard.writeText(text); }; + +/** + * @returns {boolean} true if email is valid, false otherwise + * @description Returns true if email is valid, false otherwise + * @param {string} email string to check if it is a valid email + * @example checkEmailIsValid("hello world") => false + * @example checkEmailIsValid("example@plane.so") => true + */ +export const checkEmailValidity = (email: string): boolean => { + if (!email) return false; + + const isEmailValid = + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + email + ); + + return isEmailValid; +}; diff --git a/space/hooks/use-sign-in-redirection.tsx b/space/hooks/use-sign-in-redirection.tsx new file mode 100644 index 000000000..cf794e38d --- /dev/null +++ b/space/hooks/use-sign-in-redirection.tsx @@ -0,0 +1,66 @@ +import { useCallback, useState } from "react"; +import { useRouter } from "next/router"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// types +import { IUser } from "types/user"; + +type UseSignInRedirectionProps = { + error: any | null; + isRedirecting: boolean; + handleRedirection: () => Promise; +}; + +const useSignInRedirection = (): UseSignInRedirectionProps => { + // states + const [isRedirecting, setIsRedirecting] = useState(true); + const [error, setError] = useState(null); + // router + const router = useRouter(); + const { next_path } = router.query; + // mobx store + const { + user: { fetchCurrentUser }, + } = useMobxStore(); + + const handleSignInRedirection = useCallback( + async (user: IUser) => { + const isOnboard = user.onboarding_step?.profile_complete; + + if (isOnboard) { + // if next_path is provided, redirect the user to that url + if (next_path) router.push(next_path.toString()); + else router.push("/login"); + } else { + // if the user profile is not complete, redirect them to the onboarding page to complete their profile and then redirect them to the next path + if (next_path) router.push(`/onboarding?next_path=${next_path}`); + else router.push("/onboarding"); + } + }, + [router, next_path] + ); + + const updateUserInfo = useCallback(async () => { + setIsRedirecting(true); + + await fetchCurrentUser() + .then(async (user) => { + if (user) + await handleSignInRedirection(user) + .catch((err) => setError(err)) + .finally(() => setIsRedirecting(false)); + }) + .catch((err) => { + setError(err); + setIsRedirecting(false); + }); + }, [fetchCurrentUser, handleSignInRedirection]); + + return { + error, + isRedirecting, + handleRedirection: updateUserInfo, + }; +}; + +export default useSignInRedirection; diff --git a/space/pages/accounts/password.tsx b/space/pages/accounts/password.tsx new file mode 100644 index 000000000..33d12544d --- /dev/null +++ b/space/pages/accounts/password.tsx @@ -0,0 +1,180 @@ +import { NextPage } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useTheme } from "next-themes"; +import { Lightbulb } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +import useSignInRedirection from "hooks/use-sign-in-redirection"; +// ui +import { Button, Input } from "@plane/ui"; +// images +import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; +import latestFeatures from "public/onboarding/onboarding-pages.svg"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; + +type TResetPasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +const HomePage: NextPage = () => { + // router + const router = useRouter(); + const { uidb64, token, email } = router.query; + // next-themes + const { resolvedTheme } = useTheme(); + // toast + const { setToastAlert } = useToast(); + // sign in redirection hook + const { handleRedirection } = useSignInRedirection(); + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email: email?.toString() ?? "", + }, + }); + + const handleResetPassword = async (formData: TResetPasswordFormValues) => { + if (!uidb64 || !token || !email) return; + + const payload = { + new_password: formData.password, + }; + + await authService + .resetPassword(uidb64.toString(), token.toString(), payload) + .then(() => handleRedirection()) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + return ( +
+
+
+ Plane Logo + Plane +
+
+ +
+
+
+

+ Let{"'"}s get a new password +

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

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

+
+ +

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

+ +
+
+ +

+ Try the latest features, like Tiptap editor, to write compelling responses.{" "} + + See new features + +

+
+
+
+ Plane Issues +
+
+
+
+
+ ); +}; + +export default HomePage; diff --git a/space/public/onboarding/onboarding-pages.svg b/space/public/onboarding/onboarding-pages.svg new file mode 100644 index 000000000..5ed5d44c2 --- /dev/null +++ b/space/public/onboarding/onboarding-pages.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/space/services/app-config.service.ts b/space/services/app-config.service.ts index 09a6989ef..af79935cf 100644 --- a/space/services/app-config.service.ts +++ b/space/services/app-config.service.ts @@ -2,15 +2,8 @@ import APIService from "services/api.service"; // helper import { API_BASE_URL } from "helpers/common.helper"; - -export interface IAppConfig { - email_password_login: boolean; - google_client_id: string | null; - github_app_name: string | null; - github_client_id: string | null; - magic_login: boolean; - slack_client_id: string | null; -} +// types +import { IAppConfig } from "types/app"; export class AppConfigService extends APIService { constructor() { diff --git a/space/services/authentication.service.ts b/space/services/authentication.service.ts index 4d861994f..7bf0eccfa 100644 --- a/space/services/authentication.service.ts +++ b/space/services/authentication.service.ts @@ -1,12 +1,21 @@ // services import APIService from "services/api.service"; import { API_BASE_URL } from "helpers/common.helper"; +import { IEmailCheckData, IEmailCheckResponse, ILoginTokenResponse, IPasswordSignInData } from "types/auth"; -class AuthService extends APIService { +export class AuthService extends APIService { constructor() { super(API_BASE_URL); } + async emailCheck(data: IEmailCheckData): Promise { + return this.post("/api/email-check/", data, { headers: {} }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async emailLogin(data: any) { return this.post("/api/sign-in/", data, { headers: {} }) .then((response) => { @@ -47,6 +56,26 @@ class AuthService extends APIService { }); } + async passwordSignIn(data: IPasswordSignInData): Promise { + return this.post("/api/sign-in/", data, { headers: {} }) + .then((response) => { + this.setAccessToken(response?.data?.access_token); + this.setRefreshToken(response?.data?.refresh_token); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async sendResetPasswordLink(data: { email: string }): Promise { + return this.post(`/api/forgot-password/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + async emailCode(data: any) { return this.post("/api/magic-generate/", data, { headers: {} }) .then((response) => response?.data) @@ -73,6 +102,42 @@ class AuthService extends APIService { throw response.response.data; } + async generateUniqueCode(data: { email: string }): Promise { + return this.post("/api/magic-generate/", data, { headers: {} }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async resetPassword( + uidb64: string, + token: string, + data: { + new_password: string; + } + ): Promise { + return this.post(`/api/reset-password/${uidb64}/${token}/`, data, { headers: {} }) + .then((response) => { + if (response?.status === 200) { + this.setAccessToken(response?.data?.access_token); + this.setRefreshToken(response?.data?.refresh_token); + return response?.data; + } + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async setPassword(data: { password: string }): Promise { + return this.post(`/api/users/me/set-password/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async signOut() { return this.post("/api/sign-out/", { refresh_token: this.getRefreshToken() }) .then((response) => { @@ -87,7 +152,3 @@ class AuthService extends APIService { }); } } - -const authService = new AuthService(); - -export default authService; diff --git a/space/services/user.service.ts b/space/services/user.service.ts index 21e9f941e..c8232afa9 100644 --- a/space/services/user.service.ts +++ b/space/services/user.service.ts @@ -1,13 +1,16 @@ // services import APIService from "services/api.service"; +// helpers import { API_BASE_URL } from "helpers/common.helper"; +// types +import { IUser } from "types/user"; -class UserService extends APIService { +export class UserService extends APIService { constructor() { super(API_BASE_URL); } - async currentUser(): Promise { + async currentUser(): Promise { return this.get("/api/users/me/") .then((response) => response?.data) .catch((error) => { @@ -23,5 +26,3 @@ class UserService extends APIService { }); } } - -export default UserService; diff --git a/space/store/user.ts b/space/store/user.ts index e2b6428ef..e97f655f7 100644 --- a/space/store/user.ts +++ b/space/store/user.ts @@ -1,7 +1,7 @@ // mobx import { observable, action, computed, makeObservable, runInAction } from "mobx"; // service -import UserService from "services/user.service"; +import { UserService } from "services/user.service"; // types import { IUser } from "types/user"; @@ -9,7 +9,7 @@ export interface IUserStore { loader: boolean; error: any | null; currentUser: any | null; - fetchCurrentUser: () => void; + fetchCurrentUser: () => Promise; currentActor: () => any; } @@ -83,12 +83,13 @@ class UserStore implements IUserStore { this.loader = true; this.error = null; const response = await this.userService.currentUser(); - if (response) { + + if (response) runInAction(() => { this.loader = false; this.currentUser = response; }); - } + return response; } catch (error) { console.error("Failed to fetch current user", error); this.loader = false; diff --git a/space/styles/globals.css b/space/styles/globals.css index ea04bcda6..6b2a53e3f 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -97,6 +97,28 @@ --color-background-100: 255, 255, 255; /* primary bg */ --color-background-90: 250, 250, 250; /* secondary bg */ --color-background-80: 245, 245, 245; /* tertiary bg */ + + /* onboarding colors */ + --gradient-onboarding-100: linear-gradient(106deg, #f2f6ff 29.8%, #e1eaff 99.34%); + --gradient-onboarding-200: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%); + --gradient-onboarding-300: linear-gradient(164deg, #fff 4.25%, rgba(255, 255, 255, 0.06) 93.5%); + --gradient-onboarding-400: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%); + + --color-onboarding-text-100: 23, 23, 23; + --color-onboarding-text-200: 58, 58, 58; + --color-onboarding-text-300: 82, 82, 82; + --color-onboarding-text-400: 163, 163, 163; + + --color-onboarding-background-100: 236, 241, 255; + --color-onboarding-background-200: 255, 255, 255; + --color-onboarding-background-300: 236, 241, 255; + --color-onboarding-background-400: 177, 206, 250; + + --color-onboarding-border-100: 229, 229, 229; + --color-onboarding-border-200: 217, 228, 255; + --color-onboarding-border-300: 229, 229, 229, 0.5; + + --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1); } [data-theme="light"] { @@ -140,6 +162,27 @@ --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55); --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6); --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65); + + /* onboarding colors */ + --gradient-onboarding-100: linear-gradient(106deg, #18191b 25.17%, #18191b 99.34%); + --gradient-onboarding-200: linear-gradient(129deg, rgba(47, 49, 53, 0.8) -22.23%, rgba(33, 34, 37, 0.8) 62.98%); + --gradient-onboarding-300: linear-gradient(167deg, rgba(47, 49, 53, 0.45) 19.22%, #212225 98.48%); + + --color-onboarding-text-100: 237, 238, 240; + --color-onboarding-text-200: 176, 180, 187; + --color-onboarding-text-300: 118, 123, 132; + --color-onboarding-text-400: 105, 110, 119; + + --color-onboarding-background-100: 54, 58, 64; + --color-onboarding-background-200: 40, 42, 45; + --color-onboarding-background-300: 40, 42, 45; + --color-onboarding-background-400: 67, 72, 79; + + --color-onboarding-border-100: 54, 58, 64; + --color-onboarding-border-200: 54, 58, 64; + --color-onboarding-border-300: 34, 35, 38, 0.5; + + --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1); } [data-theme="dark"] { diff --git a/space/types/app.ts b/space/types/app.ts new file mode 100644 index 000000000..bd4af3b0c --- /dev/null +++ b/space/types/app.ts @@ -0,0 +1,14 @@ +export interface IAppConfig { + email_password_login: boolean; + file_size_limit: number; + google_client_id: string | null; + github_app_name: string | null; + github_client_id: string | null; + magic_login: boolean; + slack_client_id: string | null; + posthog_api_key: string | null; + posthog_host: string | null; + has_openai_configured: boolean; + has_unsplash_configured: boolean; + is_self_managed: boolean; +} diff --git a/space/types/auth.ts b/space/types/auth.ts new file mode 100644 index 000000000..b20116c90 --- /dev/null +++ b/space/types/auth.ts @@ -0,0 +1,26 @@ +export type TEmailCheckTypes = "magic_code" | "password"; + +export interface IEmailCheckData { + email: string; +} + +export interface IEmailCheckResponse { + is_password_autoset: boolean; + is_existing: boolean; +} + +export interface ILoginTokenResponse { + access_token: string; + refresh_token: string; +} + +export interface IMagicSignInData { + email: string; + key: string; + token: string; +} + +export interface IPasswordSignInData { + email: string; + password: string; +} diff --git a/space/types/user.ts b/space/types/user.ts index 8c6d5f681..d58827876 100644 --- a/space/types/user.ts +++ b/space/types/user.ts @@ -16,6 +16,13 @@ export interface IUser { last_name: string; mobile_number: string; role: string; + is_password_autoset: boolean; + onboarding_step: { + workspace_join?: boolean; + profile_complete?: boolean; + workspace_create?: boolean; + workspace_invite?: boolean; + }; token: string; updated_at: Date; username: string; diff --git a/web/components/account/sign-in-forms/create-password.tsx b/web/components/account/sign-in-forms/create-password.tsx index 5d6b7da61..5b8ef8dc0 100644 --- a/web/components/account/sign-in-forms/create-password.tsx +++ b/web/components/account/sign-in-forms/create-password.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; // services @@ -41,6 +41,7 @@ export const CreatePasswordForm: React.FC = (props) => { control, formState: { errors, isSubmitting, isValid }, handleSubmit, + setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -74,6 +75,10 @@ export const CreatePasswordForm: React.FC = (props) => { ); }; + useEffect(() => { + setFocus("password"); + }, [setFocus]); + return ( <>

diff --git a/web/components/account/sign-in-forms/email-form.tsx b/web/components/account/sign-in-forms/email-form.tsx index abbb7bc74..0e5cad315 100644 --- a/web/components/account/sign-in-forms/email-form.tsx +++ b/web/components/account/sign-in-forms/email-form.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; // services @@ -34,6 +34,7 @@ export const EmailForm: React.FC = (props) => { control, formState: { errors, isSubmitting, isValid }, handleSubmit, + setFocus, } = useForm({ defaultValues: { email: "", @@ -67,6 +68,10 @@ export const EmailForm: React.FC = (props) => { ); }; + useEffect(() => { + setFocus("email"); + }, [setFocus]); + return ( <>

diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index 5412948bc..949aa2d9c 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; @@ -48,6 +48,7 @@ export const PasswordForm: React.FC = (props) => { getValues, handleSubmit, setError, + setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -127,6 +128,10 @@ export const PasswordForm: React.FC = (props) => { .finally(() => setIsSendingUniqueCode(false)); }; + useEffect(() => { + setFocus("password"); + }, [setFocus]); + return ( <>

diff --git a/web/components/account/sign-in-forms/self-hosted-sign-in.tsx b/web/components/account/sign-in-forms/self-hosted-sign-in.tsx index e4001ab41..fb81c4613 100644 --- a/web/components/account/sign-in-forms/self-hosted-sign-in.tsx +++ b/web/components/account/sign-in-forms/self-hosted-sign-in.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; @@ -40,6 +40,7 @@ export const SelfHostedSignInForm: React.FC = (props) => { control, formState: { dirtyFields, errors, isSubmitting }, handleSubmit, + setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -69,6 +70,10 @@ export const SelfHostedSignInForm: React.FC = (props) => { ); }; + useEffect(() => { + setFocus("email"); + }, [setFocus]); + return ( <>

diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 51eb46845..83f503846 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { CornerDownLeft, XCircle } from "lucide-react"; @@ -64,6 +64,7 @@ export const UniqueCodeForm: React.FC = (props) => { getValues, handleSubmit, reset, + setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -146,6 +147,9 @@ export const UniqueCodeForm: React.FC = (props) => { const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; const hasEmailChanged = dirtyFields.email; + useEffect(() => { + setFocus("token"); + }, [setFocus]); return ( <>