Merge pull request #4508 from Infisical/feat/ENG-3666

Allow users to change the email of their accounts
This commit is contained in:
carlosmonastyrski
2025-09-11 12:36:02 -03:00
committed by GitHub
18 changed files with 545 additions and 6 deletions
+2
View File
@@ -1,8 +1,10 @@
export {
useAddUserToWsNonE2EE,
useRemoveMyDuplicateAccounts,
useRequestEmailChangeOTP,
useRevokeMySessionById,
useSendEmailVerificationCode,
useUpdateUserEmail,
useVerifyEmailVerificationCode
} from "./mutation";
export {
+27
View File
@@ -152,3 +152,30 @@ export const useRemoveMyDuplicateAccounts = () => {
}
});
};
export const useRequestEmailChangeOTP = () => {
return useMutation({
mutationFn: async ({ newEmail }: { newEmail: string }) => {
const { data } = await apiRequest.post("/api/v2/users/me/email-change/otp", {
newEmail
});
return data;
}
});
};
export const useUpdateUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ newEmail, otpCode }: { newEmail: string; otpCode: string }) => {
const { data } = await apiRequest.patch("/api/v2/users/me/email", {
newEmail,
otpCode
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.getUser });
}
});
};
@@ -0,0 +1,245 @@
import { useState } from "react";
import ReactCodeInput from "react-code-input";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useUser } from "@app/context";
import { useRequestEmailChangeOTP, useUpdateUserEmail } from "@app/hooks/api/users";
import { clearSession } from "@app/hooks/api/users/queries";
const emailSchema = z
.object({
newEmail: z.string().email("Please enter a valid email")
})
.required();
export type EmailFormData = z.infer<typeof emailSchema>;
const otpInputProps = {
inputStyle: {
fontFamily: "monospace",
margin: "4px",
MozAppearance: "textfield" as const,
width: "45px",
borderRadius: "6px",
fontSize: "18px",
height: "45px",
padding: "0",
paddingLeft: "0",
paddingRight: "0",
backgroundColor: "#262626",
color: "white",
border: "1px solid #404040",
textAlign: "center" as const,
outlineColor: "#8ca542",
borderColor: "#404040"
}
};
export const ChangeEmailSection = () => {
const navigate = useNavigate();
const { user } = useUser();
const [isOTPModalOpen, setIsOTPModalOpen] = useState(false);
const [pendingEmail, setPendingEmail] = useState("");
const emailForm = useForm<EmailFormData>({
defaultValues: { newEmail: "" },
resolver: zodResolver(emailSchema)
});
const { mutateAsync: requestEmailChangeOTP, isPending: isRequestingOTP } =
useRequestEmailChangeOTP();
const { mutateAsync: updateUserEmail, isPending: isUpdatingEmail } = useUpdateUserEmail();
// Watch the email field to enable/disable the button
const watchedEmail = useWatch({
control: emailForm.control,
name: "newEmail",
defaultValue: ""
});
// Helper function to check if email is valid
const isEmailValid = (email: string): boolean => {
try {
emailSchema.parse({ newEmail: email });
return true;
} catch {
return false;
}
};
const handleEmailSubmit = async ({ newEmail }: EmailFormData) => {
if (newEmail.toLowerCase() === user?.email?.toLowerCase()) {
createNotification({
text: "New email must be different from current email",
type: "error"
});
return;
}
try {
await requestEmailChangeOTP({ newEmail });
setPendingEmail(newEmail);
setIsOTPModalOpen(true);
createNotification({
text: "Verification code sent to your new email address. Check your inbox!",
type: "success"
});
} catch (err: any) {
console.error(err);
const errorMessage = err?.response?.data?.message || "Failed to send verification code";
createNotification({
text: errorMessage,
type: "error"
});
}
};
const [typedOTP, setTypedOTP] = useState("");
const handleOTPSubmit = async () => {
if (typedOTP.length !== 6) {
createNotification({
text: "Please enter the complete 6-digit verification code",
type: "error"
});
return;
}
try {
await updateUserEmail({ newEmail: pendingEmail, otpCode: typedOTP });
createNotification({
text: "Email updated successfully. You will be redirected to login.",
type: "success"
});
// Reset forms and close modal
emailForm.reset();
setIsOTPModalOpen(false);
setPendingEmail("");
setTypedOTP("");
// Clear frontend session/token to ensure proper logout
clearSession(true);
// Redirect to login after a short delay
setTimeout(() => {
navigate({ to: "/login" });
}, 2000);
} catch (err: any) {
console.error(err);
const errorMessage = err?.response?.data?.message || "Invalid verification code";
if (errorMessage.includes("Invalid verification code")) {
// Reset to email step so user must request new OTP
setIsOTPModalOpen(false);
setPendingEmail("");
setTypedOTP("");
emailForm.reset();
createNotification({
text: "Invalid verification code. Please request a new one.",
type: "error"
});
} else {
createNotification({
text: errorMessage,
type: "error"
});
}
}
};
const handleOTPModalClose = () => {
setIsOTPModalOpen(false);
setPendingEmail("");
setTypedOTP("");
};
return (
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Change email</h2>
<form onSubmit={emailForm.handleSubmit(handleEmailSubmit)}>
<div className="max-w-md">
<Controller
control={emailForm.control}
name="newEmail"
render={({ field, fieldState: { error } }) => (
<FormControl
label="New email address"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="Enter new email address"
type="email"
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
</div>
<Button
type="submit"
colorSchema="secondary"
isLoading={isRequestingOTP}
isDisabled={isRequestingOTP || !isEmailValid(watchedEmail)}
>
Send Verification Code
</Button>
<p className="mt-2 font-inter text-sm text-mineshaft-400">
We&apos;ll send an 6-digit verification code to your new email address.
</p>
</form>
</div>
<Modal
isOpen={isOTPModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) handleOTPModalClose();
}}
>
<ModalContent
title="Email Verification"
subTitle={`Enter the 6-digit verification code sent to: ${pendingEmail}`}
>
<div className="flex flex-col items-center space-y-4">
<div className="flex justify-center">
<ReactCodeInput
name="otp-input"
inputMode="tel"
type="text"
fields={6}
onChange={setTypedOTP}
value={typedOTP}
{...otpInputProps}
className="mb-4"
/>
</div>
<div className="flex gap-2">
<Button colorSchema="secondary" variant="outline" onClick={handleOTPModalClose}>
Cancel
</Button>
<Button
onClick={handleOTPSubmit}
isLoading={isUpdatingEmail}
isDisabled={typedOTP.length !== 6}
>
Confirm Email Change
</Button>
</div>
</div>
</ModalContent>
</Modal>
</>
);
};
@@ -0,0 +1 @@
export { ChangeEmailSection } from "./ChangeEmailSection";
@@ -2,6 +2,7 @@ import { useGetUser } from "@app/hooks/api";
import { AuthMethod } from "@app/hooks/api/users/types";
import { AuthMethodSection } from "../AuthMethodSection";
import { ChangeEmailSection } from "../ChangeEmailSection";
import { ChangePasswordSection } from "../ChangePasswordSection";
import { MFASection } from "../SecuritySection";
@@ -16,6 +17,7 @@ export const PersonalAuthTab = () => {
</>
)}
<ChangePasswordSection />
{user && !user.authMethods.includes(AuthMethod.LDAP) && <ChangeEmailSection />}
</div>
);
};