mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
feat: email change confirmation flow (#5992)
* fix: update stripe email when changing email in account settings * fix: review feedback * fix: circular dependency, destroy all sessions on email change * add: email change confirmation flow + smtp available check + enable email flows for oss and enterprise * add: confirm email change page. update: change email behavior in account settings --------- Co-authored-by: yau-wd <[email protected]>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({ id, name, updatedBy: currentUser.id, oldPassword, newPassword, confirmPassword })
|
||||
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)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
@@ -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<void>, 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<User> & { oldPassword?: string; newPassword?: string; confirmPassword?: string },
|
||||
onEmailChanged: (userId: string, newEmail: string) => Promise<void>
|
||||
) {
|
||||
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<User>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<User> & { oldPassword?: string; newPassword?: string; confirmPassword?: string }) {
|
||||
public async updateUser(
|
||||
newUserData: Partial<User> & { oldPassword?: string; newPassword?: string; confirmPassword?: string },
|
||||
options?: { onEmailChanged?: (userId: string, newEmail: string) => Promise<void> }
|
||||
) {
|
||||
let queryRunner: QueryRunner | undefined
|
||||
let updatedUser: Partial<User>
|
||||
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)
|
||||
@@ -191,8 +197,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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" <[email protected]>',
|
||||
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 }
|
||||
|
||||
@@ -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)}`)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: <VerifyEmailPage />
|
||||
},
|
||||
{
|
||||
path: '/confirm-email-change',
|
||||
element: <ConfirmEmailChangePage />
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
element: <ForgotPasswordPage />
|
||||
|
||||
@@ -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) => (
|
||||
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
|
||||
<IconX />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
})
|
||||
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('')
|
||||
|
||||
@@ -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 (
|
||||
<MainCard>
|
||||
<Stack flexDirection='column' sx={{ width: '480px', gap: 3 }}>
|
||||
<Stack sx={{ width: '100%', alignItems: 'center', justifyContent: 'center', gap: 4 }}>
|
||||
<Stack sx={{ alignItems: 'center', gap: 2 }}>
|
||||
{loading && (
|
||||
<>
|
||||
<CircularProgress
|
||||
sx={{
|
||||
width: '48px',
|
||||
height: '48px'
|
||||
}}
|
||||
/>
|
||||
<Typography variant='h1'>Confirming email change...</Typography>
|
||||
</>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '100%',
|
||||
backgroundColor: theme.palette.error.main,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<IconX />
|
||||
</Box>
|
||||
<Typography variant='h1'>Confirmation failed.</Typography>
|
||||
<Typography variant='body2' color='textSecondary' sx={{ textAlign: 'center' }}>
|
||||
{errorMessage}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{success && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '100%',
|
||||
backgroundColor: theme.palette.success.main,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<IconCheck />
|
||||
</Box>
|
||||
<Typography variant='h1'>Email updated successfully.</Typography>
|
||||
<Typography variant='body2' color='textSecondary' sx={{ textAlign: 'center' }}>
|
||||
Please sign in with your new email address.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</MainCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmEmailChange
|
||||
Reference in New Issue
Block a user