[WEB-5430] feat: allow users to change email (#8120)

* feat: change user email

* chore: optimised the logic

* feat: add email change functionality and related modals in profile form

* refactor: format checkEmail method for improved readability

* chore: added rate limit exceeded validation

* feat: implement change email modal with localization support

- Added translation support for the change email modal, including titles, descriptions, and error messages.
- Integrated the useTranslation hook for dynamic text rendering.
- Updated form validation messages to utilize localized strings.
- Enhanced user feedback with localized success and error toast messages.
- Updated button labels and placeholders to reflect localization changes.

* chore: added extra validation in cache key

* fix: format files

---------

Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Bavisetti Narayan 2025-11-24 21:21:52 +05:30 committed by GitHub
parent d6fce114d6
commit ce6299937f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2457 additions and 5 deletions

View file

@ -0,0 +1,247 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Transition, Dialog } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Input } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { authErrorHandler } from "@/helpers/authentication.helper";
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
// hooks
import { useUser } from "@/hooks/store/user";
// services
import { AuthService } from "@/services/auth.service";
import userService from "@/services/user.service";
type Props = { isOpen: boolean; onClose: () => void };
type TModalStep = "EMAIL" | "UNIQUE_CODE";
type TUniqueCodeValuesForm = { email: string; code: string };
const defaultValues: TUniqueCodeValuesForm = { email: "", code: "" };
// service initialization
const authService = new AuthService();
export const ChangeEmailModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose } = props;
// states
const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL");
// store hooks
const { signOut } = useUser();
const { t } = useTranslation();
const changeEmailT = (path: string) => t(`account_settings.profile.change_email_modal.${path}`);
// form info
const {
handleSubmit,
control,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<TUniqueCodeValuesForm>({ defaultValues });
const secondStep = currentStep === "UNIQUE_CODE";
const handleClose = () => {
reset({ ...defaultValues });
setCurrentStep("EMAIL");
onClose();
};
const handleSignOut = async () => {
await signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("sign_out.toast.error.title"),
message: t("sign_out.toast.error.message"),
})
);
};
const onSubmit = async (formData: TUniqueCodeValuesForm) => {
if (currentStep === "UNIQUE_CODE") {
// Step 2: Verify the code and update email
try {
await userService.verifyEmailCode({ email: formData.email, code: formData.code });
setToast({
type: TOAST_TYPE.SUCCESS,
title: changeEmailT("toasts.success_title"),
message: changeEmailT("toasts.success_message"),
});
// Sign out the user after successful email update
await handleSignOut();
handleClose();
} catch (error: unknown) {
const errorMessage =
(error as { error?: string; message?: string })?.error ||
(error as { error?: string; message?: string })?.message ||
changeEmailT("form.code.errors.invalid");
setError("code", { type: "custom", message: errorMessage });
}
return;
}
// Step 1: Check email and generate verification code
try {
// Get CSRF token
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
if (!csrfToken) throw new Error("CSRF token not found");
// Check if email is available
const emailCheckResponse = await userService.checkEmail(csrfToken, formData.email);
// Check if email already exists
if (emailCheckResponse?.existing === true) {
setError("email", { type: "custom", message: changeEmailT("form.email.errors.exists") });
return;
}
// Generate verification code and send to new email
await userService.generateEmailCode({ email: formData.email });
// Move to verification code step
setCurrentStep("UNIQUE_CODE");
} catch (error: unknown) {
// Extract error code and message from backend response
const err = error as { error_code?: number | string; error_message?: string };
const errorCode = err?.error_code?.toString();
// Use authErrorHandler to get user-friendly error message
const errorInfo = errorCode ? authErrorHandler(errorCode as EAuthenticationErrorCodes) : undefined;
// Get error message from handler or fallback
const errorMessage = errorInfo
? typeof errorInfo.message === "string"
? errorInfo.message
: String(errorInfo.message)
: err?.error_message || changeEmailT("form.email.errors.validation_failed");
setError("email", { type: "custom", message: errorMessage });
}
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 transition-opacity bg-custom-backdrop" />
</Transition.Child>
<div className="overflow-y-auto fixed inset-0 z-30">
<div className="flex justify-center items-center p-4 min-h-full text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-4 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[30rem]">
<div className="py-4 space-y-0">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
{changeEmailT("title")}
</Dialog.Title>
<p className="my-4 text-sm text-custom-text-200">{changeEmailT("description")}</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="flex flex-col gap-1">
{secondStep && (
<h4 className="text-sm font-medium text-custom-text-200">{changeEmailT("form.email.label")}</h4>
)}
<Controller
control={control}
name="email"
rules={{
required: changeEmailT("form.email.errors.required"),
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: changeEmailT("form.email.errors.invalid"),
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)}
placeholder={changeEmailT("form.email.placeholder")}
className={cn(
{ "border-red-500": errors.email },
{ "cursor-not-allowed !bg-custom-background-90": secondStep }
)}
disabled={secondStep}
/>
)}
/>
{errors?.email && <span className="text-xs text-red-500">{errors?.email?.message}</span>}
</div>
{secondStep && (
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">{changeEmailT("form.code.label")}</h4>
<Controller
control={control}
name="code"
rules={{ required: changeEmailT("form.code.errors.required") }}
render={({ field: { value, onChange, ref } }) => (
<Input
id="code"
name="code"
value={value}
onChange={onChange}
ref={ref}
placeholder={changeEmailT("form.code.placeholder")}
className={cn({ "border-red-500": errors.code })}
autoFocus
/>
)}
/>
{errors?.code ? (
<span className="text-xs text-red-500">{errors?.code?.message}</span>
) : (
<span className="text-xs text-green-700">{changeEmailT("form.code.helper_text")}</span>
)}
</div>
)}
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200 py-4">
<Button type="button" variant="neutral-primary" size="sm" onClick={handleClose}>
{changeEmailT("actions.cancel")}
</Button>
<Button type="submit" variant="primary" size="sm" disabled={isSubmitting}>
{isSubmitting
? changeEmailT("states.sending")
: secondStep
? changeEmailT("actions.confirm")
: changeEmailT("actions.continue")}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View file

