mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #4508 from Infisical/feat/ENG-3666
Allow users to change the email of their accounts
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasPayloadCol = await knex.schema.hasColumn(TableName.AuthTokens, "payload");
|
||||
|
||||
if (!hasPayloadCol) {
|
||||
await knex.schema.alterTable(TableName.AuthTokens, (t) => {
|
||||
t.text("payload").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasPayloadCol = await knex.schema.hasColumn(TableName.AuthTokens, "payload");
|
||||
|
||||
if (hasPayloadCol) {
|
||||
await knex.schema.alterTable(TableName.AuthTokens, (t) => {
|
||||
t.dropColumn("payload");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ export const AuthTokensSchema = z.object({
|
||||
updatedAt: z.date(),
|
||||
userId: z.string().uuid().nullable().optional(),
|
||||
orgId: z.string().uuid().nullable().optional(),
|
||||
aliasId: z.string().nullable().optional()
|
||||
aliasId: z.string().nullable().optional(),
|
||||
payload: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TAuthTokens = z.infer<typeof AuthTokensSchema>;
|
||||
|
||||
@@ -749,6 +749,7 @@ export const registerRoutes = async (
|
||||
|
||||
const userService = userServiceFactory({
|
||||
userDAL,
|
||||
orgDAL,
|
||||
orgMembershipDAL,
|
||||
tokenService,
|
||||
permissionService,
|
||||
|
||||
@@ -129,6 +129,63 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/me/email-change/otp",
|
||||
config: {
|
||||
rateLimit: smtpRateLimit({
|
||||
keyGenerator: (req) => req.permission.id
|
||||
})
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
newEmail: z.string().email().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
preHandler: verifyAuth([AuthMode.JWT], { requireOrg: false }),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.user.requestEmailChangeOTP({
|
||||
userId: req.permission.id,
|
||||
newEmail: req.body.newEmail
|
||||
});
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/me/email",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
newEmail: z.string().email().trim(),
|
||||
otpCode: z.string().trim().length(6)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
user: UsersSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
preHandler: verifyAuth([AuthMode.JWT], { requireOrg: false }),
|
||||
handler: async (req) => {
|
||||
const user = await server.services.user.updateUserEmail({
|
||||
userId: req.permission.id,
|
||||
newEmail: req.body.newEmail,
|
||||
otpCode: req.body.otpCode
|
||||
});
|
||||
return { user };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/me/organizations",
|
||||
|
||||
@@ -36,6 +36,12 @@ export const getTokenConfig = (tokenType: TokenType) => {
|
||||
const expiresAt = new Date(new Date().getTime() + 86400000);
|
||||
return { token, triesLeft, expiresAt };
|
||||
}
|
||||
case TokenType.TOKEN_EMAIL_CHANGE_OTP: {
|
||||
const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1));
|
||||
const triesLeft = 1;
|
||||
const expiresAt = new Date(new Date().getTime() + 600000);
|
||||
return { token, triesLeft, expiresAt };
|
||||
}
|
||||
case TokenType.TOKEN_EMAIL_MFA: {
|
||||
// generate random 6-digit code
|
||||
const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1));
|
||||
@@ -75,7 +81,7 @@ export const getTokenConfig = (tokenType: TokenType) => {
|
||||
};
|
||||
|
||||
export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => {
|
||||
const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => {
|
||||
const createTokenForUser = async ({ type, userId, orgId, aliasId, payload }: TCreateTokenForUserDTO) => {
|
||||
const { token, ...tkCfg } = getTokenConfig(type);
|
||||
const appCfg = getConfig();
|
||||
const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS);
|
||||
@@ -89,7 +95,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu
|
||||
userId,
|
||||
orgId,
|
||||
triesLeft: tkCfg?.triesLeft,
|
||||
aliasId
|
||||
aliasId,
|
||||
payload
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ProjectMembershipRole } from "@app/db/schemas";
|
||||
export enum TokenType {
|
||||
TOKEN_EMAIL_CONFIRMATION = "emailConfirmation",
|
||||
TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified
|
||||
TOKEN_EMAIL_CHANGE_OTP = "emailChangeOtp",
|
||||
TOKEN_EMAIL_MFA = "emailMfa",
|
||||
TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation",
|
||||
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset",
|
||||
@@ -15,6 +16,7 @@ export type TCreateTokenForUserDTO = {
|
||||
userId: string;
|
||||
orgId?: string;
|
||||
aliasId?: string;
|
||||
payload?: string;
|
||||
};
|
||||
|
||||
export type TCreateOrgInviteTokenDTO = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
@@ -7,6 +8,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
||||
import { TokenType } from "@app/services/auth-token/auth-token-types";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
|
||||
@@ -15,7 +17,7 @@ import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
|
||||
import { TUserDALFactory } from "./user-dal";
|
||||
import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
|
||||
import { TListUserGroupsDTO, TUpdateUserEmailDTO, TUpdateUserMfaDTO } from "./user-types";
|
||||
|
||||
type TUserServiceFactoryDep = {
|
||||
userDAL: Pick<
|
||||
@@ -34,18 +36,20 @@ type TUserServiceFactoryDep = {
|
||||
| "findAllMyAccounts"
|
||||
>;
|
||||
groupProjectDAL: Pick<TGroupProjectDALFactory, "findByUserId">;
|
||||
orgDAL: Pick<TOrgDALFactory, "findById" | "find">;
|
||||
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">;
|
||||
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
|
||||
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser" | "revokeAllMySessions">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
permissionService: TPermissionServiceFactory;
|
||||
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "find" | "updateById">;
|
||||
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "find" | "updateById" | "delete">;
|
||||
};
|
||||
|
||||
export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
|
||||
|
||||
export const userServiceFactory = ({
|
||||
userDAL,
|
||||
orgDAL,
|
||||
orgMembershipDAL,
|
||||
projectMembershipDAL,
|
||||
groupProjectDAL,
|
||||
@@ -178,6 +182,135 @@ export const userServiceFactory = ({
|
||||
return updatedUser;
|
||||
};
|
||||
|
||||
const checkUserScimRestriction = async (userId: string, tx?: Knex) => {
|
||||
const userOrgs = await orgMembershipDAL.find({ userId }, { tx });
|
||||
|
||||
if (userOrgs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const orgIds = userOrgs.map((membership) => membership.orgId);
|
||||
const organizations = await orgDAL.find({ $in: { id: orgIds } }, { tx });
|
||||
|
||||
return organizations.some((org) => org.scimEnabled);
|
||||
};
|
||||
|
||||
const requestEmailChangeOTP = async ({ userId, newEmail }: TUpdateUserEmailDTO) => {
|
||||
const startTime = new Date();
|
||||
const changeEmailOTP = await userDAL.transaction(async (tx) => {
|
||||
const user = await userDAL.findById(userId, tx);
|
||||
if (!user)
|
||||
throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "RequestEmailChangeOTP" });
|
||||
|
||||
if (user.authMethods?.includes(AuthMethod.LDAP)) {
|
||||
throw new BadRequestError({ message: "Cannot update email for LDAP users", name: "RequestEmailChangeOTP" });
|
||||
}
|
||||
|
||||
const hasScimRestriction = await checkUserScimRestriction(userId, tx);
|
||||
if (hasScimRestriction) {
|
||||
throw new BadRequestError({
|
||||
message: "Email changes are disabled because SCIM is enabled for one or more of your organizations",
|
||||
name: "RequestEmailChangeOTP"
|
||||
});
|
||||
}
|
||||
|
||||
// Silently check if another user already has this email - don't send OTP if email is taken
|
||||
const existingUsers = await userDAL.findUserByUsername(newEmail.toLowerCase(), tx);
|
||||
const existingUser = existingUsers?.find((u) => u.id !== userId);
|
||||
if (!existingUser) {
|
||||
// Generate 6-digit OTP
|
||||
const otpCode = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_CHANGE_OTP,
|
||||
userId,
|
||||
payload: newEmail.toLowerCase()
|
||||
});
|
||||
|
||||
// Send OTP to NEW email address
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.EmailVerification,
|
||||
subjectLine: "Infisical email change verification",
|
||||
recipients: [newEmail.toLowerCase()],
|
||||
substitutions: {
|
||||
code: otpCode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, message: "Verification code sent to new email address" };
|
||||
});
|
||||
// Force this function to have a minimum execution time of 2 seconds to avoid possible information disclosure about existing users
|
||||
const endTime = new Date();
|
||||
const timeDiff = endTime.getTime() - startTime.getTime();
|
||||
if (timeDiff < 2000) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2000 - timeDiff);
|
||||
});
|
||||
}
|
||||
return changeEmailOTP;
|
||||
};
|
||||
|
||||
const updateUserEmail = async ({ userId, newEmail, otpCode }: TUpdateUserEmailDTO & { otpCode: string }) => {
|
||||
const changedUser = await userDAL.transaction(async (tx) => {
|
||||
const user = await userDAL.findById(userId, tx);
|
||||
if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "UpdateUserEmail" });
|
||||
|
||||
if (user.authMethods?.includes(AuthMethod.LDAP)) {
|
||||
throw new BadRequestError({ message: "Cannot update email for LDAP users", name: "UpdateUserEmail" });
|
||||
}
|
||||
|
||||
const hasScimRestriction = await checkUserScimRestriction(userId, tx);
|
||||
if (hasScimRestriction) {
|
||||
throw new BadRequestError({
|
||||
message: "You are part of an organization that has SCIM enabled, and email changes are not allowed",
|
||||
name: "UpdateUserEmail"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate OTP and get the new email from token aliasId field
|
||||
let tokenData;
|
||||
try {
|
||||
tokenData = await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_CHANGE_OTP,
|
||||
userId,
|
||||
code: otpCode
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestError({ message: "Invalid verification code", name: "UpdateUserEmail" });
|
||||
}
|
||||
|
||||
// Verify the new email matches what was stored in payload
|
||||
const tokenNewEmail = tokenData?.payload;
|
||||
if (!tokenNewEmail || tokenNewEmail !== newEmail.toLowerCase()) {
|
||||
throw new BadRequestError({ message: "Invalid verification code", name: "UpdateUserEmail" });
|
||||
}
|
||||
|
||||
// Final check if another user has this email
|
||||
const existingUsers = await userDAL.findUserByUsername(newEmail.toLowerCase(), tx);
|
||||
const existingUser = existingUsers?.find((u) => u.id !== userId);
|
||||
if (existingUser) {
|
||||
throw new BadRequestError({ message: "Email is no longer available", name: "UpdateUserEmail" });
|
||||
}
|
||||
|
||||
// Delete all user aliases since the email is changing
|
||||
await userAliasDAL.delete({ userId }, tx);
|
||||
|
||||
const updatedUser = await userDAL.updateById(
|
||||
userId,
|
||||
{
|
||||
email: newEmail.toLowerCase(),
|
||||
username: newEmail.toLowerCase()
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
// Revoke all sessions to force re-login
|
||||
await tokenService.revokeAllMySessions(userId);
|
||||
|
||||
return updatedUser;
|
||||
});
|
||||
return changedUser;
|
||||
};
|
||||
|
||||
const getAllMyAccounts = async (email: string, userId: string) => {
|
||||
const users = await userDAL.findAllMyAccounts(email);
|
||||
return users?.map((el) => ({ ...el, isMyAccount: el.id === userId }));
|
||||
@@ -313,6 +446,8 @@ export const userServiceFactory = ({
|
||||
updateUserMfa,
|
||||
updateUserName,
|
||||
updateAuthMethods,
|
||||
requestEmailChangeOTP,
|
||||
updateUserEmail,
|
||||
deleteUser,
|
||||
getMe,
|
||||
createUserAction,
|
||||
|
||||
@@ -16,3 +16,8 @@ export type TUpdateUserMfaDTO = {
|
||||
isMfaEnabled?: boolean;
|
||||
selectedMfaMethod?: MfaMethod;
|
||||
};
|
||||
|
||||
export type TUpdateUserEmailDTO = {
|
||||
userId: string;
|
||||
newEmail: string;
|
||||
};
|
||||
|
||||
@@ -7,8 +7,39 @@ description: "Learn how to authenticate into Infisical with email and password."
|
||||
|
||||
It is currently possible to use the **Email and Password** auth method to authenticate into the Web Dashboard and Infisical CLI.
|
||||
|
||||
### Emergency Kit
|
||||
Every **Email and Password** is accompanied by an emergency kit given to users during signup. If the password is lost or forgotten, emergency kit is only way to retrieve the access to your account. It is possible to generate a new emergency kit with the following steps:
|
||||
1. Open the `Personal Settings` menu.
|
||||

|
||||
2. Scroll down to the `Emergency Kit` section.
|
||||
3. Enter your current password and click `Save`.
|
||||
|
||||
### Change Password
|
||||
You can update your account password at any time:
|
||||
1. Open the `Personal Settings` menu.
|
||||

|
||||
2. Navigate to the `Authentication` tab.
|
||||

|
||||
3. In the `Change Password` section, enter your current password and new password.
|
||||

|
||||
4. Click `Save` to save your new password.
|
||||
|
||||
### Change Email
|
||||
You can update your account email address:
|
||||
1. Open the `Personal Settings` menu.
|
||||
2. Navigate to the `Authentication` tab.
|
||||
3. In the `Change Email` section, enter your new email address.
|
||||

|
||||
4. Click `Send Verification Code` to receive an 6-digit verification code at your new email address.
|
||||
5. Check your new email inbox and enter the verification code.
|
||||

|
||||
6. Click `Confirm Email Change` to complete the process.
|
||||
7. You will be logged out and need to sign in again with your new email address.
|
||||
|
||||
<Tip>
|
||||
Changing your email will remove all connected external authentication methods and terminate all active sessions for security.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Email changes are disabled if SCIM is enabled for any of your organizations. Contact your organization administrator if you need to change your email address in a SCIM-enabled environment.
|
||||
</Warning>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 677 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 753 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 799 KiB |
@@ -1,8 +1,10 @@
|
||||
export {
|
||||
useAddUserToWsNonE2EE,
|
||||
useRemoveMyDuplicateAccounts,
|
||||
useRequestEmailChangeOTP,
|
||||
useRevokeMySessionById,
|
||||
useSendEmailVerificationCode,
|
||||
useUpdateUserEmail,
|
||||
useVerifyEmailVerificationCode
|
||||
} from "./mutation";
|
||||
export {
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user