feat: init
This commit is contained in:
39
apps/web/core/components/account/auth-forms/auth-banner.tsx
Normal file
39
apps/web/core/components/account/auth-forms/auth-banner.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
|
||||
type TAuthBanner = {
|
||||
bannerData: TAuthErrorInfo | undefined;
|
||||
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
|
||||
};
|
||||
|
||||
export const AuthBanner: FC<TAuthBanner> = (props) => {
|
||||
const { bannerData, handleBannerData } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!bannerData) return <></>;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10"
|
||||
>
|
||||
<div className="size-4 flex-shrink-0 grid place-items-center">
|
||||
<Info size={16} className="text-custom-primary-100" />
|
||||
</div>
|
||||
<p className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="relative ml-auto size-6 rounded-sm grid place-items-center transition-all hover:bg-custom-primary-100/20 text-custom-primary-100/80"
|
||||
onClick={() => handleBannerData?.(undefined)}
|
||||
aria-label={t("aria_labels.auth_forms.close_alert")}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
112
apps/web/core/components/account/auth-forms/auth-header.tsx
Normal file
112
apps/web/core/components/account/auth-forms/auth-header.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
// helpers
|
||||
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// services
|
||||
|
||||
type TAuthHeader = {
|
||||
workspaceSlug: string | undefined;
|
||||
invitationId: string | undefined;
|
||||
invitationEmail: string | undefined;
|
||||
authMode: EAuthModes;
|
||||
currentAuthStep: EAuthSteps;
|
||||
};
|
||||
|
||||
const Titles = {
|
||||
[EAuthModes.SIGN_IN]: {
|
||||
[EAuthSteps.EMAIL]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Welcome back to Plane.",
|
||||
},
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Welcome back to Plane.",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Welcome back to Plane.",
|
||||
},
|
||||
},
|
||||
[EAuthModes.SIGN_UP]: {
|
||||
[EAuthSteps.EMAIL]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Create your Plane account.",
|
||||
},
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Create your Plane account.",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Create your Plane account.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const workSpaceService = new WorkspaceService();
|
||||
|
||||
export const AuthHeader: FC<TAuthHeader> = observer((props) => {
|
||||
const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep } = props;
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: invitation, isLoading } = useSWR(
|
||||
workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null,
|
||||
async () => workspaceSlug && invitationId && workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
);
|
||||
|
||||
const getHeaderSubHeader = (
|
||||
step: EAuthSteps,
|
||||
mode: EAuthModes,
|
||||
invitation: IWorkspaceMemberInvitation | undefined,
|
||||
email: string | undefined
|
||||
) => {
|
||||
if (invitation && email && invitation.email === email && invitation.workspace) {
|
||||
const workspace = invitation.workspace;
|
||||
return {
|
||||
header: (
|
||||
<div className="relative inline-flex items-center gap-2">
|
||||
{t("common.join")}{" "}
|
||||
<WorkspaceLogo logo={workspace?.logo_url} name={workspace?.name} classNames="size-9 flex-shrink-0" />{" "}
|
||||
{workspace.name}
|
||||
</div>
|
||||
),
|
||||
subHeader:
|
||||
mode == EAuthModes.SIGN_UP
|
||||
? "Create an account to start managing work with your team."
|
||||
: "Log in to start managing work with your team.",
|
||||
};
|
||||
}
|
||||
|
||||
return Titles[mode][step];
|
||||
};
|
||||
|
||||
const { header, subHeader } = getHeaderSubHeader(currentAuthStep, authMode, invitation || undefined, invitationEmail);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xl font-semibold text-custom-text-100 leading-7">
|
||||
{typeof header === "string" ? t(header) : header}
|
||||
</span>
|
||||
<span className="text-2xl font-semibold text-custom-text-400 leading-7">{subHeader}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
178
apps/web/core/components/account/auth-forms/auth-root.tsx
Normal file
178
apps/web/core/components/account/auth-forms/auth-root.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { OAuthOptions } from "@plane/ui";
|
||||
// assets
|
||||
import GithubLightLogo from "/public/logos/github-black.png";
|
||||
import GithubDarkLogo from "/public/logos/github-dark.svg";
|
||||
import GitlabLogo from "/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "/public/logos/google-logo.svg";
|
||||
// helpers
|
||||
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
import {
|
||||
EAuthModes,
|
||||
EAuthSteps,
|
||||
EAuthenticationErrorCodes,
|
||||
EErrorAlertType,
|
||||
authErrorHandler,
|
||||
} from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
// local imports
|
||||
import { TermsAndConditions } from "../terms-and-conditions";
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import { AuthFormRoot } from "./form-root";
|
||||
|
||||
type TAuthRoot = {
|
||||
authMode: EAuthModes;
|
||||
};
|
||||
|
||||
export const AuthRoot: FC<TAuthRoot> = observer((props) => {
|
||||
//router
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const emailParam = searchParams.get("email");
|
||||
const invitation_id = searchParams.get("invitation_id");
|
||||
const workspaceSlug = searchParams.get("slug");
|
||||
const error_code = searchParams.get("error_code");
|
||||
const next_path = searchParams.get("next_path");
|
||||
const { resolvedTheme } = useTheme();
|
||||
// props
|
||||
const { authMode: currentAuthMode } = props;
|
||||
// states
|
||||
const [authMode, setAuthMode] = useState<EAuthModes | undefined>(undefined);
|
||||
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
|
||||
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
|
||||
// hooks
|
||||
const { config } = useInstance();
|
||||
|
||||
// derived values
|
||||
const isOAuthEnabled =
|
||||
(config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!authMode && currentAuthMode) setAuthMode(currentAuthMode);
|
||||
}, [currentAuthMode, authMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error_code && authMode) {
|
||||
const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes);
|
||||
if (errorhandler) {
|
||||
// password error handler
|
||||
if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP].includes(errorhandler.code)) {
|
||||
setAuthMode(EAuthModes.SIGN_UP);
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN].includes(errorhandler.code)) {
|
||||
setAuthMode(EAuthModes.SIGN_IN);
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
// magic_code error handler
|
||||
if (
|
||||
[
|
||||
EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP,
|
||||
EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP,
|
||||
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP,
|
||||
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP,
|
||||
].includes(errorhandler.code)
|
||||
) {
|
||||
setAuthMode(EAuthModes.SIGN_UP);
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
}
|
||||
if (
|
||||
[
|
||||
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,
|
||||
].includes(errorhandler.code)
|
||||
) {
|
||||
setAuthMode(EAuthModes.SIGN_IN);
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
}
|
||||
|
||||
setErrorInfo(errorhandler);
|
||||
}
|
||||
}
|
||||
}, [error_code, authMode]);
|
||||
|
||||
if (!authMode) return <></>;
|
||||
|
||||
const OauthButtonContent = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
|
||||
|
||||
const OAuthConfig = [
|
||||
{
|
||||
id: "google",
|
||||
text: `${OauthButtonContent} with Google`,
|
||||
icon: <Image src={GoogleLogo} height={18} width={18} alt="Google Logo" />,
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_google_enabled,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
text: `${OauthButtonContent} with GitHub`,
|
||||
icon: (
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? GithubDarkLogo : GithubLightLogo}
|
||||
height={18}
|
||||
width={18}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_github_enabled,
|
||||
},
|
||||
{
|
||||
id: "gitlab",
|
||||
text: `${OauthButtonContent} with GitLab`,
|
||||
icon: <Image src={GitlabLogo} height={18} width={18} alt="GitLab Logo" />,
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_gitlab_enabled,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
<AuthHeader
|
||||
workspaceSlug={workspaceSlug?.toString() || undefined}
|
||||
invitationId={invitation_id?.toString() || undefined}
|
||||
invitationEmail={email || undefined}
|
||||
authMode={authMode}
|
||||
currentAuthStep={authStep}
|
||||
/>
|
||||
|
||||
{isOAuthEnabled && <OAuthOptions options={OAuthConfig} compact={authStep === EAuthSteps.PASSWORD} />}
|
||||
|
||||
<AuthFormRoot
|
||||
authStep={authStep}
|
||||
authMode={authMode}
|
||||
email={email}
|
||||
setEmail={(email) => setEmail(email)}
|
||||
setAuthMode={(authMode) => setAuthMode(authMode)}
|
||||
setAuthStep={(authStep) => setAuthStep(authStep)}
|
||||
setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)}
|
||||
currentAuthMode={currentAuthMode}
|
||||
/>
|
||||
<TermsAndConditions authType={authMode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
export const FormContainer = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
export const AuthFormHeader = ({ title, description }: { title: string; description: string }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xl font-semibold text-custom-text-100">{title}</span>
|
||||
<span className="text-2xl font-semibold text-custom-text-400">{description}</span>
|
||||
</div>
|
||||
);
|
||||
104
apps/web/core/components/account/auth-forms/email.tsx
Normal file
104
apps/web/core/components/account/auth-forms/email.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, FormEvent } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { CircleAlert, XCircle } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IEmailCheckData } from "@plane/types";
|
||||
import { Input, Spinner } from "@plane/ui";
|
||||
import { cn, checkEmailValidity } from "@plane/utils";
|
||||
// helpers
|
||||
type TAuthEmailForm = {
|
||||
defaultEmail: string;
|
||||
onSubmit: (data: IEmailCheckData) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
const { onSubmit, defaultEmail } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [email, setEmail] = useState(defaultEmail);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
const emailError = useMemo(
|
||||
() => (email && !checkEmailValidity(email) ? { email: "auth.common.email.errors.invalid" } : undefined),
|
||||
[email]
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const payload: IEmailCheckData = {
|
||||
email: email,
|
||||
};
|
||||
await onSubmit(payload);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="text-sm text-custom-text-300 font-medium">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
`relative flex items-center rounded-md bg-custom-background-100 border`,
|
||||
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-custom-border-300`
|
||||
)}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t("aria_labels.auth_forms.clear_email")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<XCircle className="size-5 stroke-custom-text-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{emailError?.email && !isFocused && (
|
||||
<p className="flex items-center gap-1 text-xs text-red-600 px-0.5">
|
||||
<CircleAlert height={12} width={12} />
|
||||
{t(emailError.email)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { X } from "lucide-react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
||||
export const ForgotPasswordPopover = () => {
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<Popover.Button as={Fragment}>
|
||||
<button
|
||||
type="button"
|
||||
ref={setReferenceElement}
|
||||
className="text-xs font-medium text-custom-primary-100 outline-none"
|
||||
>
|
||||
{t("auth.common.forgot_password")}
|
||||
</button>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="fixed z-10">
|
||||
{({ close }) => (
|
||||
<div
|
||||
className="border border-custom-border-300 bg-custom-background-100 rounded z-10 py-1 px-2 w-64 break-words flex items-start gap-3 text-left ml-3"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<span className="flex-shrink-0">🤥</span>
|
||||
<p className="text-xs">{t("auth.forgot_password.errors.smtp_not_enabled")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-3 grid place-items-center"
|
||||
onClick={() => close()}
|
||||
aria-label={t("aria_labels.auth_forms.close_popover")}
|
||||
>
|
||||
<X className="size-3 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
148
apps/web/core/components/account/auth-forms/forgot-password.tsx
Normal file
148
apps/web/core/components/account/auth-forms/forgot-password.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// icons
|
||||
import { CircleCheck } from "lucide-react";
|
||||
// plane imports
|
||||
import { AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input } from "@plane/ui";
|
||||
import { cn, checkEmailValidity } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import useTimer from "@/hooks/use-timer";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local components
|
||||
import { FormContainer } from "./common/container";
|
||||
import { AuthFormHeader } from "./common/header";
|
||||
|
||||
type TForgotPasswordFormValues = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const defaultValues: TForgotPasswordFormValues = {
|
||||
email: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const ForgotPasswordForm = observer(() => {
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email");
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
|
||||
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
} = useForm<TForgotPasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email: email?.toString() ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleForgotPassword = async (formData: TForgotPasswordFormValues) => {
|
||||
await authService
|
||||
.sendResetPasswordLink({
|
||||
email: formData.email,
|
||||
})
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: AUTH_TRACKER_EVENTS.forgot_password,
|
||||
payload: {
|
||||
email: formData.email,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("auth.forgot_password.toast.success.title"),
|
||||
message: t("auth.forgot_password.toast.success.message"),
|
||||
});
|
||||
setResendCodeTimer(30);
|
||||
})
|
||||
.catch((err) => {
|
||||
captureError({
|
||||
eventName: AUTH_TRACKER_EVENTS.forgot_password,
|
||||
payload: {
|
||||
email: formData.email,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("auth.forgot_password.toast.error.title"),
|
||||
message: err?.error ?? t("auth.forgot_password.toast.error.message"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormContainer>
|
||||
<AuthFormHeader title="Reset password" description="Regain access to your account." />
|
||||
<form onSubmit={handleSubmit(handleForgotPassword)} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-custom-text-300" htmlFor="email">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: t("auth.common.email.errors.required"),
|
||||
validate: (value) => checkEmailValidity(value) || t("auth.common.email.errors.invalid"),
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
autoComplete="on"
|
||||
disabled={resendTimerCode > 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{resendTimerCode > 0 && (
|
||||
<p className="flex items-start w-full gap-1 px-1 text-xs font-medium text-green-700">
|
||||
<CircleCheck height={12} width={12} className="mt-0.5" />
|
||||
{t("auth.forgot_password.email_sent")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting || resendTimerCode > 0}
|
||||
>
|
||||
{resendTimerCode > 0
|
||||
? t("auth.common.resend_in", { seconds: resendTimerCode })
|
||||
: t("auth.forgot_password.send_reset_link")}
|
||||
</Button>
|
||||
<Link href="/" className={cn("w-full", getButtonStyling("link-neutral", "lg"))}>
|
||||
{t("auth.common.back_to_sign_in")}
|
||||
</Link>
|
||||
</form>
|
||||
</FormContainer>
|
||||
);
|
||||
});
|
||||
134
apps/web/core/components/account/auth-forms/form-root.tsx
Normal file
134
apps/web/core/components/account/auth-forms/form-root.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { EAuthModes, EAuthSteps } from "@plane/constants";
|
||||
import type { IEmailCheckData } from "@plane/types";
|
||||
// helpers
|
||||
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local components
|
||||
import { AuthEmailForm } from "./email";
|
||||
import { AuthPasswordForm } from "./password";
|
||||
import { AuthUniqueCodeForm } from "./unique-code";
|
||||
|
||||
type TAuthFormRoot = {
|
||||
authStep: EAuthSteps;
|
||||
authMode: EAuthModes;
|
||||
email: string;
|
||||
setEmail: (email: string) => void;
|
||||
setAuthMode: (authMode: EAuthModes) => void;
|
||||
setAuthStep: (authStep: EAuthSteps) => void;
|
||||
setErrorInfo: (errorInfo: TAuthErrorInfo | undefined) => void;
|
||||
currentAuthMode: EAuthModes;
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const AuthFormRoot = observer((props: TAuthFormRoot) => {
|
||||
const { authStep, authMode, email, setEmail, setAuthMode, setAuthStep, setErrorInfo, currentAuthMode } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// query params
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next_path");
|
||||
// states
|
||||
const [isExistingEmail, setIsExistingEmail] = useState(false);
|
||||
// hooks
|
||||
const { config } = useInstance();
|
||||
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
|
||||
// submit handler- email verification
|
||||
const handleEmailVerification = async (data: IEmailCheckData) => {
|
||||
setEmail(data.email);
|
||||
setErrorInfo(undefined);
|
||||
await authService
|
||||
.emailCheck(data)
|
||||
.then(async (response) => {
|
||||
if (response.existing) {
|
||||
if (currentAuthMode === EAuthModes.SIGN_UP) setAuthMode(EAuthModes.SIGN_IN);
|
||||
if (response.status === "MAGIC_CODE") {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
generateEmailUniqueCode(data.email);
|
||||
} else if (response.status === "CREDENTIAL") {
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
} else {
|
||||
if (currentAuthMode === EAuthModes.SIGN_IN) setAuthMode(EAuthModes.SIGN_UP);
|
||||
if (response.status === "MAGIC_CODE") {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
generateEmailUniqueCode(data.email);
|
||||
} else if (response.status === "CREDENTIAL") {
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
}
|
||||
setIsExistingEmail(response.existing);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined);
|
||||
if (errorhandler?.type) setErrorInfo(errorhandler);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmailClear = () => {
|
||||
setAuthMode(currentAuthMode);
|
||||
setErrorInfo(undefined);
|
||||
setEmail("");
|
||||
setAuthStep(EAuthSteps.EMAIL);
|
||||
router.push(currentAuthMode === EAuthModes.SIGN_IN ? `/` : "/sign-up");
|
||||
};
|
||||
|
||||
// generating the unique code
|
||||
const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => {
|
||||
if (!isSMTPConfigured) 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;
|
||||
});
|
||||
};
|
||||
|
||||
if (authStep === EAuthSteps.EMAIL) {
|
||||
return <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />;
|
||||
}
|
||||
if (authStep === EAuthSteps.UNIQUE_CODE) {
|
||||
return (
|
||||
<AuthUniqueCodeForm
|
||||
mode={authMode}
|
||||
email={email}
|
||||
isExistingEmail={isExistingEmail}
|
||||
handleEmailClear={handleEmailClear}
|
||||
generateEmailUniqueCode={generateEmailUniqueCode}
|
||||
nextPath={nextPath || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (authStep === EAuthSteps.PASSWORD) {
|
||||
return (
|
||||
<AuthPasswordForm
|
||||
mode={authMode}
|
||||
isSMTPConfigured={isSMTPConfigured}
|
||||
email={email}
|
||||
handleEmailClear={handleEmailClear}
|
||||
handleAuthStep={(step: EAuthSteps) => {
|
||||
if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email);
|
||||
setAuthStep(step);
|
||||
}}
|
||||
nextPath={nextPath || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
});
|
||||
1
apps/web/core/components/account/auth-forms/index.ts
Normal file
1
apps/web/core/components/account/auth-forms/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./auth-root";
|
||||
327
apps/web/core/components/account/auth-forms/password.tsx
Normal file
327
apps/web/core/components/account/auth-forms/password.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { Eye, EyeOff, Info, X, XCircle } from "lucide-react";
|
||||
// plane imports
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS, AUTH_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { ForgotPasswordPopover } from "@/components/account/auth-forms/forgot-password-popover";
|
||||
// constants
|
||||
// helpers
|
||||
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
isSMTPConfigured: boolean;
|
||||
mode: EAuthModes;
|
||||
handleEmailClear: () => void;
|
||||
handleAuthStep: (step: EAuthSteps) => void;
|
||||
nextPath: string | undefined;
|
||||
};
|
||||
|
||||
type TPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
const defaultValues: TPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode, nextPath } = props;
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// ref
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
// states
|
||||
const [csrfPromise, setCsrfPromise] = useState<Promise<{ csrf_token: string }> | undefined>(undefined);
|
||||
const [passwordFormData, setPasswordFormData] = useState<TPasswordFormValues>({ ...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);
|
||||
const [isBannerMessage, setBannerMessage] = useState(false);
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleFormChange = (key: keyof TPasswordFormValues, value: string) =>
|
||||
setPasswordFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfPromise === undefined) {
|
||||
const promise = authService.requestCSRFToken();
|
||||
setCsrfPromise(promise);
|
||||
}
|
||||
}, [csrfPromise]);
|
||||
|
||||
const redirectToUniqueCodeSignIn = async () => {
|
||||
handleAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
};
|
||||
|
||||
const passwordSupport =
|
||||
mode === EAuthModes.SIGN_IN ? (
|
||||
<div className="w-full">
|
||||
{isSMTPConfigured ? (
|
||||
<Link
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.FORGOT_PASSWORD_FROM_SIGNIN}
|
||||
href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`}
|
||||
className="text-xs font-medium text-custom-primary-100"
|
||||
>
|
||||
{t("auth.common.forgot_password")}
|
||||
</Link>
|
||||
) : (
|
||||
<ForgotPasswordPopover />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
passwordFormData.password.length > 0 &&
|
||||
getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
|
||||
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
|
||||
)
|
||||
);
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!isSubmitting &&
|
||||
!!passwordFormData.password &&
|
||||
(mode === EAuthModes.SIGN_UP ? passwordFormData.password === passwordFormData.confirm_password : true)
|
||||
? false
|
||||
: true,
|
||||
[isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password]
|
||||
);
|
||||
|
||||
const password = passwordFormData?.password ?? "";
|
||||
const confirmPassword = passwordFormData?.confirm_password ?? "";
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
const handleCSRFToken = async () => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
const token = await csrfPromise;
|
||||
if (!token?.csrf_token) return;
|
||||
const csrfElement = formRef.current.querySelector("input[name=csrfmiddlewaretoken]");
|
||||
csrfElement?.setAttribute("value", token?.csrf_token);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isBannerMessage && mode === EAuthModes.SIGN_UP && (
|
||||
<div className="relative flex items-center p-2 rounded-md gap-2 border border-red-500/50 bg-red-500/10">
|
||||
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
|
||||
<Info size={16} className="text-red-500" />
|
||||
</div>
|
||||
<div className="w-full text-sm font-medium text-red-500">{t("auth.sign_up.errors.password.strength")}</div>
|
||||
<div
|
||||
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-red-500/20 text-custom-primary-100/80"
|
||||
onClick={() => setBannerMessage(false)}
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
ref={formRef}
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/${mode === EAuthModes.SIGN_IN ? "sign-in" : "sign-up"}/`}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault(); // Prevent form from submitting by default
|
||||
await handleCSRFToken();
|
||||
const isPasswordValid =
|
||||
mode === EAuthModes.SIGN_UP
|
||||
? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID
|
||||
: true;
|
||||
if (isPasswordValid) {
|
||||
setIsSubmitting(true);
|
||||
captureSuccess({
|
||||
eventName:
|
||||
mode === EAuthModes.SIGN_IN
|
||||
? AUTH_TRACKER_EVENTS.sign_in_with_password
|
||||
: AUTH_TRACKER_EVENTS.sign_up_with_password,
|
||||
payload: {
|
||||
email: passwordFormData.email,
|
||||
},
|
||||
});
|
||||
if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met
|
||||
} else {
|
||||
setBannerMessage(true);
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
setIsSubmitting(false);
|
||||
captureError({
|
||||
eventName:
|
||||
mode === EAuthModes.SIGN_IN
|
||||
? AUTH_TRACKER_EVENTS.sign_in_with_password
|
||||
: AUTH_TRACKER_EVENTS.sign_up_with_password,
|
||||
payload: {
|
||||
email: passwordFormData.email,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" />
|
||||
<input type="hidden" value={passwordFormData.email} name="email" />
|
||||
{nextPath && <input type="hidden" value={nextPath} name="next_path" />}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="text-sm font-medium text-custom-text-300">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div
|
||||
className={`relative flex items-center rounded-md bg-custom-background-100 border border-custom-border-300`}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={passwordFormData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 border-0`}
|
||||
disabled
|
||||
/>
|
||||
{passwordFormData.email.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 size-5"
|
||||
onClick={handleEmailClear}
|
||||
aria-label={t("aria_labels.auth_forms.clear_email")}
|
||||
>
|
||||
<XCircle className="size-5 stroke-custom-text-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="text-sm text-custom-text-300 font-medium">
|
||||
{mode === EAuthModes.SIGN_IN ? t("auth.common.password.label") : t("auth.common.password.set_password")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword?.password ? "text" : "password"}
|
||||
id="password"
|
||||
name="password"
|
||||
value={passwordFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
placeholder={t("auth.common.password.placeholder")}
|
||||
className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t(
|
||||
showPassword?.password ? "aria_labels.auth_forms.hide_password" : "aria_labels.auth_forms.show_password"
|
||||
)}
|
||||
>
|
||||
{showPassword?.password ? (
|
||||
<EyeOff className="size-5 stroke-custom-text-400" />
|
||||
) : (
|
||||
<Eye className="size-5 stroke-custom-text-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{passwordSupport}
|
||||
</div>
|
||||
|
||||
{mode === EAuthModes.SIGN_UP && (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="confirm-password" className="text-sm text-custom-text-300 font-medium">
|
||||
{t("auth.common.password.confirm_password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword?.retypePassword ? "text" : "password"}
|
||||
id="confirm-password"
|
||||
name="confirm_password"
|
||||
value={passwordFormData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t(
|
||||
showPassword?.retypePassword
|
||||
? "aria_labels.auth_forms.hide_password"
|
||||
: "aria_labels.auth_forms.show_password"
|
||||
)}
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
{showPassword?.retypePassword ? (
|
||||
<EyeOff className="size-5 stroke-custom-text-400" />
|
||||
) : (
|
||||
<Eye className="size-5 stroke-custom-text-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!!passwordFormData.confirm_password &&
|
||||
passwordFormData.password !== passwordFormData.confirm_password &&
|
||||
renderPasswordMatchError && (
|
||||
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{mode === EAuthModes.SIGN_IN ? (
|
||||
<>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? (
|
||||
<Spinner height="20px" width="20px" />
|
||||
) : isSMTPConfigured ? (
|
||||
t("common.continue")
|
||||
) : (
|
||||
t("common.go_to_workspace")
|
||||
)}
|
||||
</Button>
|
||||
{isSMTPConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.SIGN_IN_WITH_UNIQUE_CODE}
|
||||
onClick={redirectToUniqueCodeSignIn}
|
||||
variant="outline-primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{t("auth.common.sign_in_with_unique_code")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Create account"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
});
|
||||
199
apps/web/core/components/account/auth-forms/reset-password.tsx
Normal file
199
apps/web/core/components/account/auth-forms/reset-password.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// ui
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Input, PasswordStrengthIndicator } from "@plane/ui";
|
||||
// components
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// helpers
|
||||
import type { EAuthenticationErrorCodes, TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
import { EErrorAlertType, authErrorHandler } from "@/helpers/authentication.helper";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local imports
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { FormContainer } from "./common/container";
|
||||
import { AuthFormHeader } from "./common/header";
|
||||
|
||||
type TResetPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
const defaultValues: TResetPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const ResetPasswordForm = observer(() => {
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const uidb64 = searchParams.get("uidb64");
|
||||
const token = searchParams.get("token");
|
||||
const email = searchParams.get("email");
|
||||
const error_code = searchParams.get("error_code");
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState({
|
||||
password: false,
|
||||
retypePassword: false,
|
||||
});
|
||||
const [resetFormData, setResetFormData] = useState<TResetPasswordFormValues>({
|
||||
...defaultValues,
|
||||
email: email ? email.toString() : "",
|
||||
});
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) =>
|
||||
setResetFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!!resetFormData.password &&
|
||||
getPasswordStrength(resetFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID &&
|
||||
resetFormData.password === resetFormData.confirm_password
|
||||
? false
|
||||
: true,
|
||||
[resetFormData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error_code) {
|
||||
const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes);
|
||||
if (errorhandler) {
|
||||
setErrorInfo(errorhandler);
|
||||
}
|
||||
}
|
||||
}, [error_code]);
|
||||
|
||||
const password = resetFormData?.password ?? "";
|
||||
const confirmPassword = resetFormData?.confirm_password ?? "";
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<FormContainer>
|
||||
<AuthFormHeader title="Reset password" description="Create a new password." />
|
||||
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/reset-password/${uidb64?.toString()}/${token?.toString()}/`}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={resetFormData.email}
|
||||
//hasError={Boolean(errors.email)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 text-custom-text-400 cursor-not-allowed"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
|
||||
{t("auth.common.password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
name="password"
|
||||
value={resetFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
//hasError={Boolean(errors.password)}
|
||||
placeholder={t("auth.common.password.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
minLength={8}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PasswordStrengthIndicator password={resetFormData.password} isFocused={isPasswordInputFocused} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="confirm_password">
|
||||
{t("auth.common.password.confirm_password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword.retypePassword ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
value={resetFormData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!resetFormData.confirm_password &&
|
||||
resetFormData.password !== resetFormData.confirm_password &&
|
||||
renderPasswordMatchError && (
|
||||
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{t("auth.common.password.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormContainer>
|
||||
);
|
||||
});
|
||||
216
apps/web/core/components/account/auth-forms/set-password.tsx
Normal file
216
apps/web/core/components/account/auth-forms/set-password.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane imports
|
||||
import { AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input, PasswordStrengthIndicator } from "@plane/ui";
|
||||
// components
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureError, captureSuccess, captureView } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local components
|
||||
import { FormContainer } from "./common/container";
|
||||
import { AuthFormHeader } from "./common/header";
|
||||
|
||||
type TResetPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
const defaultValues: TResetPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const SetPasswordForm = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email");
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState({
|
||||
password: false,
|
||||
retypePassword: false,
|
||||
});
|
||||
const [passwordFormData, setPasswordFormData] = useState<TResetPasswordFormValues>({
|
||||
...defaultValues,
|
||||
email: email ? email.toString() : "",
|
||||
});
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { data: user, handleSetPassword } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
captureView({
|
||||
elementName: AUTH_TRACKER_ELEMENTS.SET_PASSWORD_FORM,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) =>
|
||||
setPasswordFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!!passwordFormData.password &&
|
||||
getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID &&
|
||||
passwordFormData.password === passwordFormData.confirm_password
|
||||
? false
|
||||
: true,
|
||||
[passwordFormData]
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
if (!csrfToken) throw new Error("csrf token not found");
|
||||
await handleSetPassword(csrfToken, { password: passwordFormData.password });
|
||||
captureSuccess({
|
||||
eventName: AUTH_TRACKER_EVENTS.password_created,
|
||||
});
|
||||
router.push("/");
|
||||
} catch (error: unknown) {
|
||||
let message = undefined;
|
||||
if (error instanceof Error) {
|
||||
const err = error as Error & { error?: string };
|
||||
message = err.error;
|
||||
}
|
||||
captureError({
|
||||
eventName: AUTH_TRACKER_EVENTS.password_created,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("common.errors.default.title"),
|
||||
message: message ?? t("common.errors.default.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const password = passwordFormData?.password ?? "";
|
||||
const confirmPassword = passwordFormData?.confirm_password ?? "";
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<FormContainer>
|
||||
<AuthFormHeader title="Set password" description="Create a new password." />
|
||||
<form className="space-y-4" onSubmit={(e) => handleSubmit(e)}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={user?.email}
|
||||
//hasError={Boolean(errors.email)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 text-custom-text-400 cursor-not-allowed"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
|
||||
{t("auth.common.password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
name="password"
|
||||
value={passwordFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
//hasError={Boolean(errors.password)}
|
||||
placeholder={t("auth.common.password.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
minLength={8}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="confirm_password">
|
||||
{t("auth.common.password.confirm_password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword.retypePassword ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
value={passwordFormData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!passwordFormData.confirm_password &&
|
||||
passwordFormData.password !== passwordFormData.confirm_password &&
|
||||
renderPasswordMatchError && (
|
||||
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormContainer>
|
||||
);
|
||||
});
|
||||
208
apps/web/core/components/account/auth-forms/unique-code.tsx
Normal file
208
apps/web/core/components/account/auth-forms/unique-code.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CircleCheck, XCircle } from "lucide-react";
|
||||
import { API_BASE_URL, AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Input, Spinner } from "@plane/ui";
|
||||
// constants
|
||||
// helpers
|
||||
import { EAuthModes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import useTimer from "@/hooks/use-timer";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
type TAuthUniqueCodeForm = {
|
||||
mode: EAuthModes;
|
||||
email: string;
|
||||
isExistingEmail: boolean;
|
||||
handleEmailClear: () => void;
|
||||
generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>;
|
||||
nextPath: string | undefined;
|
||||
};
|
||||
|
||||
type TUniqueCodeFormValues = {
|
||||
email: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
const defaultValues: TUniqueCodeFormValues = {
|
||||
email: "",
|
||||
code: "",
|
||||
};
|
||||
|
||||
export const AuthUniqueCodeForm: React.FC<TAuthUniqueCodeForm> = (props) => {
|
||||
const { mode, email, handleEmailClear, generateEmailUniqueCode, isExistingEmail, nextPath } = props;
|
||||
// derived values
|
||||
const defaultResetTimerValue = 5;
|
||||
// states
|
||||
const [uniqueCodeFormData, setUniqueCodeFormData] = useState<TUniqueCodeFormValues>({ ...defaultValues, email });
|
||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) =>
|
||||
setUniqueCodeFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const generateNewCode = async (email: string) => {
|
||||
try {
|
||||
setIsRequestingNewCode(true);
|
||||
const uniqueCode = await generateEmailUniqueCode(email);
|
||||
setResendCodeTimer(defaultResetTimerValue);
|
||||
handleFormChange("code", uniqueCode?.code || "");
|
||||
setIsRequestingNewCode(false);
|
||||
captureSuccess({
|
||||
eventName: AUTH_TRACKER_EVENTS.new_code_requested,
|
||||
payload: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setResendCodeTimer(0);
|
||||
console.error("Error while requesting new code");
|
||||
setIsRequestingNewCode(false);
|
||||
captureError({
|
||||
eventName: AUTH_TRACKER_EVENTS.new_code_requested,
|
||||
payload: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
|
||||
const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/${mode === EAuthModes.SIGN_IN ? "magic-sign-in" : "magic-sign-up"}/`}
|
||||
onSubmit={() => {
|
||||
setIsSubmitting(true);
|
||||
captureSuccess({
|
||||
eventName: AUTH_TRACKER_EVENTS.code_verify,
|
||||
payload: {
|
||||
state: "SUCCESS",
|
||||
first_time: !isExistingEmail,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onError={() => {
|
||||
setIsSubmitting(false);
|
||||
captureError({
|
||||
eventName: AUTH_TRACKER_EVENTS.code_verify,
|
||||
payload: {
|
||||
state: "FAILED",
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" value={uniqueCodeFormData.email} name="email" />
|
||||
{nextPath && <input type="hidden" value={nextPath} name="next_path" />}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="text-sm font-medium text-custom-text-300">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div
|
||||
className={`relative flex items-center rounded-md bg-custom-background-100 border border-custom-border-300`}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={uniqueCodeFormData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className="disable-autofill-style h-10 w-full placeholder:text-custom-text-400 border-0"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
{uniqueCodeFormData.email.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t("aria_labels.auth_forms.clear_email")}
|
||||
onClick={handleEmailClear}
|
||||
>
|
||||
<XCircle className="size-5 stroke-custom-text-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="unique-code" className="text-sm font-medium text-custom-text-300">
|
||||
{t("auth.common.unique_code.label")}
|
||||
</label>
|
||||
<Input
|
||||
name="code"
|
||||
id="unique-code"
|
||||
value={uniqueCodeFormData.code}
|
||||
onChange={(e) => handleFormChange("code", e.target.value)}
|
||||
placeholder={t("auth.common.unique_code.placeholder")}
|
||||
className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
<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} />
|
||||
{t("auth.common.unique_code.paste_code")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.REQUEST_NEW_CODE}
|
||||
onClick={() => generateNewCode(uniqueCodeFormData.email)}
|
||||
className={
|
||||
isRequestNewCodeDisabled
|
||||
? "text-custom-text-400"
|
||||
: "font-medium text-custom-primary-300 hover:text-custom-primary-200"
|
||||
}
|
||||
disabled={isRequestNewCodeDisabled}
|
||||
>
|
||||
{resendTimerCode > 0
|
||||
? t("auth.common.resend_in", { seconds: resendTimerCode })
|
||||
: isRequestingNewCode
|
||||
? t("auth.common.unique_code.requesting_new_code")
|
||||
: t("common.resend")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isButtonDisabled}
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.VERIFY_CODE}
|
||||
>
|
||||
{isRequestingNewCode ? (
|
||||
t("auth.common.unique_code.sending_code")
|
||||
) : isSubmitting ? (
|
||||
<Spinner height="20px" width="20px" />
|
||||
) : (
|
||||
t("common.continue")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
128
apps/web/core/components/account/deactivate-account-modal.tsx
Normal file
128
apps/web/core/components/account/deactivate-account-modal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DeactivateAccountModal: React.FC<Props> = (props) => {
|
||||
const router = useAppRouter();
|
||||
const { isOpen, onClose } = props;
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const { deactivateAccount, signOut } = useUser();
|
||||
|
||||
// states
|
||||
const [isDeactivating, setIsDeactivating] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDeactivating(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
setIsDeactivating(true);
|
||||
|
||||
await deactivateAccount()
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Account deactivated successfully.",
|
||||
});
|
||||
signOut();
|
||||
router.push("/");
|
||||
handleClose();
|
||||
})
|
||||
.catch((err: any) => {
|
||||
captureError({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error,
|
||||
});
|
||||
})
|
||||
.finally(() => setIsDeactivating(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="">
|
||||
<div className="flex items-start gap-x-4">
|
||||
<div className="mt-3 grid place-items-center rounded-full bg-red-500/20 p-2 sm:mt-3 sm:p-2 md:mt-0 md:p-4 lg:mt-0 lg:p-4 ">
|
||||
<Trash2
|
||||
className="h-4 w-4 text-red-600 sm:h-4 sm:w-4 md:h-6 md:w-6 lg:h-6 lg:w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100">
|
||||
{t("deactivate_your_account")}
|
||||
</Dialog.Title>
|
||||
<p className="mt-6 list-disc pr-4 text-base font-normal text-custom-text-200">
|
||||
{t("deactivate_your_account_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-end gap-2 p-4 sm:px-6">
|
||||
<Button variant="neutral-primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDeleteAccount}>
|
||||
{isDeactivating ? t("deactivating") : t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
35
apps/web/core/components/account/terms-and-conditions.tsx
Normal file
35
apps/web/core/components/account/terms-and-conditions.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { EAuthModes } from "@plane/constants";
|
||||
|
||||
interface TermsAndConditionsProps {
|
||||
authType?: EAuthModes;
|
||||
}
|
||||
|
||||
// Constants for better maintainability
|
||||
const LEGAL_LINKS = {
|
||||
termsOfService: "https://plane.so/legals/terms-and-conditions",
|
||||
privacyPolicy: "https://plane.so/legals/privacy-policy",
|
||||
} as const;
|
||||
|
||||
const MESSAGES = {
|
||||
[EAuthModes.SIGN_UP]: "By creating an account",
|
||||
[EAuthModes.SIGN_IN]: "By signing in",
|
||||
} as const;
|
||||
|
||||
// Reusable link component to reduce duplication
|
||||
const LegalLink: React.FC<{ href: string; children: React.ReactNode }> = ({ href, children }) => (
|
||||
<Link href={href} className="text-custom-text-200" target="_blank" rel="noopener noreferrer">
|
||||
<span className="text-sm font-medium underline hover:cursor-pointer">{children}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export const TermsAndConditions: React.FC<TermsAndConditionsProps> = ({ authType = EAuthModes.SIGN_IN }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<p className="text-center text-sm text-custom-text-300 whitespace-pre-line">
|
||||
{`${MESSAGES[authType]}, you understand and agree to \n our `}
|
||||
<LegalLink href={LEGAL_LINKS.termsOfService}>Terms of Service</LegalLink> and{" "}
|
||||
<LegalLink href={LEGAL_LINKS.privacyPolicy}>Privacy Policy</LegalLink>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user