@ -17,10 +17,12 @@ import { cn, getFileURL } from "@plane/utils";
// components
import { DeactivateAccountModal } from "@/components/account/deactivate-account-modal";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
import { ChangeEmailModal } from "@/components/core/modals/change-email-modal";
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
// helpers
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUser, useUserProfile } from "@/hooks/store/user";
type TUserProfileForm = {
@ -49,6 +51,7 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
const [isLoading, setIsLoading] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const [deactivateAccountModal, setDeactivateAccountModal] = useState(false);
const [isChangeEmailModalOpen, setIsChangeEmailModalOpen] = useState(false);
// language support
const { t } = useTranslation();
// form info
@ -78,6 +81,9 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
// store hooks
const { data: currentUser, updateCurrentUser } = useUser();
const { updateUserProfile } = useUserProfile();
const { config } = useInstance();
const isSMTPConfigured = config?.is_smtp_configured || false;
const handleProfilePictureDelete = async (url: string | null | undefined) => {
if (!url) return;
@ -156,6 +162,7 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
return (
<>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
<ChangeEmailModal isOpen={isChangeEmailModalOpen} onClose={() => setIsChangeEmailModalOpen(false)} />
<Controller
control={control}
name="avatar_url"
@ -355,6 +362,15 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
/>
)}
/>
{isSMTPConfigured && (
<button
type="button"
className="text-xs underline btn w-fit text-custom-text-200"
onClick={() => setIsChangeEmailModalOpen(true)}
>
{t("change_email")}
</button>
)}
</div>
</div>
</div>

View file

@ -11,6 +11,7 @@ import type {
IUserEmailNotificationSettings,
TIssuesResponse,
TUserProfile,
IEmailCheckResponse,
} from "@plane/types";
import { APIService } from "@/services/api.service";
// types
@ -258,6 +259,38 @@ export class UserService extends APIService {
throw error?.response?.data;
});
}
async checkEmail(token: string, email: string): Promise<IEmailCheckResponse> {
return this.post(
"/auth/email-check/",
{ email },
{
headers: {
"X-CSRFTOKEN": token,
},
}
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async generateEmailCode(data: { email: string }): Promise<any> {
return this.post("/api/users/me/email/generate-code/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async verifyEmailCode(data: { email: string; code: string }): Promise<any> {
return this.patch("/api/users/me/email/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
const userService = new UserService();