From 9e73686b302468f7dd7831d8a10af0e8324530c5 Mon Sep 17 00:00:00 2001 From: Ilango Rajagopal Date: Tue, 17 Mar 2026 15:00:59 +0530 Subject: [PATCH 1/5] fix: update stripe email when changing email in account settings --- packages/server/src/IdentityManager.ts | 5 ++++ packages/server/src/StripeManager.ts | 5 ++++ .../src/enterprise/services/user.service.ts | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/packages/server/src/IdentityManager.ts b/packages/server/src/IdentityManager.ts index c56903be9c2..fa3562baeaa 100644 --- a/packages/server/src/IdentityManager.ts +++ b/packages/server/src/IdentityManager.ts @@ -312,6 +312,11 @@ export class IdentityManager { return await this.stripeManager.getCustomerWithDefaultSource(customerId) } + public async updateStripeCustomerEmail(customerId: string, email: string) { + if (!this.stripeManager) return + await this.stripeManager.updateCustomerEmail(customerId, email) + } + public async getAdditionalSeatsProration(subscriptionId: string, newQuantity: number) { if (!subscriptionId) return {} if (!this.stripeManager) { diff --git a/packages/server/src/StripeManager.ts b/packages/server/src/StripeManager.ts index 806349d9710..8d65a59c721 100644 --- a/packages/server/src/StripeManager.ts +++ b/packages/server/src/StripeManager.ts @@ -270,6 +270,11 @@ export class StripeManager { } } + public async updateCustomerEmail(customerId: string, email: string) { + if (!this.stripe) return + await this.stripe.customers.update(customerId, { email }) + } + public async getAdditionalSeatsProration(subscriptionId: string, quantity: number) { if (!this.stripe) { throw new Error('Stripe is not initialized') diff --git a/packages/server/src/enterprise/services/user.service.ts b/packages/server/src/enterprise/services/user.service.ts index bb88988f6f7..497165696c0 100644 --- a/packages/server/src/enterprise/services/user.service.ts +++ b/packages/server/src/enterprise/services/user.service.ts @@ -4,12 +4,16 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { generateId } from '../../utils' import { GeneralErrorMessage } from '../../utils/constants' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import logger from '../../utils/logger' import { sanitizeUser } from '../../utils/sanitize.util' import { Telemetry, TelemetryEventType } from '../../utils/telemetry' +import { Platform } from '../../Interface' import { User, UserStatus } from '../database/entities/user.entity' import { destroyAllSessionsForUser } from '../middleware/passport/SessionPersistance' import { compareHash, getHash } from '../utils/encryption.util' import { isInvalidEmail, isInvalidName, isInvalidPassword, isInvalidUUID } from '../utils/validation.util' +import { OrganizationService } from './organization.service' +import { OrganizationUserService } from './organization-user.service' export const enum UserErrorMessage { EXPIRED_TEMP_TOKEN = 'Expired Temporary Token', @@ -145,6 +149,8 @@ export class UserService { const oldUserData = await this.readUserById(newUserData.id, queryRunner) if (!oldUserData) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND) + const currentEmail = oldUserData.email + if (newUserData.updatedBy) { const updateUserData = await this.readUserById(newUserData.updatedBy, queryRunner) if (!updateUserData) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND) @@ -185,6 +191,30 @@ export class UserService { if (newUserData.oldPassword && newUserData.newPassword && newUserData.confirmPassword) { await destroyAllSessionsForUser(updatedUser.id as string) } + + // Update Stripe customer email when user changes email (CLOUD only; expect exactly one org) + const appServer = getRunningExpressApp() + if (appServer.identityManager.getPlatformType() === Platform.CLOUD && updatedUser.email && updatedUser.email !== currentEmail) { + const organizationUserService = new OrganizationUserService() + const organizationService = new OrganizationService() + let syncQueryRunner: QueryRunner | undefined + try { + syncQueryRunner = this.dataSource.createQueryRunner() + await syncQueryRunner.connect() + const orgUsers = await organizationUserService.readOrganizationUserByUserId(updatedUser.id as string, syncQueryRunner) + const orgUsersCreatedByUser = orgUsers.filter((ou) => ou.createdBy === updatedUser.id) + if (orgUsersCreatedByUser.length === 1) { + const org = await organizationService.readOrganizationById(orgUsersCreatedByUser[0].organizationId, syncQueryRunner) + if (org?.customerId) { + await appServer.identityManager.updateStripeCustomerEmail(org.customerId, updatedUser.email as string) + } + } + } catch (stripeError) { + logger.warn(`Failed to update Stripe customer email for user ${updatedUser.id}:`, stripeError) + } finally { + if (syncQueryRunner && !syncQueryRunner.isReleased) await syncQueryRunner.release() + } + } } catch (error) { if (queryRunner && queryRunner.isTransactionActive) await queryRunner.rollbackTransaction() throw error From c64cce17aeb4ff754106014f558f9d1f882ad563 Mon Sep 17 00:00:00 2001 From: Ilango Rajagopal Date: Tue, 17 Mar 2026 15:08:16 +0530 Subject: [PATCH 2/5] fix: review feedback --- packages/server/src/IdentityManager.ts | 2 +- packages/server/src/StripeManager.ts | 2 +- packages/server/src/enterprise/services/user.service.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/server/src/IdentityManager.ts b/packages/server/src/IdentityManager.ts index fa3562baeaa..f89453572b5 100644 --- a/packages/server/src/IdentityManager.ts +++ b/packages/server/src/IdentityManager.ts @@ -313,7 +313,7 @@ export class IdentityManager { } public async updateStripeCustomerEmail(customerId: string, email: string) { - if (!this.stripeManager) return + if (!this.stripeManager) throw new Error('Stripe manager is not initialized') await this.stripeManager.updateCustomerEmail(customerId, email) } diff --git a/packages/server/src/StripeManager.ts b/packages/server/src/StripeManager.ts index 8d65a59c721..5b563c2e8c5 100644 --- a/packages/server/src/StripeManager.ts +++ b/packages/server/src/StripeManager.ts @@ -271,7 +271,7 @@ export class StripeManager { } public async updateCustomerEmail(customerId: string, email: string) { - if (!this.stripe) return + if (!this.stripe) throw new Error('Stripe is not initialized') await this.stripe.customers.update(customerId, { email }) } diff --git a/packages/server/src/enterprise/services/user.service.ts b/packages/server/src/enterprise/services/user.service.ts index 497165696c0..dd53d74fd26 100644 --- a/packages/server/src/enterprise/services/user.service.ts +++ b/packages/server/src/enterprise/services/user.service.ts @@ -202,9 +202,9 @@ export class UserService { syncQueryRunner = this.dataSource.createQueryRunner() await syncQueryRunner.connect() const orgUsers = await organizationUserService.readOrganizationUserByUserId(updatedUser.id as string, syncQueryRunner) - const orgUsersCreatedByUser = orgUsers.filter((ou) => ou.createdBy === updatedUser.id) - if (orgUsersCreatedByUser.length === 1) { - const org = await organizationService.readOrganizationById(orgUsersCreatedByUser[0].organizationId, syncQueryRunner) + const ownerOrgLinks = orgUsers.filter((ou) => ou.isOrgOwner) + if (ownerOrgLinks.length === 1) { + const org = await organizationService.readOrganizationById(ownerOrgLinks[0].organizationId, syncQueryRunner) if (org?.customerId) { await appServer.identityManager.updateStripeCustomerEmail(org.customerId, updatedUser.email as string) } From 15748bcfcdec6384ce4ddbdaa99bfb16e3ab0d8c Mon Sep 17 00:00:00 2001 From: Ilango Rajagopal Date: Thu, 19 Mar 2026 15:29:59 +0530 Subject: [PATCH 3/5] fix: circular dependency, destroy all sessions on email change --- .../enterprise/controllers/user.controller.ts | 6 ++- .../enterprise/services/account.service.ts | 26 ++++++++++++ .../src/enterprise/services/user.service.ts | 41 +++++-------------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/packages/server/src/enterprise/controllers/user.controller.ts b/packages/server/src/enterprise/controllers/user.controller.ts index 2acc458bb3b..224dcd3c48c 100644 --- a/packages/server/src/enterprise/controllers/user.controller.ts +++ b/packages/server/src/enterprise/controllers/user.controller.ts @@ -4,6 +4,7 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { GeneralErrorMessage } from '../../utils/constants' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { User } from '../database/entities/user.entity' +import { AccountService } from '../services/account.service' import { UserErrorMessage, UserService } from '../services/user.service' export class UserController { @@ -60,7 +61,10 @@ export class UserController { if (currentUser.id !== id) { throw new InternalFlowiseError(StatusCodes.FORBIDDEN, UserErrorMessage.USER_NOT_FOUND) } - const user = await userService.updateUser(req.body) + const accountService = new AccountService() + const user = await userService.updateUser(req.body, { + onEmailChanged: (userId, newEmail) => accountService.syncStripeCustomerEmailAfterUserEmailChange(userId, newEmail) + }) return res.status(StatusCodes.OK).json(user) } catch (error) { next(error) diff --git a/packages/server/src/enterprise/services/account.service.ts b/packages/server/src/enterprise/services/account.service.ts index 7ca6b25fe76..d6c5c135fc8 100644 --- a/packages/server/src/enterprise/services/account.service.ts +++ b/packages/server/src/enterprise/services/account.service.ts @@ -624,4 +624,30 @@ export class AccountService { ) } } + + /** + * Sync Stripe customer email when user changes their email (CLOUD only). + * Expects exactly one org where the user is org owner; updates that org's Stripe customer email. + */ + public async syncStripeCustomerEmailAfterUserEmailChange(userId: string, newEmail: string) { + if (this.identityManager.getPlatformType() !== Platform.CLOUD) return + + let queryRunner: QueryRunner | undefined + try { + queryRunner = this.dataSource.createQueryRunner() + await queryRunner.connect() + const orgUsers = await this.organizationUserService.readOrganizationUserByUserId(userId, queryRunner) + const ownerOrgLinks = orgUsers.filter((ou) => ou.isOrgOwner) + if (ownerOrgLinks.length === 1) { + const org = await this.organizationservice.readOrganizationById(ownerOrgLinks[0].organizationId, queryRunner) + if (org?.customerId) { + await this.identityManager.updateStripeCustomerEmail(org.customerId, newEmail) + } + } + } catch (error) { + logger.warn(`Failed to update Stripe customer email for user ${userId}:`, error) + } finally { + if (queryRunner && !queryRunner.isReleased) await queryRunner.release() + } + } } diff --git a/packages/server/src/enterprise/services/user.service.ts b/packages/server/src/enterprise/services/user.service.ts index dd53d74fd26..5d983cbefdf 100644 --- a/packages/server/src/enterprise/services/user.service.ts +++ b/packages/server/src/enterprise/services/user.service.ts @@ -4,16 +4,12 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { generateId } from '../../utils' import { GeneralErrorMessage } from '../../utils/constants' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' -import logger from '../../utils/logger' import { sanitizeUser } from '../../utils/sanitize.util' import { Telemetry, TelemetryEventType } from '../../utils/telemetry' -import { Platform } from '../../Interface' import { User, UserStatus } from '../database/entities/user.entity' import { destroyAllSessionsForUser } from '../middleware/passport/SessionPersistance' import { compareHash, getHash } from '../utils/encryption.util' import { isInvalidEmail, isInvalidName, isInvalidPassword, isInvalidUUID } from '../utils/validation.util' -import { OrganizationService } from './organization.service' -import { OrganizationUserService } from './organization-user.service' export const enum UserErrorMessage { EXPIRED_TEMP_TOKEN = 'Expired Temporary Token', @@ -140,7 +136,10 @@ export class UserService { return newUser } - public async updateUser(newUserData: Partial & { oldPassword?: string; newPassword?: string; confirmPassword?: string }) { + public async updateUser( + newUserData: Partial & { oldPassword?: string; newPassword?: string; confirmPassword?: string }, + options?: { onEmailChanged?: (userId: string, newEmail: string) => Promise } + ) { let queryRunner: QueryRunner | undefined let updatedUser: Partial try { @@ -187,33 +186,15 @@ export class UserService { await this.saveUser(updatedUser, queryRunner) await queryRunner.commitTransaction() - // Invalidate all sessions for this user if password was changed - if (newUserData.oldPassword && newUserData.newPassword && newUserData.confirmPassword) { - await destroyAllSessionsForUser(updatedUser.id as string) + const passwordChanged = !!(newUserData.oldPassword && newUserData.newPassword && newUserData.confirmPassword) + const emailChanged = !!(updatedUser.email && updatedUser.email !== currentEmail) + + if (emailChanged && updatedUser.email && options?.onEmailChanged) { + await options.onEmailChanged(updatedUser.id as string, updatedUser.email) } - // Update Stripe customer email when user changes email (CLOUD only; expect exactly one org) - const appServer = getRunningExpressApp() - if (appServer.identityManager.getPlatformType() === Platform.CLOUD && updatedUser.email && updatedUser.email !== currentEmail) { - const organizationUserService = new OrganizationUserService() - const organizationService = new OrganizationService() - let syncQueryRunner: QueryRunner | undefined - try { - syncQueryRunner = this.dataSource.createQueryRunner() - await syncQueryRunner.connect() - const orgUsers = await organizationUserService.readOrganizationUserByUserId(updatedUser.id as string, syncQueryRunner) - const ownerOrgLinks = orgUsers.filter((ou) => ou.isOrgOwner) - if (ownerOrgLinks.length === 1) { - const org = await organizationService.readOrganizationById(ownerOrgLinks[0].organizationId, syncQueryRunner) - if (org?.customerId) { - await appServer.identityManager.updateStripeCustomerEmail(org.customerId, updatedUser.email as string) - } - } - } catch (stripeError) { - logger.warn(`Failed to update Stripe customer email for user ${updatedUser.id}:`, stripeError) - } finally { - if (syncQueryRunner && !syncQueryRunner.isReleased) await syncQueryRunner.release() - } + if (passwordChanged || emailChanged) { + await destroyAllSessionsForUser(updatedUser.id as string) } } catch (error) { if (queryRunner && queryRunner.isTransactionActive) await queryRunner.rollbackTransaction() From fd366d0c154cd39dc040044c41c23b4a317df014 Mon Sep 17 00:00:00 2001 From: Ilango Rajagopal Date: Tue, 24 Mar 2026 14:56:45 +0530 Subject: [PATCH 4/5] add: email change confirmation flow + smtp available check + enable email flows for oss and enterprise --- .../controllers/account.controller.ts | 10 + .../enterprise/controllers/user.controller.ts | 9 +- .../emails/confirm_email_change.hbs | 877 +++++++++++ .../emails/confirm_email_change.html | 1282 +++++++++++++++++ .../src/enterprise/routes/account.route.ts | 2 + .../enterprise/services/account.service.ts | 272 +++- .../src/enterprise/services/user.service.ts | 3 +- .../enterprise/utils/emailChangeJwt.util.ts | 28 + .../server/src/enterprise/utils/sendEmail.ts | 28 +- .../src/enterprise/utils/url.util.test.ts | 4 +- .../server/src/enterprise/utils/url.util.ts | 2 +- packages/server/src/utils/constants.ts | 4 +- 12 files changed, 2495 insertions(+), 26 deletions(-) create mode 100644 packages/server/src/enterprise/emails/confirm_email_change.hbs create mode 100644 packages/server/src/enterprise/emails/confirm_email_change.html create mode 100644 packages/server/src/enterprise/utils/emailChangeJwt.util.ts diff --git a/packages/server/src/enterprise/controllers/account.controller.ts b/packages/server/src/enterprise/controllers/account.controller.ts index 794971d9c60..874d52d6776 100644 --- a/packages/server/src/enterprise/controllers/account.controller.ts +++ b/packages/server/src/enterprise/controllers/account.controller.ts @@ -57,6 +57,16 @@ export class AccountController { } } + public async confirmEmailChange(req: Request, res: Response, next: NextFunction) { + try { + const accountService = new AccountService() + const data = await accountService.confirmEmailChange(req.body) + return res.status(StatusCodes.OK).json(data) + } catch (error) { + next(error) + } + } + public async forgotPassword(req: Request, res: Response, next: NextFunction) { try { const accountService = new AccountService() diff --git a/packages/server/src/enterprise/controllers/user.controller.ts b/packages/server/src/enterprise/controllers/user.controller.ts index 224dcd3c48c..a10d19f6c99 100644 --- a/packages/server/src/enterprise/controllers/user.controller.ts +++ b/packages/server/src/enterprise/controllers/user.controller.ts @@ -52,7 +52,6 @@ export class UserController { public async update(req: Request, res: Response, next: NextFunction) { try { - const userService = new UserService() const currentUser = req.user if (!currentUser) { throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, UserErrorMessage.USER_NOT_FOUND) @@ -62,10 +61,10 @@ export class UserController { throw new InternalFlowiseError(StatusCodes.FORBIDDEN, UserErrorMessage.USER_NOT_FOUND) } const accountService = new AccountService() - const user = await userService.updateUser(req.body, { - onEmailChanged: (userId, newEmail) => accountService.syncStripeCustomerEmailAfterUserEmailChange(userId, newEmail) - }) - return res.status(StatusCodes.OK).json(user) + const result = await accountService.updateAuthenticatedUserProfile(currentUser.id, req.body, (userId, newEmail) => + accountService.syncStripeCustomerEmailAfterUserEmailChange(userId, newEmail) + ) + return res.status(StatusCodes.OK).json(result) } catch (error) { next(error) } diff --git a/packages/server/src/enterprise/emails/confirm_email_change.hbs b/packages/server/src/enterprise/emails/confirm_email_change.hbs new file mode 100644 index 00000000000..46385687376 --- /dev/null +++ b/packages/server/src/enterprise/emails/confirm_email_change.hbs @@ -0,0 +1,877 @@ + + + FlowiseAI + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + +
+ + + + + + +
+ + + + + + + + + + +
+ + + + + + +
+ +
+
+
+
+ + +
+
+ + + + + + + + +
+ + + + + + +
+ + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + +
+
Confirm your FlowiseAI email change +
+
+
+ Hi there! 👋, +

+ You requested to change the email on your FlowiseAI account to + {{newEmail}}. If you didn't make the request, you + can safely ignore this email. +

+ If this was you, confirm the change using the link below: +
    +
  1. Visit the following link (or click the button below):
  2. + {{confirmLink}} +
  3. Your sign-in email will be updated after you confirm
  4. +
+
+
+
+
+ + +
+
+ + + + + + + + +
+ + + + + + +
+ + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ Confirm email change +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + +
+ + + + + + +
+ + + + + + +
+
+

+ The FlowiseAI Team +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + +
+ + + + + + +
+ + +
+ + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + +
+
+
+
+ + +
+ + + + + + +
+ + + + + + +
+
  +
+
+
+
+ + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+
+
+
+ + +
+ + +
+
+ + + + +
+ + \ No newline at end of file diff --git a/packages/server/src/enterprise/emails/confirm_email_change.html b/packages/server/src/enterprise/emails/confirm_email_change.html new file mode 100644 index 00000000000..4bc581b3f0b --- /dev/null +++ b/packages/server/src/enterprise/emails/confirm_email_change.html @@ -0,0 +1,1282 @@ + + + + FlowiseAI + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+ + + + + + +
+ +
+
+
+
+ + +
+
+ + + + + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + +
+
+ Confirm your FlowiseAI email change +
+
+
+ Hi there! 👋,

+ You requested to change the email on your FlowiseAI account to + {{newEmail}}. If you didn't make the request, you + can safely ignore this email.

+ If this was you, confirm the change using the link below: +
    +
  1. Visit the following link (or click the button below):
  2. + {{confirmLink}} +
  3. Your sign-in email will be updated after you confirm
  4. +
+
+
+
+
+ + +
+
+ + + + + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + Confirm email change + +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+
+

+ The FlowiseAI Team +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + +
+ + + + + + +
+ +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + +
+
+
+
+ + +
+ + + + + + +
+ + + + + + +
+
+   +
+
+
+
+ + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+
+
+
+ + +
+ + +
+
+ + + + +
+ + diff --git a/packages/server/src/enterprise/routes/account.route.ts b/packages/server/src/enterprise/routes/account.route.ts index 57dbdce07f7..349468e8e90 100644 --- a/packages/server/src/enterprise/routes/account.route.ts +++ b/packages/server/src/enterprise/routes/account.route.ts @@ -22,6 +22,8 @@ router.post('/logout', accountController.logout) router.post('/verify', accountController.verify) +router.post('/confirm-email-change', accountController.confirmEmailChange) + router.post('/resend-verification', accountController.resendVerificationEmail) router.post('/forgot-password', accountController.forgotPassword) diff --git a/packages/server/src/enterprise/services/account.service.ts b/packages/server/src/enterprise/services/account.service.ts index d6c5c135fc8..3a5284c04ad 100644 --- a/packages/server/src/enterprise/services/account.service.ts +++ b/packages/server/src/enterprise/services/account.service.ts @@ -1,4 +1,5 @@ import bcrypt from 'bcryptjs' +import jwt, { JwtPayload } from 'jsonwebtoken' import { StatusCodes } from 'http-status-codes' import moment from 'moment' import { DataSource, QueryRunner } from 'typeorm' @@ -17,8 +18,17 @@ import { WorkspaceUser, WorkspaceUserStatus } from '../database/entities/workspa import { Workspace, WorkspaceName } from '../database/entities/workspace.entity' import { LoggedInUser, LoginActivityCode } from '../Interface.Enterprise' import { destroyAllSessionsForUser } from '../middleware/passport/SessionPersistance' +import { getJWTAuthTokenSecret } from '../utils/authSecrets' import { compareHash, getHash, getPasswordSaltRounds, hashNeedsUpgrade } from '../utils/encryption.util' -import { sendPasswordResetEmail, sendVerificationEmailForCloud, sendWorkspaceAdd, sendWorkspaceInvite } from '../utils/sendEmail' +import { EMAIL_CHANGE_JWT_TYP, isEmailChangeJwtShape, signEmailChangeJwt, verifyEmailChangeJwt } from '../utils/emailChangeJwt.util' +import { + isSmtpConfigured, + sendEmailChangeConfirmationEmail, + sendPasswordResetEmail, + sendVerificationEmailForCloud, + sendWorkspaceAdd, + sendWorkspaceInvite +} from '../utils/sendEmail' import { generateTempToken } from '../utils/tempTokenUtils' import { getSecureAppUrl, getSecureTokenLink } from '../utils/url.util' import { validatePasswordOrThrow } from '../utils/validation.util' @@ -26,6 +36,7 @@ import auditService from './audit' import { OrganizationUserErrorMessage, OrganizationUserService } from './organization-user.service' import { OrganizationErrorMessage, OrganizationService } from './organization.service' import { RoleErrorMessage, RoleService } from './role.service' +import { sanitizeUser } from '../../utils/sanitize.util' import { UserErrorMessage, UserService } from './user.service' import { WorkspaceUserErrorMessage, WorkspaceUserService } from './workspace-user.service' import { WorkspaceErrorMessage, WorkspaceService } from './workspace.service' @@ -64,6 +75,32 @@ export class AccountService { this.identityManager = appServer.identityManager } + /** Cloud always sends; open source / enterprise require SMTP to be configured. */ + private canSendTransactionalEmail(): boolean { + return this.identityManager.getPlatformType() === Platform.CLOUD || isSmtpConfigured() + } + + private async sendInviteEmailIfAllowed(send: () => Promise, context: string) { + if (this.canSendTransactionalEmail()) { + await send() + } else { + logger.warn(`Skipping transactional email (${context}): SMTP is not configured`) + } + } + + /** Prevents email-change JWTs from being consumed by verify / reset-password flows. */ + private assertNotEmailChangeJwt(token: string | undefined | null) { + if (!isEmailChangeJwtShape(token)) return + try { + const payload = jwt.verify(token, getJWTAuthTokenSecret()) as JwtPayload + if (payload.typ === EMAIL_CHANGE_JWT_TYP) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.EMAIL_CHANGE_USE_CONFIRM_LINK) + } + } catch (err) { + if (err instanceof InternalFlowiseError) throw err + } + } + private initializeAccountDTO(data: AccountDTO) { data.organization = data.organization || {} data.organizationUser = data.organizationUser || {} @@ -75,6 +112,9 @@ export class AccountService { } public async resendVerificationEmail({ email }: { email: string }) { + if (!this.canSendTransactionalEmail()) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, GeneralErrorMessage.SMTP_NOT_CONFIGURED) + } const queryRunner = this.dataSource.createQueryRunner() await queryRunner.connect() try { @@ -317,7 +357,10 @@ export class AccountService { this.identityManager.getPlatformType() === Platform.ENTERPRISE ? getSecureTokenLink('/register', data.user.tempToken!) : getSecureAppUrl('/register') - await sendWorkspaceInvite(data.user.email!, data.workspace.name!, registerLink, this.identityManager.getPlatformType()) + await this.sendInviteEmailIfAllowed( + () => sendWorkspaceInvite(data.user.email!, data.workspace.name!, registerLink, this.identityManager.getPlatformType()), + 'workspace-invite' + ) data.user = await this.userService.createNewUser(data.user, queryRunner) data.organizationUser.organizationId = data.workspace.organizationId @@ -391,29 +434,49 @@ export class AccountService { if (workspaceUser.length === 1) { oldWorkspaceUser = workspaceUser[0] if (oldWorkspaceUser.workspace.name === WorkspaceName.DEFAULT_PERSONAL_WORKSPACE) { - await sendWorkspaceInvite( - data.user.email!, - data.workspace.name!, - registerLink, - this.identityManager.getPlatformType() + await this.sendInviteEmailIfAllowed( + () => + sendWorkspaceInvite( + data.user.email!, + data.workspace.name!, + registerLink, + this.identityManager.getPlatformType() + ), + 'workspace-invite' ) } else { - await sendWorkspaceInvite( - data.user.email!, - data.workspace.name!, - registerLink, - this.identityManager.getPlatformType(), - 'update' + await this.sendInviteEmailIfAllowed( + () => + sendWorkspaceInvite( + data.user.email!, + data.workspace.name!, + registerLink, + this.identityManager.getPlatformType(), + 'update' + ), + 'workspace-invite-update' ) } } else { - await sendWorkspaceInvite(data.user.email!, data.workspace.name!, registerLink, this.identityManager.getPlatformType()) + await this.sendInviteEmailIfAllowed( + () => + sendWorkspaceInvite( + data.user.email!, + data.workspace.name!, + registerLink, + this.identityManager.getPlatformType() + ), + 'workspace-invite' + ) } } else { data.organizationUser.updatedBy = data.user.createdBy const dashboardLink = getSecureAppUrl() - await sendWorkspaceAdd(data.user.email!, data.workspace.name!, dashboardLink) + await this.sendInviteEmailIfAllowed( + () => sendWorkspaceAdd(data.user.email!, data.workspace.name!, dashboardLink), + 'workspace-add' + ) } workspace.updatedBy = data.user.createdBy @@ -514,6 +577,7 @@ export class AccountService { try { await queryRunner.startTransaction() if (!data.user.tempToken) throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.INVALID_TEMP_TOKEN) + this.assertNotEmailChangeJwt(data.user.tempToken) const user = await this.userService.readUserByToken(data.user.tempToken, queryRunner) if (!user) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND) data.user = user @@ -534,6 +598,9 @@ export class AccountService { public async forgotPassword(data: AccountDTO) { data = this.initializeAccountDTO(data) + if (!this.canSendTransactionalEmail()) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, GeneralErrorMessage.SMTP_NOT_CONFIGURED) + } const queryRunner = this.dataSource.createQueryRunner() await queryRunner.connect() try { @@ -569,6 +636,7 @@ export class AccountService { await queryRunner.connect() try { if (!data.user.tempToken) throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.INVALID_TEMP_TOKEN) + this.assertNotEmailChangeJwt(data.user.tempToken) const user = await this.userService.readUserByEmail(data.user.email, queryRunner) if (!user) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND) @@ -625,6 +693,180 @@ export class AccountService { } } + public async initiateEmailChange(userId: string, newEmail: string) { + const queryRunner = this.dataSource.createQueryRunner() + await queryRunner.connect() + try { + await queryRunner.startTransaction() + const user = await this.userService.readUserById(userId, queryRunner) + if (!user?.email) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND) + + const expiryInHours = process.env.INVITE_TOKEN_EXPIRY_IN_HOURS ? parseInt(process.env.INVITE_TOKEN_EXPIRY_IN_HOURS) : 24 + const { token, tokenExpiry } = signEmailChangeJwt(userId, newEmail, expiryInHours) + + const merged = queryRunner.manager.merge(User, user, { + tempToken: token, + tokenExpiry + }) + await this.userService.saveUser(merged, queryRunner) + + const confirmLink = getSecureTokenLink('/confirm-email-change', token) + await sendEmailChangeConfirmationEmail(user.email, confirmLink, newEmail) + + await queryRunner.commitTransaction() + } catch (error) { + if (queryRunner.isTransactionActive) await queryRunner.rollbackTransaction() + throw error + } finally { + await queryRunner.release() + } + } + + public async confirmEmailChange(data: { user: { tempToken?: string } }) { + const token = data.user?.tempToken + if (!token) throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.INVALID_TEMP_TOKEN) + + let userId: string + let newEmail: string + try { + ;({ userId, newEmail } = verifyEmailChangeJwt(token)) + } catch (e) { + if (e instanceof jwt.TokenExpiredError) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.EXPIRED_TEMP_TOKEN) + } + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.INVALID_TEMP_TOKEN) + } + + const queryRunner = this.dataSource.createQueryRunner() + await queryRunner.connect() + try { + const user = await this.userService.readUserById(userId, queryRunner) + if (!user || user.tempToken !== token) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND) + + const taken = await this.userService.readUserByEmail(newEmail, queryRunner) + if (taken && taken.id !== user.id) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.USER_EMAIL_ALREADY_EXISTS) + } + + await this.userService.updateUser( + { + id: user.id, + updatedBy: user.id, + email: newEmail, + tempToken: null, + tokenExpiry: null + }, + { + onEmailChanged: (uid, em) => this.syncStripeCustomerEmailAfterUserEmailChange(uid, em) + } + ) + + return { message: 'success' } + } finally { + await queryRunner.release() + } + } + + public async updateAuthenticatedUserProfile( + currentUserId: string, + body: Partial & { oldPassword?: string; newPassword?: string; confirmPassword?: string }, + onEmailChanged: (userId: string, newEmail: string) => Promise + ) { + const queryRunner = this.dataSource.createQueryRunner() + await queryRunner.connect() + try { + const dbUser = await this.userService.readUserById(currentUserId, queryRunner) + if (!dbUser) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND) + + const platform = this.identityManager.getPlatformType() + const newEmailRaw = body.email?.trim() + const emailChanging = newEmailRaw !== undefined && newEmailRaw.toLowerCase() !== (dbUser.email || '').toLowerCase() + + const useEmailChangeConfirmation = emailChanging && (platform === Platform.CLOUD || isSmtpConfigured()) + + const passwordChanging = !!(body.oldPassword && body.newPassword && body.confirmPassword) + const nameChanging = body.name !== undefined && body.name !== dbUser.name + + if (emailChanging && useEmailChangeConfirmation) { + this.userService.validateUserEmail(newEmailRaw) + const taken = await this.userService.readUserByEmail(newEmailRaw, queryRunner) + if (taken && taken.id !== dbUser.id) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.USER_EMAIL_ALREADY_EXISTS) + } + + if (passwordChanging || nameChanging) { + await this.userService.updateUser( + { + id: currentUserId, + updatedBy: currentUserId, + name: body.name !== undefined ? body.name : dbUser.name, + email: dbUser.email, + oldPassword: body.oldPassword, + newPassword: body.newPassword, + confirmPassword: body.confirmPassword + }, + {} + ) + } + + await this.initiateEmailChange(currentUserId, newEmailRaw!) + + const readRunner = this.dataSource.createQueryRunner() + await readRunner.connect() + try { + const refreshed = await this.userService.readUserById(currentUserId, readRunner) + return { + user: sanitizeUser({ ...refreshed }) as Partial, + emailChangePending: true, + pendingEmail: newEmailRaw + } + } finally { + await readRunner.release() + } + } + + if (emailChanging && !useEmailChangeConfirmation) { + this.userService.validateUserEmail(newEmailRaw) + const taken = await this.userService.readUserByEmail(newEmailRaw, queryRunner) + if (taken && taken.id !== dbUser.id) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.USER_EMAIL_ALREADY_EXISTS) + } + + const user = await this.userService.updateUser( + { + id: currentUserId, + updatedBy: currentUserId, + ...(body.name !== undefined ? { name: body.name } : {}), + email: body.email, + oldPassword: body.oldPassword, + newPassword: body.newPassword, + confirmPassword: body.confirmPassword, + tempToken: null, + tokenExpiry: null + }, + { onEmailChanged } + ) + return { user } + } + + const user = await this.userService.updateUser( + { + id: currentUserId, + updatedBy: currentUserId, + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.email !== undefined ? { email: body.email } : {}), + oldPassword: body.oldPassword, + newPassword: body.newPassword, + confirmPassword: body.confirmPassword + }, + {} + ) + return { user } + } finally { + await queryRunner.release() + } + } + /** * Sync Stripe customer email when user changes their email (CLOUD only). * Expects exactly one org where the user is org owner; updates that org's Stripe customer email. diff --git a/packages/server/src/enterprise/services/user.service.ts b/packages/server/src/enterprise/services/user.service.ts index 5d983cbefdf..aa2b5d1d38c 100644 --- a/packages/server/src/enterprise/services/user.service.ts +++ b/packages/server/src/enterprise/services/user.service.ts @@ -25,7 +25,8 @@ export const enum UserErrorMessage { USER_NOT_FOUND = 'User Not Found', USER_FOUND_MULTIPLE = 'User Found Multiple', INCORRECT_USER_EMAIL_OR_CREDENTIALS = 'Incorrect Email or Password', - PASSWORDS_DO_NOT_MATCH = 'Passwords do not match' + PASSWORDS_DO_NOT_MATCH = 'Passwords do not match', + EMAIL_CHANGE_USE_CONFIRM_LINK = 'Use the confirm email change link from your email to complete this action.' } export class UserService { private telemetry: Telemetry diff --git a/packages/server/src/enterprise/utils/emailChangeJwt.util.ts b/packages/server/src/enterprise/utils/emailChangeJwt.util.ts new file mode 100644 index 00000000000..73c3250c9e6 --- /dev/null +++ b/packages/server/src/enterprise/utils/emailChangeJwt.util.ts @@ -0,0 +1,28 @@ +import jwt, { JwtPayload } from 'jsonwebtoken' +import { getJWTAuthTokenSecret } from './authSecrets' + +export const EMAIL_CHANGE_JWT_TYP = 'email_change' + +export function signEmailChangeJwt(userId: string, newEmail: string, expiryHours: number): { token: string; tokenExpiry: Date } { + const secret = getJWTAuthTokenSecret() + const token = jwt.sign({ typ: EMAIL_CHANGE_JWT_TYP, sub: userId, newEmail }, secret, { + expiresIn: `${expiryHours}h` + }) + const decoded = jwt.decode(token) as JwtPayload + if (!decoded?.exp) throw new Error('Failed to decode email change token') + const tokenExpiry = new Date(decoded.exp * 1000) + return { token, tokenExpiry } +} + +export function verifyEmailChangeJwt(token: string): { userId: string; newEmail: string } { + const secret = getJWTAuthTokenSecret() + const payload = jwt.verify(token, secret) as JwtPayload + if (payload.typ !== EMAIL_CHANGE_JWT_TYP || typeof payload.newEmail !== 'string' || typeof payload.sub !== 'string') { + throw new jwt.JsonWebTokenError('Invalid email change token payload') + } + return { userId: payload.sub, newEmail: payload.newEmail } +} + +export function isEmailChangeJwtShape(token: string | undefined | null): token is string { + return Boolean(token && token.split('.').length === 3) +} diff --git a/packages/server/src/enterprise/utils/sendEmail.ts b/packages/server/src/enterprise/utils/sendEmail.ts index 2292dd25ff9..d60ae34201b 100644 --- a/packages/server/src/enterprise/utils/sendEmail.ts +++ b/packages/server/src/enterprise/utils/sendEmail.ts @@ -8,6 +8,17 @@ const SMTP_HOST = process.env.SMTP_HOST const SMTP_PORT = parseInt(process.env.SMTP_PORT as string, 10) const SMTP_USER = process.env.SMTP_USER const SMTP_PASSWORD = process.env.SMTP_PASSWORD + +export const isSmtpConfigured = (): boolean => { + const port = parseInt(process.env.SMTP_PORT as string, 10) + return Boolean( + process.env.SMTP_HOST?.trim() && + process.env.SMTP_USER?.trim() && + process.env.SMTP_PASSWORD?.trim() && + process.env.SMTP_PORT && + !Number.isNaN(port) + ) +} const SENDER_EMAIL = process.env.SENDER_EMAIL const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : true const TLS = process.env.ALLOW_UNAUTHORIZED_CERTS ? { rejectUnauthorized: false } : undefined @@ -99,6 +110,21 @@ const sendPasswordResetEmail = async (email: string, resetLink: string) => { }) } +const sendEmailChangeConfirmationEmail = async (email: string, confirmLink: string, newEmail: string) => { + const template = getEmailTemplate('confirm_email_change.hbs') + const compiled = handlebars.compile(template) + const htmlToSend = compiled({ confirmLink, newEmail }) + const textContent = `Confirm your email change to ${newEmail}: ${confirmLink}` + + await transporter.sendMail({ + from: SENDER_EMAIL || '"FlowiseAI Team" ', + to: email, + subject: 'Confirm your email address change', + text: textContent, + html: htmlToSend + }) +} + const sendVerificationEmailForCloud = async (email: string, verificationLink: string) => { let htmlToSend let textContent @@ -117,4 +143,4 @@ const sendVerificationEmailForCloud = async (email: string, verificationLink: st }) } -export { sendWorkspaceAdd, sendWorkspaceInvite, sendPasswordResetEmail, sendVerificationEmailForCloud } +export { sendWorkspaceAdd, sendWorkspaceInvite, sendPasswordResetEmail, sendVerificationEmailForCloud, sendEmailChangeConfirmationEmail } diff --git a/packages/server/src/enterprise/utils/url.util.test.ts b/packages/server/src/enterprise/utils/url.util.test.ts index 513e74623ff..c9141ca367f 100644 --- a/packages/server/src/enterprise/utils/url.util.test.ts +++ b/packages/server/src/enterprise/utils/url.util.test.ts @@ -100,11 +100,11 @@ describe('URL Security Utilities', () => { expect(result).toBe('http://localhost:3000/verify?token=xyz789') }) - it('should handle complex tokens', () => { + it('should encode tokens for use in query strings', () => { process.env.APP_URL = 'https://example.com' const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0' const result = getSecureTokenLink('/register', token) - expect(result).toBe(`https://example.com/register?token=${token}`) + expect(result).toBe(`https://example.com/register?token=${encodeURIComponent(token)}`) }) }) diff --git a/packages/server/src/enterprise/utils/url.util.ts b/packages/server/src/enterprise/utils/url.util.ts index 1508043b860..f018ac2e059 100644 --- a/packages/server/src/enterprise/utils/url.util.ts +++ b/packages/server/src/enterprise/utils/url.util.ts @@ -59,5 +59,5 @@ export function getSecureAppUrl(path?: string): string { */ export function getSecureTokenLink(path: string, token: string): string { const baseUrl = getSecureAppUrl(path) - return `${baseUrl}?token=${token}` + return `${baseUrl}?token=${encodeURIComponent(token)}` } diff --git a/packages/server/src/utils/constants.ts b/packages/server/src/utils/constants.ts index 17caf4834ae..0f9b7e96f50 100644 --- a/packages/server/src/utils/constants.ts +++ b/packages/server/src/utils/constants.ts @@ -33,6 +33,7 @@ export const WHITELIST_URLS = [ '/api/v1/account/resend-verification', '/api/v1/account/forgot-password', '/api/v1/account/reset-password', + '/api/v1/account/confirm-email-change', '/api/v1/account/basic-auth', '/api/v1/loginmethod/default', '/api/v1/pricing', @@ -63,7 +64,8 @@ export const enum GeneralErrorMessage { UNHANDLED_EDGE_CASE = 'Unhandled Edge Case', INVALID_PASSWORD = 'Invalid Password', NOT_ALLOWED_TO_DELETE_OWNER = 'Not Allowed To Delete Owner', - INTERNAL_SERVER_ERROR = 'Internal Server Error' + INTERNAL_SERVER_ERROR = 'Internal Server Error', + SMTP_NOT_CONFIGURED = 'Email (SMTP) is not configured on this server' } export const enum GeneralSuccessMessage { From eb1d4438cf66f106d7a334b9efaf281847fb8c08 Mon Sep 17 00:00:00 2001 From: Ilango Rajagopal Date: Tue, 24 Mar 2026 14:57:30 +0530 Subject: [PATCH 5/5] add: confirm email change page. update: change email behavior in account settings --- packages/ui/src/api/account.api.js | 2 + packages/ui/src/routes/AuthRoutes.jsx | 5 + packages/ui/src/views/account/index.jsx | 34 ++++- .../src/views/auth/confirm-email-change.jsx | 128 ++++++++++++++++++ 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/views/auth/confirm-email-change.jsx diff --git a/packages/ui/src/api/account.api.js b/packages/ui/src/api/account.api.js index 2f6f62066ab..212d018db95 100644 --- a/packages/ui/src/api/account.api.js +++ b/packages/ui/src/api/account.api.js @@ -3,6 +3,7 @@ import client from '@/api/client' const inviteAccount = (body) => client.post(`/account/invite`, body) const registerAccount = (body) => client.post(`/account/register`, body) const verifyAccountEmail = (body) => client.post('/account/verify', body) +const confirmEmailChange = (body) => client.post('/account/confirm-email-change', body) const resendVerificationEmail = (body) => client.post('/account/resend-verification', body) const forgotPassword = (body) => client.post('/account/forgot-password', body) const resetPassword = (body) => client.post('/account/reset-password', body) @@ -16,6 +17,7 @@ export default { inviteAccount, registerAccount, verifyAccountEmail, + confirmEmailChange, resendVerificationEmail, forgotPassword, resetPassword, diff --git a/packages/ui/src/routes/AuthRoutes.jsx b/packages/ui/src/routes/AuthRoutes.jsx index eb303b98c13..404a8a52a76 100644 --- a/packages/ui/src/routes/AuthRoutes.jsx +++ b/packages/ui/src/routes/AuthRoutes.jsx @@ -7,6 +7,7 @@ const ResolveLoginPage = Loadable(lazy(() => import('@/views/auth/login'))) const SignInPage = Loadable(lazy(() => import('@/views/auth/signIn'))) const RegisterPage = Loadable(lazy(() => import('@/views/auth/register'))) const VerifyEmailPage = Loadable(lazy(() => import('@/views/auth/verify-email'))) +const ConfirmEmailChangePage = Loadable(lazy(() => import('@/views/auth/confirm-email-change'))) const ForgotPasswordPage = Loadable(lazy(() => import('@/views/auth/forgotPassword'))) const ResetPasswordPage = Loadable(lazy(() => import('@/views/auth/resetPassword'))) const UnauthorizedPage = Loadable(lazy(() => import('@/views/auth/unauthorized'))) @@ -34,6 +35,10 @@ const AuthRoutes = { path: '/verify', element: }, + { + path: '/confirm-email-change', + element: + }, { path: '/forgot-password', element: diff --git a/packages/ui/src/views/account/index.jsx b/packages/ui/src/views/account/index.jsx index b967207734b..bb0718522d1 100644 --- a/packages/ui/src/views/account/index.jsx +++ b/packages/ui/src/views/account/index.jsx @@ -221,8 +221,29 @@ const AccountSettings = () => { email: email } const saveProfileResp = await userApi.updateUser(obj) - if (saveProfileResp.data) { - store.dispatch(userProfileUpdated(saveProfileResp.data)) + const payload = saveProfileResp.data + if (payload?.user) { + store.dispatch(userProfileUpdated(payload.user)) + const pendingMsg = + payload.emailChangePending && + `Check your current email (${payload.user.email}) to confirm the change to ${payload.pendingEmail}.` + enqueueSnackbar({ + message: pendingMsg || 'Profile updated', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + if (payload.user.email) { + setEmail(payload.user.email) + } + } else if (payload) { + store.dispatch(userProfileUpdated(payload)) enqueueSnackbar({ message: 'Profile updated', options: { @@ -235,6 +256,9 @@ const AccountSettings = () => { ) } }) + if (payload.email) { + setEmail(payload.email) + } } } catch (error) { enqueueSnackbar({ @@ -292,8 +316,10 @@ const AccountSettings = () => { confirmPassword } const saveProfileResp = await userApi.updateUser(obj) - if (saveProfileResp.data) { - store.dispatch(userProfileUpdated(saveProfileResp.data)) + const pwdPayload = saveProfileResp.data + const updatedUser = pwdPayload?.user ?? pwdPayload + if (updatedUser) { + store.dispatch(userProfileUpdated(updatedUser)) setOldPassword('') setNewPassword('') setConfirmPassword('') diff --git a/packages/ui/src/views/auth/confirm-email-change.jsx b/packages/ui/src/views/auth/confirm-email-change.jsx new file mode 100644 index 00000000000..277ca5aa4d2 --- /dev/null +++ b/packages/ui/src/views/auth/confirm-email-change.jsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' + +// material-ui +import { Stack, Typography, Box, useTheme, CircularProgress } from '@mui/material' + +// project imports +import MainCard from '@/ui-component/cards/MainCard' + +// API +import accountApi from '@/api/account.api' + +// Hooks +import useApi from '@/hooks/useApi' + +// icons +import { IconCheck, IconX } from '@tabler/icons-react' + +const ConfirmEmailChange = () => { + const confirmApi = useApi(accountApi.confirmEmailChange) + + const [searchParams] = useSearchParams() + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [success, setSuccess] = useState(false) + const navigate = useNavigate() + + const theme = useTheme() + + useEffect(() => { + if (confirmApi.data) { + setLoading(false) + setErrorMessage('') + setSuccess(true) + setTimeout(() => { + navigate('/signin') + }, 3000) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [confirmApi.data]) + + useEffect(() => { + if (confirmApi.error) { + setLoading(false) + setErrorMessage(confirmApi.error) + setSuccess(false) + } + }, [confirmApi.error]) + + useEffect(() => { + const token = searchParams.get('token') + if (token) { + setLoading(true) + setErrorMessage('') + setSuccess(false) + confirmApi.request({ user: { tempToken: token } }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + + + + {loading && ( + <> + + Confirming email change... + + )} + {errorMessage && ( + <> + + + + Confirmation failed. + + {errorMessage} + + + )} + {success && ( + <> + + + + Email updated successfully. + + Please sign in with your new email address. + + + )} + + + + + ) +} + +export default ConfirmEmailChange