diff --git a/packages/server/src/IdentityManager.ts b/packages/server/src/IdentityManager.ts
index c56903be9c2..f89453572b5 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) throw new Error('Stripe manager is not initialized')
+ 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..5b563c2e8c5 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) throw new Error('Stripe is not initialized')
+ 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/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 2acc458bb3b..a10d19f6c99 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 {
@@ -51,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)
@@ -60,8 +60,11 @@ export class UserController {
if (currentUser.id !== id) {
throw new InternalFlowiseError(StatusCodes.FORBIDDEN, UserErrorMessage.USER_NOT_FOUND)
}
- const user = await userService.updateUser(req.body)
- return res.status(StatusCodes.OK).json(user)
+ const accountService = new AccountService()
+ 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:
+
+ - Visit the following link (or click the button below):
+ {{confirmLink}}
+ - Your sign-in email will be updated after you confirm
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ 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:
+
+ - Visit the following link (or click the button below):
+ {{confirmLink}}
+ - Your sign-in email will be updated after you confirm
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ 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 7ca6b25fe76..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)
@@ -624,4 +692,204 @@ 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.
+ */
+ 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 bb88988f6f7..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
@@ -136,7 +137,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 {
@@ -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)
@@ -181,8 +187,14 @@ 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) {
+ 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)
+ }
+
+ if (passwordChanged || emailChanged) {
await destroyAllSessionsForUser(updatedUser.id as string)
}
} catch (error) {
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 {
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