mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Wire up the email change confirmation flow (#38730)
This commit is contained in:
parent
1d029046f8
commit
ccaeeb7eb6
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user