diff --git a/clients/dashboard/src/api/identity.ts b/clients/dashboard/src/api/identity.ts index baa860057f..64218f71e7 100644 --- a/clients/dashboard/src/api/identity.ts +++ b/clients/dashboard/src/api/identity.ts @@ -425,9 +425,9 @@ export async function getMyProfileWithETag(): Promise<{ profile: UserDto; etag: } /** - * Updates the authenticated user's profile. Maps to UpdateUserCommand server-side. Image and - * email changes go through their own dedicated endpoints — this is for the editable profile - * fields surfaced in settings/profile. The unedited fields come off `input.profile`, the copy + * Updates the authenticated user's profile. Maps to UpdateUserCommand server-side. The image + * goes through its own endpoint, and the e-mail address can't be changed here (it needs a + * confirmation flow) — this is for the editable profile fields surfaced in settings/profile. The unedited fields come off `input.profile`, the copy * the form was seeded from, so they keep their values instead of being nulled. * * The PUT carries `If-Match` with that same copy's ETag, so the server answers 412 when the @@ -447,7 +447,6 @@ export async function updateMyProfile(input: UpdateProfileInput): Promise firstName: input.firstName ?? profile.firstName ?? null, lastName: input.lastName ?? profile.lastName ?? null, phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null, - email: profile.email, deleteCurrentImage: false, }), }); diff --git a/clients/dashboard/tests/settings/profile.spec.ts b/clients/dashboard/tests/settings/profile.spec.ts index 348de47f82..aa95488386 100644 --- a/clients/dashboard/tests/settings/profile.spec.ts +++ b/clients/dashboard/tests/settings/profile.spec.ts @@ -77,8 +77,9 @@ test.describe("settings/profile — wired to PUT /identity/profile", () => { firstName: "Alicia", lastName: "Nguyen-Ortiz", phoneNumber: "+1 (555) 000-1234", - email: TEST_USER.email, }); + // The profile PUT can't change the e-mail address, so the body must not pretend it can. + expect(body).not.toHaveProperty("email"); }); test("shows a success toast on 200", async ({ page }) => { diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs index 4f89ec1d8a..e6cccc6846 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs @@ -10,7 +10,6 @@ public class UpdateUserCommand : ICommand public string? FirstName { get; set; } public string? LastName { get; set; } public string? PhoneNumber { get; set; } - public string? Email { get; set; } public FileUploadRequest? Image { get; set; } public bool DeleteCurrentImage { get; set; } public string? Locale { get; set; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs index b8b4ef32ac..ce8b14d24f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -26,10 +26,6 @@ public UpdateUserCommandValidator(IStringLocalizer localizer) .MaximumLength(15) .When(x => !string.IsNullOrWhiteSpace(x.PhoneNumber)); - RuleFor(x => x.Email) - .EmailAddress() - .When(x => !string.IsNullOrWhiteSpace(x.Email)); - When(x => x.Image is not null, () => { RuleFor(x => x.Image!) diff --git a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs index f486c769c4..98e685505d 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs @@ -71,24 +71,6 @@ public void Validate_Should_Fail_When_FirstNameExceedsMaxLength() result.Errors.ShouldContain(e => e.PropertyName == "FirstName"); } - [Fact] - public void Validate_Should_Fail_When_EmailIsInvalid() - { - // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - Email = "not-an-email" - }; - - // Act - var result = _sut.Validate(command); - - // Assert - result.IsValid.ShouldBeFalse(); - result.Errors.ShouldContain(e => e.PropertyName == "Email"); - } - [Fact] public void Validate_Should_Fail_When_DeleteImageAndUploadImage_Simultaneously() { @@ -149,13 +131,13 @@ public void Builtin_validation_message_is_localized_under_ptBR() { // Act — FluentValidation resolves built-in messages via CurrentUICulture (ships a pt catalog). var result = WithCulture("pt-BR", () => - _sut.Validate(new UpdateUserCommand { Id = "user-123", Email = "not-an-email" })); + _sut.Validate(new UpdateUserCommand { Id = "user-123", PhoneNumber = new string('1', 16) })); // Assert — pin the actual Portuguese text, not merely the absence of the English one. // "does not contain the English sentence" is satisfied by a blank message, by a raw // resource key leaking through, and by any wrong-but-non-English string, so it stayed // green through exactly the failures it existed to catch. - var message = result.Errors.Single(e => e.PropertyName == "Email").ErrorMessage; - message.ShouldBe("'Email' é um endereço de email inválido."); + var message = result.Errors.Single(e => e.PropertyName == "PhoneNumber").ErrorMessage; + message.ShouldBe("'Phone Number' deve ser menor ou igual a 15 caracteres. Você digitou 16 caracteres."); } } diff --git a/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs b/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs index 5bc1e94c30..215d4cdf65 100644 --- a/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs +++ b/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs @@ -50,6 +50,32 @@ public async Task UpdateProfile_Should_PersistChanges_When_AuthenticatedUserUpda dto.PhoneNumber.ShouldBe("1234567890"); } + [Fact] + public async Task UpdateProfile_Should_LeaveEmailUnchanged_When_BodyCarriesAnEmail() + { + // Arrange — e-mail changes need a confirmation flow, so PUT /profile has no email field; + // System.Text.Json skips the unknown property rather than rejecting the request. + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "upd-email"); + using var userClient = await _auth.CreateAuthenticatedClientAsync(user.Email, user.Password); + + // Act + var response = await userClient.PutAsJsonAsync( + $"{TestConstants.IdentityBasePath}/profile", new + { + firstName = "Renamed", + email = $"changed-{Guid.NewGuid():N}@example.com" + }); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var profile = await userClient.GetAsync($"{TestConstants.IdentityBasePath}/profile"); + var dto = await profile.DeserializeAsync(); + dto.FirstName.ShouldBe("Renamed"); + dto.Email.ShouldBe(user.Email); + } + [Fact] public async Task UpdateProfile_Should_IgnoreSuppliedId_When_DifferentFromAuthenticatedUser() {