snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
// services
import { WorkspaceService } from "@/services/workspace.service";
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 = observer(function AuthHeader(props: TAuthHeader) {
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 <AuthHeaderBase subHeader={subHeader} header={header} />;
});
type TAuthHeaderBase = {
header: React.ReactNode;
subHeader: string;
};
export function AuthHeaderBase(props: TAuthHeaderBase) {
return (
<div className="flex flex-col gap-1">
<span className="text-h4-semibold text-primary">{props.header}</span>
<span className="text-h4-semibold text-placeholder">{props.subHeader}</span>
</div>
);
}

View File

@@ -0,0 +1,164 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// plane imports
import { Banner } from "@makeplane/propel/components/banner";
import { OAuthOptions } from "@plane/ui";
// helpers
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
import {
EAuthModes,
EAuthSteps,
EAuthenticationErrorCodes,
EErrorAlertType,
authErrorHandler,
} from "@/helpers/authentication.helper";
// hooks
import { useOAuthConfig } from "@/hooks/oauth";
import { useInstance } from "@/hooks/store/use-instance";
// local imports
import { TermsAndConditions } from "../terms-and-conditions";
import { AuthHeader, AuthHeaderBase } from "./auth-header";
import { AuthFormRoot } from "./form-root";
type TAuthRoot = {
authMode: EAuthModes;
};
export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
//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");
// 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);
// store hooks
const { config } = useInstance();
// derived values
const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText);
const isEmailBasedAuthEnabled = config?.is_email_password_enabled || config?.is_magic_login_enabled;
const noAuthMethodsAvailable = !isOAuthEnabled && !isEmailBasedAuthEnabled;
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 <></>;
if (noAuthMethodsAvailable) {
return (
<AuthContainer>
<AuthHeaderBase
header="No authentication methods available"
subHeader="Please contact your administrator to enable authentication for your instance."
/>
</AuthContainer>
);
}
return (
<AuthContainer>
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
<Banner
placement="inline"
variant="accent"
role="alert"
description={errorInfo.message}
onDismiss={() => setErrorInfo(undefined)}
/>
)}
<AuthHeader
workspaceSlug={workspaceSlug?.toString() || undefined}
invitationId={invitation_id?.toString() || undefined}
invitationEmail={email || undefined}
authMode={authMode}
currentAuthStep={authStep}
/>
{isOAuthEnabled && (
<OAuthOptions
options={oAuthOptions}
compact={authStep === EAuthSteps.PASSWORD}
showDivider={isEmailBasedAuthEnabled}
/>
)}
{isEmailBasedAuthEnabled && (
<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} />
</AuthContainer>
);
});
function AuthContainer({ children }: { children: React.ReactNode }) {
return (
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export function FormContainer({ children }: { children: React.ReactNode }) {
return (
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export function AuthFormHeader({ title, description }: { title: string; description: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-20 font-semibold text-primary">{title}</span>
<span className="text-20 font-semibold text-placeholder">{description}</span>
</div>
);
}

View File

@@ -0,0 +1,109 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FormEvent } from "react";
import { useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
// icons
import { CloseCircleOutline, WarningCircleOutline } from "@makeplane/propel/icons";
// plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IEmailCheckData } from "@plane/types";
import { Spinner } from "@plane/ui";
import { checkEmailValidity } from "@plane/utils";
// helpers
type TAuthEmailForm = {
defaultEmail: string;
onSubmit: (data: IEmailCheckData) => Promise<void>;
};
export const AuthEmailForm = observer(function AuthEmailForm(props: TAuthEmailForm) {
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-13 font-medium text-tertiary">
{t("auth.common.email.label")}
</label>
<Field name="email" invalid={!isFocused && Boolean(emailError?.email)}>
<InputGroup
size="2xl"
onFocus={() => {
setIsFocused(true);
}}
onBlur={() => {
setIsFocused(false);
}}
>
<Input
size="2xl"
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("auth.common.email.placeholder")}
autoComplete="off"
autoFocus
ref={inputRef}
/>
{email.length > 0 && (
<button
type="button"
onClick={() => {
setEmail("");
inputRef.current?.focus();
}}
className="grid size-5 place-items-center"
aria-label={t("aria_labels.auth_forms.clear_email")}
tabIndex={-1}
>
<CloseCircleOutline className="size-5 text-placeholder" />
</button>
)}
</InputGroup>
</Field>
{emailError?.email && !isFocused && (
<p className="flex items-center gap-1 px-0.5 text-11 text-danger-primary">
<WarningCircleOutline height={12} width={12} />
{t(emailError.email)}
</p>
)}
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : t("common.continue")}
</Button>
</form>
);
});

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { Popover } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { CloseOutline } from "@makeplane/propel/icons";
export function 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-11 font-medium text-accent-primary outline-none"
>
{t("auth.common.forgot_password")}
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
{({ close }) => (
<div
className="z-10 ml-3 flex w-64 items-start gap-3 rounded-sm border border-strong bg-surface-1 px-2 py-1 text-left break-words"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<span className="flex-shrink-0">🤥</span>
<p className="text-11">{t("auth.forgot_password.errors.smtp_not_enabled")}</p>
<button
type="button"
className="grid size-3 flex-shrink-0 place-items-center"
onClick={() => close()}
aria-label={t("aria_labels.auth_forms.close_popover")}
>
<CloseOutline className="size-3 text-secondary" />
</button>
</div>
)}
</Popover.Panel>
</Popover>
);
}

