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:
Ilango
2026-04-01 20:16:13 +05:30
committed by GitHub
co-authored by yau-wd
parent dc07f4062b
commit 6d668fbba1
18 changed files with 2712 additions and 31 deletions
+5
View File
@@ -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) {
+5
View File
@@ -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)}`
}
+3 -1
View File
@@ -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 {
+2
View File
@@ -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,
+5
View File
@@ -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 />
+30 -4
View File
@@ -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