feat: Wire up the email change confirmation flow (#38730)

This commit is contained in:
Andreas Fitzek 2026-09-16 15:49:37 +00:00 committed by GitHub
parent 1d029046f8
commit ccaeeb7eb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 654 additions and 583 deletions

View File

@ -1,26 +1,10 @@
import { UserUpdateRequestDto } from '../user-update-request.dto';
describe('UserUpdateRequestDto', () => {
it('should fail validation for an invalid email', () => {
const invalidRequest = {
email: 'invalid-email',
firstName: 'John',
lastName: 'Doe',
mfaCode: '123456',
};
const result = UserUpdateRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['email']);
});
it('should fail validation for a firstName with potential XSS attack', () => {
const invalidRequest = {
email: 'test@example.com',
firstName: '<script>alert("XSS")</script>',
lastName: 'Doe',
mfaCode: '123456',
};
const result = UserUpdateRequestDto.safeParse(invalidRequest);
@ -31,10 +15,8 @@ describe('UserUpdateRequestDto', () => {
it('should fail validation for a firstName with a URL', () => {
const invalidRequest = {
email: 'test@example.com',
firstName: 'test http://malicious.com',
lastName: 'Doe',
mfaCode: '123456',
};
const result = UserUpdateRequestDto.safeParse(invalidRequest);
@ -45,10 +27,8 @@ describe('UserUpdateRequestDto', () => {
it('should fail validation for a lastName with potential XSS attack', () => {
const invalidRequest = {
email: 'test@example.com',
firstName: 'John',
lastName: '<script>alert("XSS")</script>',
mfaCode: '123456',
};
const result = UserUpdateRequestDto.safeParse(invalidRequest);
@ -59,10 +39,8 @@ describe('UserUpdateRequestDto', () => {
it('should fail validation for a lastName with a URL', () => {
const invalidRequest = {
email: 'test@example.com',
firstName: 'John',
lastName: 'testing http://malicious.com',
mfaCode: '123456',
};
const result = UserUpdateRequestDto.safeParse(invalidRequest);
@ -73,14 +51,25 @@ describe('UserUpdateRequestDto', () => {
it('should validate a valid user update request', () => {
const validRequest = {
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
mfaCode: '123456',
};
const result = UserUpdateRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
it('should strip an email field from the request', () => {
const requestWithEmail = {
firstName: 'John',
lastName: 'Doe',
email: 'test@example.com',
};
const result = UserUpdateRequestDto.safeParse(requestWithEmail);
expect(result.success).toBe(true);
expect(result.data).not.toHaveProperty('email');
});
});

View File

@ -25,12 +25,6 @@ const nameSchema = () =>
});
export class UserUpdateRequestDto extends Z.class({
email: z.string().email(),
firstName: nameSchema().optional(),
lastName: nameSchema().optional(),
mfaCode: z.string().optional(),
/**
* The current password is required when changing the email address and MFA is disabled.
*/
currentPassword: z.string().optional(),
}) {}

View File

@ -48,7 +48,7 @@ describe('MeController', () => {
});
describe('updateCurrentUser', () => {
it('should update the user in the DB, and issue a new cookie', async () => {
it('should update the user name in the DB', async () => {
const user = {
id: '123',
email: 'valid@email.com',
@ -58,7 +58,6 @@ describe('MeController', () => {
mfaEnabled: false,
} as unknown as User;
const payload = new UserUpdateRequestDto({
email: 'valid@email.com',
firstName: 'John',
lastName: 'Potato',
});
@ -66,7 +65,6 @@ describe('MeController', () => {
const res = mock<Response>();
userRepository.findOneByOrFail.mockResolvedValue(user);
userService.findUserWithAuthIdentities.mockResolvedValue(user);
vi.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
await controller.updateCurrentUser(req, res, payload);
@ -80,18 +78,8 @@ describe('MeController', () => {
expect(userService.update).toHaveBeenCalled();
expect(eventService.emit).toHaveBeenCalledWith('user-updated', {
user,
fieldsChanged: ['firstName', 'lastName'], // email did not change
fieldsChanged: ['firstName', 'lastName'],
});
expect(res.cookie).toHaveBeenCalledWith(
AUTH_COOKIE_NAME,
'signed-token',
expect.objectContaining({
maxAge: expect.any(Number),
httpOnly: true,
sameSite: 'lax',
secure: false,
}),
);
expect(externalHooks.run).toHaveBeenCalledWith('user.profile.update', [
user.email,
@ -99,11 +87,49 @@ describe('MeController', () => {
]);
});
it('should not change the email when an email field is sent', async () => {
const user = {
id: '123',
email: 'valid@email.com',
firstName: 'John',
lastName: 'Doe',
password: 'password',
authIdentities: [],
role: GLOBAL_OWNER_ROLE,
mfaEnabled: false,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const res = mock<Response>();
userRepository.findOneByOrFail.mockResolvedValue(user);
userService.findUserWithAuthIdentities.mockResolvedValue(user);
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
// The DTO strips unknown keys, so a stray email never reaches the service.
const payload = new UserUpdateRequestDto({
firstName: 'Jane',
lastName: 'Doe',
email: 'new@email.com',
} as unknown as UserUpdateRequestDto);
await controller.updateCurrentUser(req, res, payload);
expect(userService.update).toHaveBeenCalledWith(user.id, {
firstName: 'Jane',
lastName: 'Doe',
});
expect(eventService.emit).toHaveBeenCalledWith('user-updated', {
user,
fieldsChanged: ['firstName'],
});
});
it('should throw BadRequestError if beforeUpdate hook throws BadRequestError', async () => {
const user = {
id: '123',
password: 'password',
email: 'current@email.com',
firstName: 'John',
lastName: 'Doe',
authIdentities: [],
role: GLOBAL_OWNER_ROLE,
mfaEnabled: false,
@ -112,17 +138,17 @@ describe('MeController', () => {
externalHooks.run.mockImplementationOnce(async (hookName) => {
if (hookName === 'user.profile.beforeUpdate') {
throw new BadRequestError('Invalid email address');
throw new BadRequestError('Invalid name');
}
});
const execution = controller.updateCurrentUser(
req,
mock(),
mock({ email: user.email, firstName: 'John', lastName: 'Potato' }),
mock({ firstName: 'Jane', lastName: 'Doe' }),
);
await expect(execution).rejects.toThrow(BadRequestError);
await expect(execution).rejects.toThrow('Invalid email address');
await expect(execution).rejects.toThrow('Invalid name');
});
describe('when user is authenticated via LDAP or OIDC', () => {
@ -148,7 +174,6 @@ describe('MeController', () => {
req,
mock(),
new UserUpdateRequestDto({
email: 'ldap@email.com',
firstName: 'Jane',
lastName: 'Doe',
}),
@ -182,8 +207,7 @@ describe('MeController', () => {
req,
mock(),
new UserUpdateRequestDto({
email: 'new-oidc@email.com',
firstName: 'John',
firstName: 'Jane',
lastName: 'Doe',
}),
);
@ -198,14 +222,15 @@ describe('MeController', () => {
const user = {
id: '123',
email: 'valid@email.com',
firstName: 'John',
lastName: 'Doe',
password: 'password',
authIdentities: [],
role: GLOBAL_OWNER_ROLE,
mfaEnabled: false,
} as unknown as User;
const payload = new UserUpdateRequestDto({
email: 'valid@email.com',
firstName: 'John',
firstName: 'Jane',
lastName: 'Potato',
});
const req = { user, browserId } as unknown as AuthenticatedRequest;
@ -213,7 +238,6 @@ describe('MeController', () => {
userRepository.findOneByOrFail.mockResolvedValue(user);
userService.findUserWithAuthIdentities.mockResolvedValue(user);
vi.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
await controller.updateCurrentUser(req, res, payload);
@ -244,7 +268,6 @@ describe('MeController', () => {
req,
mock(),
new UserUpdateRequestDto({
email: 'multi@email.com',
firstName: 'Jane',
lastName: 'Doe',
}),
@ -258,12 +281,9 @@ describe('MeController', () => {
});
describe('when an auth_identity exists but the SSO provider is no longer active', () => {
const passwordHash = '$2a$10$ffitcKrHT.Ls.m9FfWrMrOod76aaI0ogKbc3S96Q320impWpCbgj6'; // Hashed 'old_password'
const setUpdateMocks = (user: User) => {
userRepository.findOneByOrFail.mockResolvedValue(user);
userService.findUserWithAuthIdentities.mockResolvedValue(user);
vi.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
};
@ -289,7 +309,6 @@ describe('MeController', () => {
req,
mock(),
new UserUpdateRequestDto({
email: 'saml@email.com',
firstName: 'Jane',
lastName: 'Doe',
}),
@ -313,7 +332,6 @@ describe('MeController', () => {
mfaEnabled: false,
} as unknown as User;
const payload = new UserUpdateRequestDto({
email: 'saml@email.com',
firstName: 'NewFirst',
lastName: 'NewLast',
});
@ -328,7 +346,6 @@ describe('MeController', () => {
await controller.updateCurrentUser(req, res, payload);
expect(userService.update).toHaveBeenCalledWith(user.id, {
email: 'saml@email.com',
firstName: 'NewFirst',
lastName: 'NewLast',
});
@ -350,7 +367,6 @@ describe('MeController', () => {
mfaEnabled: false,
} as unknown as User;
const payload = new UserUpdateRequestDto({
email: 'ldap@email.com',
firstName: 'NewFirst',
lastName: 'NewLast',
});
@ -379,7 +395,6 @@ describe('MeController', () => {
mfaEnabled: false,
} as unknown as User;
const payload = new UserUpdateRequestDto({
email: 'oidc@email.com',
firstName: 'NewFirst',
lastName: 'NewLast',
});
@ -396,39 +411,6 @@ describe('MeController', () => {
expect(userService.update).toHaveBeenCalled();
});
it('should allow email change for previously-SAML user once SAML is disabled', async () => {
const user = {
id: '123',
email: 'saml-old@email.com',
firstName: 'John',
lastName: 'Doe',
password: passwordHash,
authIdentities: [],
role: GLOBAL_OWNER_ROLE,
mfaEnabled: false,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const res = mock<Response>();
userService.findSsoIdentity.mockResolvedValue({
providerType: 'saml',
} as unknown as AuthIdentity);
setUpdateMocks(user);
await controller.updateCurrentUser(
req,
res,
new UserUpdateRequestDto({
email: 'saml-new@email.com',
firstName: 'John',
lastName: 'Doe',
currentPassword: 'old_password',
}),
);
expect(userService.update).toHaveBeenCalled();
});
it('should allow profile update when providerType is token-exchange', async () => {
const user = {
id: '123',
@ -452,7 +434,6 @@ describe('MeController', () => {
req,
res,
new UserUpdateRequestDto({
email: 'token@email.com',
firstName: 'NewFirst',
lastName: 'NewLast',
}),
@ -486,7 +467,6 @@ describe('MeController', () => {
req,
res,
new UserUpdateRequestDto({
email: 'unchanged@email.com',
firstName: 'Same',
lastName: 'Name',
}),
@ -496,221 +476,6 @@ describe('MeController', () => {
expect(userService.update).toHaveBeenCalled();
});
});
describe('when mfa is enabled', () => {
it('should throw BadRequestError if mfa code is missing', async () => {
const user = {
id: '123',
email: 'valid@email.com',
password: 'password',
authIdentities: [],
role: GLOBAL_OWNER_ROLE,
mfaEnabled: true,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const execution = controller.updateCurrentUser(
req,
mock(),
new UserUpdateRequestDto({
email: 'new@email.com',
firstName: 'John',
lastName: 'Potato',
}),
);
await expect(execution).rejects.toThrow(BadRequestError);
await expect(execution).rejects.toThrow('Two-factor code is required to change email');
});
it('should throw InvalidMfaCodeError if mfa code is invalid', async () => {
const user = {
id: '123',
email: 'valid@email.com',
password: 'password',
authIdentities: [],
role: GLOBAL_OWNER_ROLE,
mfaEnabled: true,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
mockMfaService.validateMfa.mockResolvedValue(false);
await expect(
controller.updateCurrentUser(
req,
mock(),
mock({
email: 'new@email.com',
firstName: 'John',
lastName: 'Potato',
mfaCode: 'invalid',
}),
),
).rejects.toThrow(InvalidMfaCodeError);
});
it("should update the user's email if mfa code is valid", async () => {
const user = {
id: '123',
email: 'valid@email.com',
password: 'password',
authIdentities: [],
role: GLOBAL_OWNER_ROLE,
mfaEnabled: true,
mfaSecret: 'secret',
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const res = mock<Response>();
userRepository.findOneByOrFail.mockResolvedValue(user);
userService.findUserWithAuthIdentities.mockResolvedValue(user);
vi.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
mockMfaService.validateMfa.mockResolvedValue(true);
const result = await controller.updateCurrentUser(
req,
res,
mock({
email: 'new@email.com',
firstName: 'John',
lastName: 'Potato',
mfaCode: '123456',
}),
);
expect(result).toEqual({});
});
});
describe('when mfa is disabled and email is being changed', () => {
const oldPasswordPlain = 'old_password';
const passwordHash = '$2a$10$ffitcKrHT.Ls.m9FfWrMrOod76aaI0ogKbc3S96Q320impWpCbgj6'; // Hashed 'old_password'
it('should throw BadRequestError if currentPassword is missing', async () => {
const user = {
id: '123',
email: 'michel-old@email.com',
password: passwordHash,
mfaEnabled: false,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const execution = controller.updateCurrentUser(
req,
mock(),
new UserUpdateRequestDto({
email: 'michel-new@email.com',
firstName: 'Michel',
lastName: 'n8n',
}),
);
await expect(execution).rejects.toThrow(BadRequestError);
await expect(execution).rejects.toThrow('Current password is required to change email');
});
it('should throw BadRequestError if currentPassword is not a string', async () => {
const user = {
id: '123',
email: 'michel-old@email.com',
password: passwordHash,
mfaEnabled: false,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const execution = controller.updateCurrentUser(req, mock(), {
email: 'michel-new@email.com',
firstName: 'Michel',
lastName: 'n8n',
currentPassword: 123 as any,
} as any);
await expect(execution).rejects.toThrow(BadRequestError);
await expect(execution).rejects.toThrow('Current password is required to change email');
});
it('should throw BadRequestError if currentPassword is incorrect', async () => {
const user = {
email: 'michel-old@email.com',
password: passwordHash,
mfaEnabled: false,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const execution = controller.updateCurrentUser(
req,
mock(),
mock({
email: 'michel-new@email.com',
firstName: 'Michel',
lastName: 'n8n',
currentPassword: 'wrong-password',
}),
);
await expect(execution).rejects.toThrow(BadRequestError);
await expect(execution).rejects.toThrow(
'Unable to update profile. Please check your credentials and try again.',
);
});
it('should update the user email if currentPassword is correct', async () => {
const user = {
email: 'michel-old@email.com',
password: passwordHash,
mfaEnabled: false,
role: GLOBAL_OWNER_ROLE,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const res = mock<Response>();
userRepository.findOneByOrFail.mockResolvedValue(user);
userService.findUserWithAuthIdentities.mockResolvedValue(user);
vi.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token');
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
const result = await controller.updateCurrentUser(
req,
res,
mock({
email: 'michel-new@email.com',
firstName: 'Michel',
lastName: 'n8n',
currentPassword: oldPasswordPlain,
}),
);
expect(userService.update).toHaveBeenCalled();
expect(result).toEqual({});
});
it('should not require currentPassword when email is not being changed', async () => {
const user = {
email: 'michel@email.com',
password: passwordHash,
mfaEnabled: false,
role: GLOBAL_OWNER_ROLE,
} as unknown as User;
const req = { user, browserId } as unknown as AuthenticatedRequest;
const res = mock<Response>();
userRepository.findOneByOrFail.mockResolvedValue(user);
userService.findUserWithAuthIdentities.mockResolvedValue(user);
vi.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token');
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
const result = await controller.updateCurrentUser(
req,
res,
new UserUpdateRequestDto({
email: 'michel@email.com',
firstName: 'Michel',
lastName: 'n8n',
}),
);
expect(userService.update).toHaveBeenCalled();
expect(result).toEqual({});
});
});
});
describe('when user is managed by env', () => {

View File

@ -21,7 +21,6 @@ import { ExternalHooks } from '@/external-hooks';
import { validateEntity } from '@/generic-helpers';
import { MfaService } from '@/mfa/mfa.service';
import { MeRequest } from '@/requests';
import { EmailChangeService } from '@/services/email-change.service';
import { PasswordUtility } from '@/services/password.utility';
import { UserService } from '@/services/user.service';
import { getCurrentAuthenticationMethod, isSamlLicensedAndEnabled } from '@/sso.ee/sso-helpers';
@ -40,7 +39,6 @@ export class MeController {
private readonly eventService: EventService,
private readonly mfaService: MfaService,
private readonly globalConfig: GlobalConfig,
private readonly emailChangeService: EmailChangeService,
) {}
/**
@ -49,7 +47,7 @@ export class MeController {
@Patch('/')
async updateCurrentUser(
req: AuthenticatedRequest,
res: Response,
_: Response,
@Body payload: UserUpdateRequestDto,
): Promise<PublicUser> {
const {
@ -65,14 +63,12 @@ export class MeController {
);
}
const { currentPassword, ...payloadWithoutPassword } = payload;
const { email, firstName, lastName } = payload;
const isEmailBeingChanged = email !== currentEmail;
const { firstName, lastName } = payload;
const isFirstNameChanged = firstName !== currentFirstName;
const isLastNameChanged = lastName !== currentLastName;
// Check if the user is authenticated via SSO - they cannot change their profile info
if (isEmailBeingChanged || isFirstNameChanged || isLastNameChanged) {
if (isFirstNameChanged || isLastNameChanged) {
const ssoIdentity = await this.userService.findSsoIdentity(userId);
if (ssoIdentity && this.isAuthIdentityActive(ssoIdentity)) {
@ -80,7 +76,7 @@ export class MeController {
`Request to update user failed because ${ssoIdentity.providerType} user may not change their profile information`,
{
userId,
payload: payloadWithoutPassword,
payload,
},
);
throw new BadRequestError(
@ -89,28 +85,15 @@ export class MeController {
}
}
if (isEmailBeingChanged) {
await this.emailChangeService.assertMayRequestEmailChange(req.user, {
currentPassword,
mfaCode: payload.mfaCode,
});
}
await this.externalHooks.run('user.profile.beforeUpdate', [
userId,
currentEmail,
payloadWithoutPassword,
]);
await this.externalHooks.run('user.profile.beforeUpdate', [userId, currentEmail, payload]);
const preUpdateUser = await this.userRepository.findOneByOrFail({ id: userId });
await this.userService.update(userId, payloadWithoutPassword);
await this.userService.update(userId, payload);
const user = await this.userService.findUserWithAuthIdentities(userId);
this.logger.info('User updated successfully', { userId });
this.authService.issueCookie(res, user, req.authInfo?.usedMfa ?? false, req.browserId);
const changeableFields = ['email', 'firstName', 'lastName'] as const;
const changeableFields = ['firstName', 'lastName'] as const;
const fieldsChanged = changeableFields.filter(
(key) => key in payload && payload[key] !== preUpdateUser[key],
);

View File

@ -1,4 +1,4 @@
import type { FrontendSettings, UserUpdateRequestDto } from '@n8n/api-types';
import type { FrontendSettings } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { ClientOAuth2Options } from '@n8n/client-oauth2';
import { GlobalConfig } from '@n8n/config';
@ -104,7 +104,7 @@ type ExternalHooksMap = {
'user.profile.beforeUpdate': [
userId: string,
currentEmail: string,
payload: UserUpdateRequestDto,
payload: { email?: string; firstName?: string; lastName?: string },
];
'user.profile.update': [currentEmail: string, publicUser: PublicUser];
'user.password.update': [updatedEmail: string, updatedPassword: string | null];

View File

@ -49,9 +49,9 @@ describe('Owner shell', () => {
response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email.toLowerCase());
expect(firstName).toBe(validPayload.firstName);
expect(lastName).toBe(validPayload.lastName);
expect(email).toBeNull();
if (validPayload.firstName !== undefined) expect(firstName).toBe(validPayload.firstName);
if (validPayload.lastName !== undefined) expect(lastName).toBe(validPayload.lastName);
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
@ -59,15 +59,13 @@ describe('Owner shell', () => {
const storedOwnerShell = await Container.get(UserRepository).findOneByOrFail({ id });
expect(storedOwnerShell.email).toBe(validPayload.email.toLowerCase());
expect(storedOwnerShell.firstName).toBe(validPayload.firstName);
expect(storedOwnerShell.lastName).toBe(validPayload.lastName);
const storedPersonalProject = await Container.get(
ProjectRepository,
).getPersonalProjectForUserOrFail(storedOwnerShell.id);
expect(storedPersonalProject.name).toBe(storedOwnerShell.createPersonalProjectName());
expect(storedOwnerShell.email).toBeNull();
if (validPayload.firstName !== undefined) {
expect(storedOwnerShell.firstName).toBe(validPayload.firstName);
}
if (validPayload.lastName !== undefined) {
expect(storedOwnerShell.lastName).toBe(validPayload.lastName);
}
}
});
@ -147,16 +145,16 @@ describe('Member', () => {
});
test('PATCH /me should succeed with valid inputs', async () => {
for (const validPayload of getValidPatchMePayloads('member')) {
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
const response = await authMemberAgent.patch('/me').send(validPayload).expect(200);
const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email.toLowerCase());
expect(firstName).toBe(validPayload.firstName);
expect(lastName).toBe(validPayload.lastName);
expect(email).toBe(member.email);
if (validPayload.firstName !== undefined) expect(firstName).toBe(validPayload.firstName);
if (validPayload.lastName !== undefined) expect(lastName).toBe(validPayload.lastName);
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
@ -164,19 +162,30 @@ describe('Member', () => {
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id });
expect(storedMember.email).toBe(validPayload.email.toLowerCase());
expect(storedMember.firstName).toBe(validPayload.firstName);
expect(storedMember.lastName).toBe(validPayload.lastName);
const storedPersonalProject =
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(id);
expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName());
expect(storedMember.email).toBe(member.email);
if (validPayload.firstName !== undefined) {
expect(storedMember.firstName).toBe(validPayload.firstName);
}
if (validPayload.lastName !== undefined) {
expect(storedMember.lastName).toBe(validPayload.lastName);
}
}
});
test('PATCH /me should not change the email when an email field is sent', async () => {
const response = await authMemberAgent
.patch('/me')
.send({ email: randomEmail(), firstName: randomName(), lastName: randomName() });
expect(response.statusCode).toBe(200);
expect(response.body.data.email).toBe(member.email);
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id: member.id });
expect(storedMember.email).toBe(member.email);
});
test('PATCH /me should fail with invalid inputs', async () => {
for (const invalidPayload of getInvalidPatchMePayloads('member')) {
for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) {
const response = await authMemberAgent.patch('/me').send(invalidPayload);
expect(response.statusCode).toBe(400);
@ -193,39 +202,6 @@ describe('Member', () => {
}
});
test('PATCH /me should fail when changing email without currentPassword', async () => {
const payloadWithoutPassword = {
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
};
const response = await authMemberAgent.patch('/me').send(payloadWithoutPassword);
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain('Current password is required to change email');
const storedMember = await Container.get(UserRepository).findOneByOrFail({});
expect(storedMember.email).toBe(member.email);
});
test('PATCH /me should fail when changing email with wrong currentPassword', async () => {
const payloadWithWrongPassword = {
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
currentPassword: 'WrongPassword123',
};
const response = await authMemberAgent.patch('/me').send(payloadWithWrongPassword);
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain(
'Unable to update profile. Please check your credentials and try again.',
);
const storedMember = await Container.get(UserRepository).findOneByOrFail({});
expect(storedMember.email).toBe(member.email);
});
test('PATCH /me/password should succeed with valid inputs', async () => {
const validPayload = {
currentPassword: memberPassword,
@ -343,16 +319,16 @@ describe('Chat User', () => {
});
test('PATCH /me should succeed with valid inputs', async () => {
for (const validPayload of getValidPatchMePayloads('chatUser')) {
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
const response = await authMemberAgent.patch('/me').send(validPayload).expect(200);
const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email.toLowerCase());
expect(firstName).toBe(validPayload.firstName);
expect(lastName).toBe(validPayload.lastName);
expect(email).toBe(member.email);
if (validPayload.firstName !== undefined) expect(firstName).toBe(validPayload.firstName);
if (validPayload.lastName !== undefined) expect(lastName).toBe(validPayload.lastName);
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
@ -360,19 +336,18 @@ describe('Chat User', () => {
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id });
expect(storedMember.email).toBe(validPayload.email.toLowerCase());
expect(storedMember.firstName).toBe(validPayload.firstName);
expect(storedMember.lastName).toBe(validPayload.lastName);
const storedPersonalProject =
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(id);
expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName());
expect(storedMember.email).toBe(member.email);
if (validPayload.firstName !== undefined) {
expect(storedMember.firstName).toBe(validPayload.firstName);
}
if (validPayload.lastName !== undefined) {
expect(storedMember.lastName).toBe(validPayload.lastName);
}
}
});
test('PATCH /me should fail with invalid inputs', async () => {
for (const invalidPayload of getInvalidPatchMePayloads('chatUser')) {
for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) {
const response = await authMemberAgent.patch('/me').send(invalidPayload);
expect(response.statusCode).toBe(400);
@ -389,39 +364,6 @@ describe('Chat User', () => {
}
});
test('PATCH /me should fail when changing email without currentPassword', async () => {
const payloadWithoutPassword = {
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
};
const response = await authMemberAgent.patch('/me').send(payloadWithoutPassword);
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain('Current password is required to change email');
const storedMember = await Container.get(UserRepository).findOneByOrFail({});
expect(storedMember.email).toBe(member.email);
});
test('PATCH /me should fail when changing email with wrong currentPassword', async () => {
const payloadWithWrongPassword = {
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
currentPassword: 'WrongPassword123',
};
const response = await authMemberAgent.patch('/me').send(payloadWithWrongPassword);
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain(
'Unable to update profile. Please check your credentials and try again.',
);
const storedMember = await Container.get(UserRepository).findOneByOrFail({});
expect(storedMember.email).toBe(member.email);
});
test('PATCH /me/password should succeed with valid inputs', async () => {
const validPayload = {
currentPassword: memberPassword,
@ -479,7 +421,7 @@ describe('Owner', () => {
});
const authOwnerAgent = testServer.authAgentFor(owner);
for (const validPayload of getValidPatchMePayloads('owner')) {
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
const response = await authOwnerAgent.patch('/me').send(validPayload);
expect(response.statusCode).toBe(200);
@ -497,9 +439,9 @@ describe('Owner', () => {
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email.toLowerCase());
expect(firstName).toBe(validPayload.firstName);
expect(lastName).toBe(validPayload.lastName);
expect(email).toBe(owner.email);
if (validPayload.firstName !== undefined) expect(firstName).toBe(validPayload.firstName);
if (validPayload.lastName !== undefined) expect(lastName).toBe(validPayload.lastName);
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
@ -508,15 +450,13 @@ describe('Owner', () => {
const storedOwner = await Container.get(UserRepository).findOneByOrFail({ id });
expect(storedOwner.email).toBe(validPayload.email.toLowerCase());
expect(storedOwner.firstName).toBe(validPayload.firstName);
expect(storedOwner.lastName).toBe(validPayload.lastName);
const storedPersonalProject = await Container.get(
ProjectRepository,
).getPersonalProjectForUserOrFail(storedOwner.id);
expect(storedPersonalProject.name).toBe(storedOwner.createPersonalProjectName());
expect(storedOwner.email).toBe(owner.email);
if (validPayload.firstName !== undefined) {
expect(storedOwner.firstName).toBe(validPayload.firstName);
}
if (validPayload.lastName !== undefined) {
expect(storedOwner.lastName).toBe(validPayload.lastName);
}
}
});
});
@ -547,75 +487,37 @@ const EMPTY_SURVEY: IPersonalizationSurveyAnswersV4 = {
personalization_survey_n8n_version: '1.0.0',
};
function getValidPatchMePayloads(userType: 'owner' | 'member' | 'chatUser') {
return VALID_PATCH_ME_PAYLOADS.map((payload) => {
if (userType === 'owner') {
return { ...payload, currentPassword: ownerPassword };
}
return { ...payload, currentPassword: memberPassword };
});
}
function getInvalidPatchMePayloads(userType: 'owner' | 'member' | 'chatUser') {
return INVALID_PATCH_ME_PAYLOADS.map((payload) => {
if (userType === 'owner') {
return { ...payload, currentPassword: ownerPassword };
}
return { ...payload, currentPassword: memberPassword };
});
}
const VALID_PATCH_ME_PAYLOADS = [
{
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
},
// {
// email: randomEmail().toUpperCase(),
// firstName: randomName(),
// lastName: randomName(),
// },
{
firstName: randomName(),
},
{
lastName: randomName(),
},
];
const INVALID_PATCH_ME_PAYLOADS = [
{
email: 'invalid',
firstName: randomName(),
lastName: randomName(),
},
{
email: randomEmail(),
firstName: '',
lastName: randomName(),
},
{
email: randomEmail(),
firstName: randomName(),
lastName: '',
},
{
email: randomEmail(),
firstName: 123,
lastName: randomName(),
},
{
firstName: randomName(),
lastName: randomName(),
},
{
firstName: randomName(),
},
{
lastName: randomName(),
},
{
email: randomEmail(),
firstName: 'John <script',
lastName: randomName(),
},
{
email: randomEmail(),
firstName: 'John <a',
lastName: randomName(),
},

View File

@ -65,7 +65,7 @@ async function attachSamlIdentity(user: User, providerId: string) {
}
const testServer = utils.setupTestServer({
endpointGroups: ['me', 'saml'],
endpointGroups: ['me', 'saml', 'changeEmail'],
enabledFeatures: ['feat:saml'],
});
@ -98,18 +98,31 @@ describe('Instance owner', () => {
})
.expect(200);
});
});
describe('POST /change-email', () => {
test('should throw BadRequestError if email is changed when SAML is enabled', async () => {
await enableSaml(true);
await authOwnerAgent
.patch('/me')
.send({
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
})
.post('/change-email')
.send({ email: randomEmail() })
.expect(400, { code: 400, message: 'SAML user may not change their email' });
});
test('should allow a user with a SAML auth_identity to change email once SAML is disabled', async () => {
// The SAML identity is still attached but inactive, so the SSO guard must pass.
await enableSaml(false);
const newEmail = randomEmail();
await authSamlUserAgent
.post('/change-email')
.send({ email: newEmail, currentPassword: samlUserPassword })
.expect(200);
const refreshed = await Container.get(UserRepository).findOneByOrFail({ id: samlUser.id });
expect(refreshed.email).toBe(newEmail);
samlUser.email = newEmail;
});
});
describe('PATCH /me for a user with a SAML auth_identity', () => {
@ -148,25 +161,6 @@ describe('Instance owner', () => {
samlUser.firstName = newFirstName;
samlUser.lastName = newLastName;
});
test('should allow email change once SAML is disabled', async () => {
await enableSaml(false);
const newEmail = randomEmail();
await authSamlUserAgent
.patch('/me')
.send({
email: newEmail,
firstName: samlUser.firstName,
lastName: samlUser.lastName,
currentPassword: samlUserPassword,
})
.expect(200);
const refreshed = await Container.get(UserRepository).findOneByOrFail({ id: samlUser.id });
expect(refreshed.email).toBe(newEmail);
samlUser.email = newEmail;
});
});
describe('PATCH /password', () => {

View File

@ -40,6 +40,7 @@ export enum VIEWS {
SETUP = 'SetupView',
FORGOT_PASSWORD = 'ForgotMyPasswordView',
CHANGE_PASSWORD = 'ChangePasswordView',
CONFIRM_EMAIL_CHANGE = 'ConfirmEmailChangeView',
SETTINGS = 'Settings',
USERS_SETTINGS = 'UsersSettings',
LDAP_SETTINGS = 'LdapSettings',

View File

@ -219,6 +219,14 @@
"auth.changePassword.passwordsMustMatchError": "Passwords must match",
"auth.changePassword.reenterNewPassword": "Re-enter new password",
"auth.changePassword.tokenValidationError": "Invalid password-reset token",
"auth.confirmEmailChange.title": "Confirm email change",
"auth.confirmEmailChange.message": "Confirm the change of your email address to {newEmail}.",
"auth.confirmEmailChange.button": "Confirm email change",
"auth.confirmEmailChange.success.title": "Email changed",
"auth.confirmEmailChange.success.message": "You can now sign in with your new email address",
"auth.confirmEmailChange.error": "Problem confirming the email change",
"auth.confirmEmailChange.tokenValidationError": "Invalid or expired email-change link",
"auth.confirmEmailChange.missingTokenError": "Missing token",
"auth.confirmPassword": "Confirm password",
"auth.confirmPassword.currentPassword": "Current password",
"auth.confirmPassword.confirmPasswordToChangeEmail": "Please confirm your password in order to change your email address.",
@ -3802,6 +3810,8 @@
"settings.personal.personalSettings": "Personal Settings",
"settings.personal.personalSettingsUpdated": "Personal details updated",
"settings.personal.personalSettingsUpdatedError": "Problem updating your details",
"settings.personal.emailChange.confirmationSent.title": "Confirm your email change",
"settings.personal.emailChange.confirmationSent.message": "Check your current inbox for a link to confirm the change.",
"settings.personal.role.tooltip.default": "Default role for new users",
"settings.personal.role.tooltip.member": "Create and manage own workflows and credentials",
"settings.personal.role.tooltip.chatUser": "Access to Chat feature only",

View File

@ -1,4 +1,7 @@
import type {
ChangeEmailRequestDto,
ChangeEmailResponse,
ConfirmEmailChangeRequestDto,
LoginRequestDto,
PasswordUpdateRequestDto,
SettingsUpdateRequestDto,
@ -150,6 +153,27 @@ export async function updateCurrentUser(
return await makeRestApiRequest(context, 'PATCH', '/me', params);
}
export async function requestEmailChange(
context: IRestApiContext,
params: ChangeEmailRequestDto,
): Promise<ChangeEmailResponse> {
return await makeRestApiRequest(context, 'POST', '/change-email', params);
}
export async function resolveEmailChangeToken(
context: IRestApiContext,
params: { token: string },
): Promise<{ email: string }> {
return await makeRestApiRequest(context, 'GET', '/resolve-change-email-token', params);
}
export async function confirmEmailChange(
context: IRestApiContext,
params: ConfirmEmailChangeRequestDto,
): Promise<{ success: true }> {
return await makeRestApiRequest(context, 'POST', '/confirm-email-change', params);
}
export async function updateCurrentUserSettings(
context: IRestApiContext,
settings: UserSelfSettingsUpdateRequestDto,

View File

@ -6,7 +6,18 @@ import { createPinia, setActivePinia } from 'pinia';
import { useSettingsStore } from './settings.store';
import { useUsersStore } from './users.store';
const { loginCurrentUser, inviteUsers, login, logout, getUsers, oidcLogout } = vi.hoisted(() => {
const {
loginCurrentUser,
inviteUsers,
login,
logout,
getUsers,
oidcLogout,
updateCurrentUser,
requestEmailChange,
resolveEmailChangeToken,
confirmEmailChange,
} = vi.hoisted(() => {
return {
loginCurrentUser: vi.fn(),
identify: vi.fn(),
@ -15,6 +26,10 @@ const { loginCurrentUser, inviteUsers, login, logout, getUsers, oidcLogout } = v
logout: vi.fn(),
getUsers: vi.fn(),
oidcLogout: vi.fn(),
updateCurrentUser: vi.fn(),
requestEmailChange: vi.fn(),
resolveEmailChangeToken: vi.fn(),
confirmEmailChange: vi.fn(),
};
});
@ -23,6 +38,10 @@ vi.mock('@n8n/rest-api-client/api/users', () => ({
login,
logout,
getUsers,
updateCurrentUser,
requestEmailChange,
resolveEmailChangeToken,
confirmEmailChange,
}));
vi.mock('@n8n/rest-api-client/api/sso', () => ({
@ -36,6 +55,7 @@ vi.mock('./invitation.api', () => ({
vi.mock('./useRootStore', () => ({
useRootStore: vi.fn(() => ({
instanceId: 'test-instance-id',
restApiContext: { baseUrl: 'http://localhost', pushRef: '' },
})),
}));
@ -433,6 +453,71 @@ describe('users.store', () => {
});
});
describe('updateUserName', () => {
it('sends only the name to PATCH /me', async () => {
const usersStore = useUsersStore();
loginCurrentUser.mockResolvedValueOnce(mockUser);
await usersStore.loginWithCookie();
updateCurrentUser.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' });
await usersStore.updateUserName({ firstName: 'Jane', lastName: 'Doe' });
expect(updateCurrentUser).toHaveBeenCalledWith(expect.anything(), {
firstName: 'Jane',
lastName: 'Doe',
});
});
});
describe('requestEmailChange', () => {
it('passes the params through and returns the response', async () => {
const usersStore = useUsersStore();
requestEmailChange.mockResolvedValueOnce({ status: 'confirmation-sent' });
const result = await usersStore.requestEmailChange({
email: 'new@email.com',
currentPassword: 'secret',
});
expect(requestEmailChange).toHaveBeenCalledWith(expect.anything(), {
email: 'new@email.com',
currentPassword: 'secret',
});
expect(result).toEqual({ status: 'confirmation-sent' });
});
it('adds the user to the store when the change applies immediately', async () => {
const usersStore = useUsersStore();
const changedUser = { id: '2', firstName: 'Jane', role: 'global:member', email: 'x@y.com' };
requestEmailChange.mockResolvedValueOnce({ status: 'changed', user: changedUser });
await usersStore.requestEmailChange({ email: 'x@y.com', currentPassword: 'secret' });
expect(usersStore.usersById['2']).toMatchObject({ id: '2', email: 'x@y.com' });
});
});
describe('resolveEmailChangeToken / confirmEmailChange', () => {
it('resolves the token via the API', async () => {
const usersStore = useUsersStore();
resolveEmailChangeToken.mockResolvedValueOnce({ email: 'new@email.com' });
const result = await usersStore.resolveEmailChangeToken({ token: 'abc' });
expect(resolveEmailChangeToken).toHaveBeenCalledWith(expect.anything(), { token: 'abc' });
expect(result).toEqual({ email: 'new@email.com' });
});
it('confirms the change via the API', async () => {
const usersStore = useUsersStore();
confirmEmailChange.mockResolvedValueOnce({ success: true });
await usersStore.confirmEmailChange({ token: 'abc' });
expect(confirmEmailChange).toHaveBeenCalledWith(expect.anything(), { token: 'abc' });
});
});
describe('fetchUsers', () => {
it('does not fetch when the injected permission check denies listing (default)', async () => {
const usersStore = useUsersStore();

View File

@ -1,4 +1,7 @@
import {
type ChangeEmailRequestDto,
type ChangeEmailResponse,
type ConfirmEmailChangeRequestDto,
type LoginRequestDto,
type PasswordUpdateRequestDto,
type SettingsUpdateRequestDto,
@ -336,10 +339,26 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
return;
}
return await updateUser({
email: currentUser.value.email as string,
...params,
});
return await updateUser(params);
};
const requestEmailChange = async (
params: ChangeEmailRequestDto,
): Promise<ChangeEmailResponse> => {
const result = await usersApi.requestEmailChange(rootStore.restApiContext, params);
// Without email delivery the backend applies the change at once, so sync the store.
if (result.status === 'changed') {
addUsers([result.user]);
}
return result;
};
const resolveEmailChangeToken = async (params: { token: string }) => {
return await usersApi.resolveEmailChangeToken(rootStore.restApiContext, params);
};
const confirmEmailChange = async (params: ConfirmEmailChangeRequestDto) => {
await usersApi.confirmEmailChange(rootStore.restApiContext, params);
};
const updateUserSettings = async (settings: UserSelfSettingsUpdateRequestDto) => {
@ -529,6 +548,9 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
changePassword,
updateUser,
updateUserName,
requestEmailChange,
resolveEmailChangeToken,
confirmEmailChange,
updateUserSettings,
updateOtherUserSettings,
updateCurrentUserPassword,

View File

@ -34,6 +34,8 @@ import {
const ChangePasswordView = async () =>
await import('@/features/core/auth/views/ChangePasswordView.vue');
const ConfirmEmailChangeView = async () =>
await import('@/features/core/auth/views/ConfirmEmailChangeView.vue');
const ErrorView = async () => await import('@/app/views/ErrorView.vue');
const EntityNotFound = async () => await import('@/app/views/EntityNotFound.vue');
const EntityUnAuthorised = async () => await import('@/app/views/EntityUnAuthorised.vue');
@ -600,6 +602,19 @@ export const routes: RouteRecordRaw[] = [
},
},
},
{
path: '/confirm-email-change',
name: VIEWS.CONFIRM_EMAIL_CHANGE,
component: ConfirmEmailChangeView,
// No auth middleware: the token authorizes the change, so the link works
// whether the user is signed in or not (the confirm endpoint is skipAuth).
meta: {
layout: 'auth',
telemetry: {
pageCategory: 'auth',
},
},
},
{
path: '/settings',
name: VIEWS.SETTINGS,

View File

@ -0,0 +1,95 @@
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import { fireEvent, waitFor } from '@testing-library/vue';
import { VIEWS } from '@/app/constants';
import ConfirmEmailChangeView from './ConfirmEmailChangeView.vue';
import { useUsersStore } from '@n8n/stores/users.store';
const { push, replace, routerState } = vi.hoisted(() => ({
push: vi.fn(),
replace: vi.fn(),
routerState: { token: 'test-token' as string | undefined },
}));
vi.mock('vue-router', () => ({
useRouter: () => ({
push,
replace,
currentRoute: { value: { query: { token: routerState.token } } },
}),
RouterLink: { template: '<a><slot /></a>' },
}));
const { showError, showMessage } = vi.hoisted(() => ({
showError: vi.fn(),
showMessage: vi.fn(),
}));
vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showError, showMessage }),
}));
const renderComponent = createComponentRenderer(ConfirmEmailChangeView);
describe('ConfirmEmailChangeView', () => {
beforeEach(() => {
vi.clearAllMocks();
routerState.token = 'test-token';
});
it('should resolve the token and render the confirm card', async () => {
const pinia = createTestingPinia();
const usersStore = mockedStore(useUsersStore);
usersStore.resolveEmailChangeToken.mockResolvedValue({ email: 'new@email.com' });
const { findByTestId } = renderComponent({ pinia });
expect(await findByTestId('confirm-email-change')).toHaveTextContent('new@email.com');
expect(usersStore.resolveEmailChangeToken).toHaveBeenCalledWith({ token: 'test-token' });
});
it('should confirm the change and redirect to sign-in on submit', async () => {
const pinia = createTestingPinia();
const usersStore = mockedStore(useUsersStore);
usersStore.resolveEmailChangeToken.mockResolvedValue({ email: 'new@email.com' });
usersStore.confirmEmailChange.mockResolvedValue(undefined);
usersStore.logout.mockResolvedValue({ redirectUrl: null });
const { findByTestId } = renderComponent({ pinia });
const button = await findByTestId('confirm-email-change-button');
await fireEvent.click(button);
await waitFor(() => {
expect(usersStore.confirmEmailChange).toHaveBeenCalledWith({ token: 'test-token' });
expect(usersStore.logout).toHaveBeenCalled();
expect(push).toHaveBeenCalledWith({ name: VIEWS.SIGNIN });
});
});
it('should redirect to sign-in when the token is invalid', async () => {
const pinia = createTestingPinia();
const usersStore = mockedStore(useUsersStore);
usersStore.resolveEmailChangeToken.mockRejectedValue(new Error('invalid'));
renderComponent({ pinia });
await waitFor(() => {
expect(replace).toHaveBeenCalledWith({ name: VIEWS.SIGNIN });
});
});
it('should redirect to sign-in when the token is missing', async () => {
routerState.token = undefined;
const pinia = createTestingPinia();
const usersStore = mockedStore(useUsersStore);
renderComponent({ pinia });
await waitFor(() => {
expect(replace).toHaveBeenCalledWith({ name: VIEWS.SIGNIN });
});
expect(usersStore.resolveEmailChangeToken).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { N8nButton, N8nHeading, N8nLogo, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useUsersStore } from '@n8n/stores/users.store';
import { VIEWS } from '@/app/constants';
const usersStore = useUsersStore();
const {
settings: { releaseChannel },
} = useSettingsStore();
const locale = useI18n();
const toast = useToast();
const router = useRouter();
const loading = ref(false);
const ready = ref(false);
const newEmail = ref('');
const getToken = () => {
const { token } = router.currentRoute.value.query;
return typeof token === 'string' && token.length > 0 ? token : null;
};
const onConfirm = async () => {
const token = getToken();
if (!token) return;
try {
loading.value = true;
await usersStore.confirmEmailChange({ token });
toast.showMessage({
type: 'success',
title: locale.baseText('auth.confirmEmailChange.success.title'),
message: locale.baseText('auth.confirmEmailChange.success.message'),
});
// ponytail: end the session so the /signin guest guard renders sign-in.
// A failed /logout call lands in the catch below with a generic error toast;
// scope the catch if that mislabel is ever hit in practice.
await usersStore.logout();
await router.push({ name: VIEWS.SIGNIN });
} catch (error) {
toast.showError(error, locale.baseText('auth.confirmEmailChange.error'));
}
loading.value = false;
};
onMounted(async () => {
const token = getToken();
if (!token) {
toast.showError(
new Error(locale.baseText('auth.confirmEmailChange.missingTokenError')),
locale.baseText('auth.confirmEmailChange.error'),
);
void router.replace({ name: VIEWS.SIGNIN });
return;
}
try {
// The mutating step runs on confirm, so an email-scanner GET of this link
// only resolves the token here and never applies the change.
const resolved = await usersStore.resolveEmailChangeToken({ token });
newEmail.value = resolved.email;
ready.value = true;
} catch (error) {
toast.showError(error, locale.baseText('auth.confirmEmailChange.tokenValidationError'));
void router.replace({ name: VIEWS.SIGNIN });
}
});
</script>
<template>
<div :class="$style.container">
<N8nLogo size="large" :release-channel="releaseChannel" />
<div v-if="ready" :class="$style.card" data-test-id="confirm-email-change">
<N8nHeading size="xlarge">{{ locale.baseText('auth.confirmEmailChange.title') }}</N8nHeading>
<N8nText color="text-base">
{{ locale.baseText('auth.confirmEmailChange.message', { interpolate: { newEmail } }) }}
</N8nText>
<N8nButton
:label="locale.baseText('auth.confirmEmailChange.button')"
:loading="loading"
size="large"
data-test-id="confirm-email-change-button"
@click="onConfirm"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
align-items: center;
padding-top: var(--spacing--2xl);
}
.card {
margin-top: var(--spacing--xl);
width: 352px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--md);
padding: var(--spacing--xl);
text-align: center;
background-color: var(--color--background--light-3);
border: var(--border);
border-radius: var(--radius--lg);
box-shadow: 0 4px 16px rgba(99, 77, 255, 0.06);
}
</style>

View File

@ -1,7 +1,8 @@
import { createPinia } from 'pinia';
import { waitFor } from '@testing-library/vue';
import { fireEvent, waitFor } from '@testing-library/vue';
import { waitAllPromises, getTooltip, hoverTooltipTrigger } from '@/__tests__/utils';
import SettingsPersonalView from './SettingsPersonalView.vue';
import { confirmPasswordEventBus } from '../auth.eventBus';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useUsersStore } from '@n8n/stores/users.store';
import { createComponentRenderer } from '@/__tests__/render';
@ -75,6 +76,55 @@ describe('SettingsPersonalView', () => {
expect(getByTestId('change-password-link')).toBeInTheDocument();
});
describe('when saving basic info', () => {
it('should save a name-only change through updateUserName', async () => {
const updateUserNameSpy = vi
.spyOn(usersStore, 'updateUserName')
.mockResolvedValue({ id: '1', isPending: false });
const requestEmailChangeSpy = vi.spyOn(usersStore, 'requestEmailChange');
const { getByTestId, getAllByRole } = renderComponent({ pinia });
await waitAllPromises();
const firstNameInput = getAllByRole('textbox')[0];
await fireEvent.update(firstNameInput, 'Jane');
await waitAllPromises();
getByTestId('save-settings-button').click();
await waitAllPromises();
expect(updateUserNameSpy).toHaveBeenCalledWith({ firstName: 'Jane', lastName: 'Doe' });
expect(requestEmailChangeSpy).not.toHaveBeenCalled();
});
it('should route an email change through requestEmailChange, not updateUser', async () => {
const requestEmailChangeSpy = vi
.spyOn(usersStore, 'requestEmailChange')
.mockResolvedValue({ status: 'confirmation-sent' });
const updateUserSpy = vi.spyOn(usersStore, 'updateUser');
const { getByTestId, getAllByRole } = renderComponent({ pinia });
await waitAllPromises();
const emailInput = getAllByRole('textbox').find((el) => el.getAttribute('type') === 'email')!;
await fireEvent.update(emailInput, 'new@example.com');
await waitAllPromises();
getByTestId('save-settings-button').click();
await waitAllPromises();
// The password modal collects the current password; simulate confirming it.
confirmPasswordEventBus.emit('close', { currentPassword: 'secret' });
await waitAllPromises();
expect(requestEmailChangeSpy).toHaveBeenCalledWith({
email: 'new@example.com',
currentPassword: 'secret',
});
expect(updateUserSpy).not.toHaveBeenCalled();
});
});
describe('when changing theme', () => {
it('should disable save button when theme has not been changed', async () => {
const { getByTestId } = renderComponent({ pinia });

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ROLE, type Role } from '@n8n/api-types';
import { ROLE, type Role, type ChangeEmailRequestDto } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
@ -43,14 +43,6 @@ type UserBasicDetailsForm = {
firstName: string;
lastName: string;
email: string;
/**
* Required when changing the user email and no MFA enabled
*/
currentPassword?: string;
};
type UserBasicDetailsWithMfa = UserBasicDetailsForm & {
mfaCode?: string;
};
type RoleContent = {
@ -62,6 +54,7 @@ const i18n = useI18n();
const { showToast, showError } = useToast();
const documentTitle = useDocumentTitle();
const isActive = ref<boolean>(true);
const hasAnyBasicInfoChanges = ref<boolean>(false);
const formInputs = ref<null | IFormInputs>(null);
const formBus = createFormEventBus();
@ -186,9 +179,8 @@ const currentUserRole = computed<RoleContent>(() => {
};
});
onMounted(() => {
documentTitle.set(i18n.baseText('settings.personal.personalSettings'));
formInputs.value = [
function buildFormInputs(): IFormInputs {
return [
{
name: 'firstName',
initialValue: currentUser.value?.firstName,
@ -227,6 +219,11 @@ onMounted(() => {
},
},
];
}
onMounted(() => {
documentTitle.set(i18n.baseText('settings.personal.personalSettings'));
formInputs.value = buildFormInputs();
});
function onInput() {
@ -237,12 +234,38 @@ function onReadyToSubmit(ready: boolean) {
readyToSubmit.value = ready;
}
/** Saves users basic info and personalization settings */
async function saveUserSettings(params: UserBasicDetailsWithMfa) {
async function onSubmit(data: Record<string, string | number | boolean | null | undefined>) {
const form = data as UserBasicDetailsForm;
const emailChanged = usersStore.currentUser?.email !== form.email;
// Name and theme save immediately - they need no re-authentication.
await saveNameAndPersonalisation(form);
// Email changes go through the confirmation flow, gated by password or MFA.
// Skip if the view unmounted during the awaited save, so the modal never
// opens on a departed page.
if (emailChanged && isActive.value) {
startEmailChange(form.email);
}
}
/** Saves name and personalization settings, only when they changed. */
async function saveNameAndPersonalisation(form: UserBasicDetailsForm) {
const current = usersStore.currentUser;
const nameChanged = current?.firstName !== form.firstName || current?.lastName !== form.lastName;
if (!nameChanged && !hasAnyPersonalisationChanges.value) {
return;
}
try {
// The MFA code might be invalid so we update the user's basic info first
await updateUserBasicInfo(params);
await updatePersonalisationSettings();
if (nameChanged && usersStore.currentUserId) {
await usersStore.updateUserName({ firstName: form.firstName, lastName: form.lastName });
}
if (hasAnyPersonalisationChanges.value) {
uiStore.setTheme(currentSelectedTheme.value);
}
hasAnyBasicInfoChanges.value = false;
showToast({
title: i18n.baseText('settings.personal.personalSettingsUpdated'),
@ -254,11 +277,8 @@ async function saveUserSettings(params: UserBasicDetailsWithMfa) {
}
}
async function onSubmit(data: Record<string, string | number | boolean | null | undefined>) {
const form = data as UserBasicDetailsForm;
const emailChanged = usersStore.currentUser?.email !== form.email;
if (usersStore.currentUser?.mfaEnabled && emailChanged) {
function startEmailChange(newEmail: string) {
if (usersStore.currentUser?.mfaEnabled) {
uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY);
promptMfaCodeBus.once('closed', async (payload: MfaModalEvents['closed']) => {
@ -267,53 +287,53 @@ async function onSubmit(data: Record<string, string | number | boolean | null |
return;
}
await saveUserSettings({
...form,
mfaCode: payload.mfaCode,
});
await submitEmailChange({ email: newEmail, mfaCode: payload.mfaCode });
});
} else if (emailChanged) {
} else {
uiStore.openModal(CONFIRM_PASSWORD_MODAL_KEY);
confirmPasswordEventBus.once('close', async (payload: ConfirmPasswordModalEvents['close']) => {
if (!payload) {
// User closed the modal without submitting the form
return;
}
await saveUserSettings({
...form,
currentPassword: payload.currentPassword,
});
await submitEmailChange({ email: newEmail, currentPassword: payload.currentPassword });
uiStore.closeModal(CONFIRM_PASSWORD_MODAL_KEY);
});
} else {
await saveUserSettings(form);
}
}
async function updateUserBasicInfo(userBasicInfo: UserBasicDetailsWithMfa) {
if (!hasAnyBasicInfoChanges.value || !usersStore.currentUserId) {
return;
}
async function submitEmailChange(params: ChangeEmailRequestDto) {
try {
const result = await usersStore.requestEmailChange(params);
await usersStore.updateUser({
firstName: userBasicInfo.firstName,
lastName: userBasicInfo.lastName,
email: userBasicInfo.email,
mfaCode: userBasicInfo.mfaCode,
currentPassword: userBasicInfo.currentPassword,
});
if (result.status === 'confirmation-sent') {
// The change is not applied yet, so put the field back to the current email.
revertEmailField();
showToast({
title: i18n.baseText('settings.personal.emailChange.confirmationSent.title'),
message: i18n.baseText('settings.personal.emailChange.confirmationSent.message'),
type: 'success',
});
} else {
// status 'changed': no email delivery is configured, so it applied at once.
showToast({
title: i18n.baseText('settings.personal.personalSettingsUpdated'),
message: '',
type: 'success',
});
}
} catch (e) {
showError(e, i18n.baseText('settings.personal.personalSettingsUpdatedError'));
}
}
function revertEmailField() {
formInputs.value = buildFormInputs();
hasAnyBasicInfoChanges.value = false;
}
async function updatePersonalisationSettings() {
if (!hasAnyPersonalisationChanges.value) {
return;
}
uiStore.setTheme(currentSelectedTheme.value);
}
function onSaveClick() {
formBus.emit('submit');
}
@ -368,6 +388,7 @@ async function onMfaDisableClick() {
}
onBeforeUnmount(() => {
isActive.value = false;
promptMfaCodeBus.off('closed', disableMfa);
});
</script>