feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

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

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

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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