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
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:
124
apps/web/core/components/account/auth-forms/auth-header.tsx
Normal file
124
apps/web/core/components/account/auth-forms/auth-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
apps/web/core/components/account/auth-forms/auth-root.tsx
Normal file
164
apps/web/core/components/account/auth-forms/auth-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
109
apps/web/core/components/account/auth-forms/email.tsx
Normal file
109
apps/web/core/components/account/auth-forms/email.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
142
apps/web/core/components/account/auth-forms/forgot-password.tsx
Normal file
142
apps/web/core/components/account/auth-forms/forgot-password.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
138
apps/web/core/components/account/auth-forms/form-root.tsx
Normal file
138
apps/web/core/components/account/auth-forms/form-root.tsx
Normal 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 <></>;
|
||||
});
|
||||
7
apps/web/core/components/account/auth-forms/index.ts
Normal file
7
apps/web/core/components/account/auth-forms/index.ts
Normal 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";
|
||||
304
apps/web/core/components/account/auth-forms/password.tsx
Normal file
304
apps/web/core/components/account/auth-forms/password.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
211
apps/web/core/components/account/auth-forms/reset-password.tsx
Normal file
211
apps/web/core/components/account/auth-forms/reset-password.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
208
apps/web/core/components/account/auth-forms/set-password.tsx
Normal file
208
apps/web/core/components/account/auth-forms/set-password.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
180
apps/web/core/components/account/auth-forms/unique-code.tsx
Normal file
180
apps/web/core/components/account/auth-forms/unique-code.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
45
apps/web/core/components/account/terms-and-conditions.tsx
Normal file
45
apps/web/core/components/account/terms-and-conditions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user