View File

@@ -0,0 +1,142 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// icons
import { TickCircleOutline } from "@makeplane/propel/icons";
// plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { useTranslation } from "@plane/i18n";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { cn, checkEmailValidity } from "@plane/utils";
// 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(function ForgotPasswordForm() {
// 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(() => {
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) => {
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-13 font-medium text-tertiary" 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 } }) => (
<Field name="email" invalid={Boolean(errors.email)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
placeholder={t("auth.common.email.placeholder")}
autoComplete="off"
disabled={resendTimerCode > 0}
/>
</InputGroup>
</Field>
)}
/>
{resendTimerCode > 0 && (
<p className="flex w-full items-start gap-1 px-1 text-11 font-medium text-success-primary">
<TickCircleOutline 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="xl"
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", "lg"))}>
{t("auth.common.back_to_sign_in")}
</Link>
</form>
</FormContainer>
);
});

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function AuthFormRoot(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,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./auth-root";

View File

@@ -0,0 +1,304 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { CloseCircleOutline, HideOutline, ShowOutline } from "@makeplane/propel/icons";
// plane imports
import { Banner } from "@makeplane/propel/components/banner";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { 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";
// 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 = observer(function AuthPasswordForm(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
href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`}
className="text-11 font-medium text-accent-primary"
>
{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 && (
<Banner
placement="inline"
variant="danger"
description={t("auth.sign_up.errors.password.strength")}
onDismiss={() => setBannerMessage(false)}
/>
)}
<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);
if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met
} else {
setBannerMessage(true);
}
}}
onError={() => {
setIsSubmitting(false);
}}
>
<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-13 font-medium text-tertiary">
{t("auth.common.email.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
id="email"
name="email"
type="email"
value={passwordFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder={t("auth.common.email.placeholder")}
disabled
/>
{passwordFormData.email.length > 0 && (
<button
type="button"
className="grid size-5 place-items-center"
onClick={handleEmailClear}
aria-label={t("aria_labels.auth_forms.clear_email")}
>
<CloseCircleOutline className="size-5 text-placeholder" />
</button>
)}
</InputGroup>
</div>
<div className="space-y-1">
<label htmlFor="password" className="text-13 font-medium text-tertiary">
{mode === EAuthModes.SIGN_IN ? t("auth.common.password.label") : t("auth.common.password.set_password")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
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")}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="off"
autoFocus
/>
<button
type="button"
onClick={() => handleShowPassword("password")}
className="grid size-5 place-items-center"
aria-label={t(
showPassword?.password ? "aria_labels.auth_forms.hide_password" : "aria_labels.auth_forms.show_password"
)}
>
{showPassword?.password ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
{passwordSupport}
</div>
{mode === EAuthModes.SIGN_UP && (
<div className="space-y-1">
<label htmlFor="confirm-password" className="text-13 font-medium text-tertiary">
{t("auth.common.password.confirm_password.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
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")}
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="off"
/>
<button
type="button"
className="grid size-5 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 ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
{!!passwordFormData.confirm_password &&
passwordFormData.password !== passwordFormData.confirm_password &&
renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{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="xl" disabled={isButtonDisabled}>
{isSubmitting ? (
<Spinner height="20px" width="20px" />
) : isSMTPConfigured ? (
t("common.continue")
) : (
t("common.go_to_workspace")
)}
</Button>
{isSMTPConfigured && (
<Button
type="button"
onClick={redirectToUniqueCodeSignIn}
variant="secondary"
className="w-full"
size="xl"
>
{t("auth.common.sign_in_with_unique_code")}
</Button>
)}
</>
) : (
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Create account"}
</Button>
)}
</div>
</form>
</>
);
});

View File

@@ -0,0 +1,211 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// icons
import { HideOutline, ShowOutline } from "@makeplane/propel/icons";
// ui
import { Banner } from "@makeplane/propel/components/banner";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { 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 { 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(function ResetPasswordForm() {
// 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 && (
<Banner
placement="inline"
variant="accent"
role="alert"
description={errorInfo.message}
onDismiss={() => setErrorInfo(undefined)}
/>
)}
<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-13 font-medium text-tertiary" htmlFor="email">
{t("auth.common.email.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
id="email"
name="email"
type="email"
value={resetFormData.email}
placeholder={t("auth.common.email.placeholder")}
autoComplete="off"
disabled
/>
</InputGroup>
</div>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password">
{t("auth.common.password.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
type={showPassword.password ? "text" : "password"}
name="password"
id="password"
value={resetFormData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
placeholder={t("auth.common.password.placeholder")}
minLength={8}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="new-password"
autoFocus
/>
<button
type="button"
onClick={() => handleShowPassword("password")}
className="grid size-5 place-items-center"
>
{showPassword.password ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
<PasswordStrengthIndicator password={resetFormData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="confirm_password">
{t("auth.common.password.confirm_password.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
type={showPassword.retypePassword ? "text" : "password"}
name="confirm_password"
id="confirm_password"
value={resetFormData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder={t("auth.common.password.confirm_password.placeholder")}
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => handleShowPassword("retypePassword")}
className="grid size-5 place-items-center"
>
{showPassword.retypePassword ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
{!!resetFormData.confirm_password &&
resetFormData.password !== resetFormData.confirm_password &&
renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
)}
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={isButtonDisabled}>
{t("auth.common.password.submit")}
</Button>
</form>
</FormContainer>
);
});

View File

@@ -0,0 +1,208 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FormEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// icons
import { HideOutline, ShowOutline } from "@makeplane/propel/icons";
// plane imports
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { 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 { PasswordStrengthIndicator } from "@plane/ui";
// components
import { getPasswordStrength } from "@plane/utils";
// 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(function SetPasswordForm() {
// 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(() => {
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 });
router.push("/");
} catch (error: unknown) {
let message = undefined;
if (error instanceof Error) {
const err = error as Error & { error?: string };
message = err.error;
}
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-13 font-medium text-tertiary" htmlFor="email">
{t("auth.common.email.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
id="email"
name="email"
type="email"
value={user?.email}
placeholder={t("auth.common.email.placeholder")}
autoComplete="off"
disabled
/>
</InputGroup>
</div>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password">
{t("auth.common.password.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
type={showPassword.password ? "text" : "password"}
name="password"
id="password"
value={passwordFormData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
placeholder={t("auth.common.password.placeholder")}
minLength={8}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="new-password"
autoFocus
/>
<button
type="button"
onClick={() => handleShowPassword("password")}
className="grid size-5 place-items-center"
>
{showPassword.password ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="confirm_password">
{t("auth.common.password.confirm_password.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
type={showPassword.retypePassword ? "text" : "password"}
name="confirm_password"
id="confirm_password"
value={passwordFormData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder={t("auth.common.password.confirm_password.placeholder")}
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => handleShowPassword("retypePassword")}
className="grid size-5 place-items-center"
>
{showPassword.retypePassword ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
{!!passwordFormData.confirm_password &&
passwordFormData.password !== passwordFormData.confirm_password &&
renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
)}
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={isButtonDisabled}>
{t("common.continue")}
</Button>
</form>
</FormContainer>
);
});

View File

@@ -0,0 +1,180 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { CloseCircleOutline, TickCircleOutline } from "@makeplane/propel/icons";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { API_BASE_URL } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Spinner } from "@plane/ui";
// constants
// helpers
import { EAuthModes } from "@/helpers/authentication.helper";
// hooks
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 function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) {
const { mode, email, handleEmailClear, generateEmailUniqueCode, 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);
} catch {
setResendCodeTimer(0);
console.error("Error while requesting new code");
setIsRequestingNewCode(false);
}
};
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);
}}
onError={() => {
setIsSubmitting(false);
}}
>
<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-13 font-medium text-tertiary">
{t("auth.common.email.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
id="email"
name="email"
type="email"
value={uniqueCodeFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder={t("auth.common.email.placeholder")}
autoComplete="off"
disabled
/>
{uniqueCodeFormData.email.length > 0 && (
<button
type="button"
className="grid size-5 place-items-center"
aria-label={t("aria_labels.auth_forms.clear_email")}
onClick={handleEmailClear}
>
<CloseCircleOutline className="size-5 text-placeholder" />
</button>
)}
</InputGroup>
</div>
<div className="space-y-1">
<label htmlFor="unique-code" className="text-13 font-medium text-tertiary">
{t("auth.common.unique_code.label")}
</label>
<InputGroup size="2xl">
<Input
size="2xl"
name="code"
id="unique-code"
value={uniqueCodeFormData.code}
onChange={(e) => handleFormChange("code", e.target.value)}
placeholder={t("auth.common.unique_code.placeholder")}
autoComplete="off"
autoFocus
/>
</InputGroup>
<div className="flex w-full items-center justify-between px-1 pt-1 text-11">
<p className="flex items-center gap-1 font-medium text-success-primary">
<TickCircleOutline height={12} width={12} />
{t("auth.common.unique_code.paste_code")}
</p>
<button
type="button"
onClick={() => generateNewCode(uniqueCodeFormData.email)}
className={
isRequestNewCodeDisabled
? "text-placeholder"
: "font-medium text-accent-secondary hover:text-accent-secondary"
}
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="xl" disabled={isButtonDisabled}>
{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,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/propel/button";
import { DeleteOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
type Props = {
isOpen: boolean;
onClose: () => void;
};
export function DeactivateAccountModal(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(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Account deactivated successfully.",
});
signOut();
router.push("/");
handleClose();
return;
})
.catch((err: any) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.error,
});
})
.finally(() => setIsDeactivating(false));
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="px-4 pt-5 pb-4 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-danger-subtle p-2 sm:mt-3 sm:p-2 md:mt-0 md:p-4 lg:mt-0 lg:p-4">
<DeleteOutline
className="h-4 w-4 text-danger-primary sm:h-4 sm:w-4 md:h-6 md:w-6 lg:h-6 lg:w-6"
aria-hidden="true"
/>
</div>
<div>
<h3 className="my-4 text-20 leading-6 font-medium text-primary">{t("deactivate_your_account")}</h3>
<p className="mt-6 list-disc pr-4 text-14 font-regular text-secondary">
{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="secondary" size="lg" onClick={handleClose}>
{t("cancel")}
</Button>
<Button variant="error-fill" size="lg" onClick={handleDeleteAccount}>
{isDeactivating ? t("deactivating") : t("confirm")}
</Button>
</div>
</ModalCore>
);
}

View File

@@ -0,0 +1,45 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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
function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link href={href} className="text-secondary" target="_blank" rel="noopener noreferrer">
<span className="text-13 font-medium underline hover:cursor-pointer">{children}</span>
</Link>
);
}
export function TermsAndConditions({ authType = EAuthModes.SIGN_IN }: TermsAndConditionsProps) {
return (
<div className="flex items-center justify-center">
<p className="text-center text-13 whitespace-pre-line text-tertiary">
{`${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>
);
}