diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs index c7dd963..aa6f9ea 100644 --- a/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs @@ -169,7 +169,8 @@ private async Task CompleteProfileAsync(CompleteProfilePayl } Guid userId = Guid.Parse(validatedToken.Subject); User user = await _userService.FindAsync(userId, cancellationToken) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); - user = await _userService.SaveProfileAsync(user.Id, payload, cancellationToken); + Phone? phone = validatedToken.GetPhone(); + user = await _userService.CompleteProfileAsync(user.Id, payload, phone, cancellationToken); return await EnsureProfileIsCompleted(user, customAttributes, cancellationToken); } diff --git a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs index 0b2f1c3..5abd973 100644 --- a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs @@ -7,6 +7,7 @@ public interface IUserService { Task AuthenticateAsync(User user, string password, CancellationToken cancellationToken = default); Task AuthenticateAsync(string uniqueName, string password, CancellationToken cancellationToken = default); + Task CompleteProfileAsync(Guid userId, CompleteProfilePayload payload, Phone? phone = null, CancellationToken cancellationToken = default); Task CreateAsync(Email email, CancellationToken cancellationToken = default); Task FindAsync(string uniqueName, CancellationToken cancellationToken = default); Task FindAsync(Guid id, CancellationToken cancellationToken = default); diff --git a/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs b/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs index 0d4ba7f..140eb8f 100644 --- a/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs +++ b/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs @@ -1,7 +1,12 @@ -using Logitar.Identity.Domain.Users; +using Logitar.Identity.Contracts; +using Logitar.Identity.Contracts.Users; +using Logitar.Identity.Domain.Users; +using Logitar.Master.Application.Constants; using Logitar.Master.Contracts.Accounts; using Logitar.Portal.Contracts; +using Logitar.Portal.Contracts.Tokens; using Logitar.Portal.Contracts.Users; +using Logitar.Security.Claims; namespace Logitar.Master.Application.Accounts; @@ -20,6 +25,43 @@ public static void SetMultiFactorAuthenticationMode(this UpdateUserPayload paylo payload.CustomAttributes.Add(new CustomAttributeModification(MultiFactorAuthenticationModeKey, mode.ToString())); } + public static Phone? GetPhone(this ValidatedToken validatedToken) // TODO(fpion): unit tests + { + Phone phone = new(); + foreach (TokenClaim claim in validatedToken.Claims) + { + switch (claim.Name) + { + case ClaimNames.PhoneCountryCode: + phone.CountryCode = claim.Value; + break; + case ClaimNames.PhoneNumberRaw: + phone.Number = claim.Value; + break; + case Rfc7519ClaimNames.IsPhoneVerified: + phone.IsVerified = bool.Parse(claim.Value); + break; + case Rfc7519ClaimNames.PhoneNumber: + int index = claim.Value.IndexOf(';'); + if (index < 0) + { + phone.E164Formatted = claim.Value; + } + else + { + phone.E164Formatted = claim.Value[..index]; + phone.Extension = claim.Value[(index + 1)..].Remove("ext="); + } + break; + } + } + if (string.IsNullOrEmpty(phone.Number) || string.IsNullOrEmpty(phone.E164Formatted)) + { + return null; + } + return phone; + } + public static string GetSubject(this User user) => user.Id.ToString(); public static void CompleteProfile(this UpdateUserPayload payload) @@ -37,6 +79,9 @@ public static bool IsProfileCompleted(this User user) return customAttribute == null ? null : DateTime.Parse(customAttribute.Value); } + public static EmailPayload ToEmailPayload(this Email email) => email.ToEmailPayload(email.IsVerified); // TODO(fpion): unit tests + public static EmailPayload ToEmailPayload(this IEmail email, bool isVerified = false) => new(email.Address, isVerified); // TODO(fpion): unit tests + public static Phone ToPhone(this AccountPhone phone) { Phone result = new(phone.CountryCode?.CleanTrim(), phone.Number.Trim(), extension: null, e164Formatted: string.Empty); @@ -44,6 +89,20 @@ public static Phone ToPhone(this AccountPhone phone) return result; } + public static PhonePayload ToPhonePayload(this Phone phone) => phone.ToPhonePayload(phone.IsVerified); // TODO(fpion): unit tests + public static PhonePayload ToPhonePayload(this IPhone phone, bool isVerified = false) => new(phone.CountryCode, phone.Number, phone.Extension, isVerified); // TODO(fpion): unit tests + + public static UpdateUserPayload ToUpdateUserPayload(this SaveProfilePayload payload) => new() + { + FirstName = new Modification(payload.FirstName), + MiddleName = new Modification(payload.MiddleName), + LastName = new Modification(payload.LastName), + Birthdate = new Modification(payload.Birthdate), + Gender = new Modification(payload.Gender), + Locale = new Modification(payload.Locale), + TimeZone = new Modification(payload.TimeZone) + }; // TODO(fpion): unit tests + public static UserProfile ToUserProfile(this User user) => new() { CreatedOn = user.CreatedOn, diff --git a/backend/src/Logitar.Master.Contracts/Accounts/CompleteProfilePayload.cs b/backend/src/Logitar.Master.Contracts/Accounts/CompleteProfilePayload.cs index c0f4865..bdd0d53 100644 --- a/backend/src/Logitar.Master.Contracts/Accounts/CompleteProfilePayload.cs +++ b/backend/src/Logitar.Master.Contracts/Accounts/CompleteProfilePayload.cs @@ -7,8 +7,6 @@ public record CompleteProfilePayload : SaveProfilePayload public string? Password { get; set; } public MultiFactorAuthenticationMode MultiFactorAuthenticationMode { get; set; } - public string? PhoneNumber { get; set; } - public CompleteProfilePayload() : this(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty) { } diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs index 206d375..81df638 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs @@ -26,6 +26,23 @@ public async Task AuthenticateAsync(string uniqueName, string password, Ca return await _userClient.AuthenticateAsync(payload, context); } + public async Task CompleteProfileAsync(Guid userId, CompleteProfilePayload profile, Phone? phone, CancellationToken cancellationToken) + { + UpdateUserPayload payload = profile.ToUpdateUserPayload(); + if (profile.Password != null) + { + payload.Password = new ChangePasswordPayload(profile.Password); + } + if (phone != null) + { + payload.Phone = new Modification(phone.ToPhonePayload()); + } + payload.CompleteProfile(); + payload.SetMultiFactorAuthenticationMode(profile.MultiFactorAuthenticationMode); + RequestContext context = new(userId.ToString(), cancellationToken); + return await _userClient.UpdateAsync(userId, payload, context) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); + } + public async Task CreateAsync(Email email, CancellationToken cancellationToken) { CreateUserPayload payload = new(email.Address) @@ -50,31 +67,7 @@ public async Task CreateAsync(Email email, CancellationToken cancellationT public async Task SaveProfileAsync(Guid userId, SaveProfilePayload profile, CancellationToken cancellationToken) { - UpdateUserPayload payload = new() - { - FirstName = new Modification(profile.FirstName), - MiddleName = new Modification(profile.MiddleName), - LastName = new Modification(profile.LastName), - Birthdate = new Modification(profile.Birthdate), - Gender = new Modification(profile.Gender), - Locale = new Modification(profile.Locale), - TimeZone = new Modification(profile.TimeZone) - }; - - if (profile is CompleteProfilePayload completedProfile) - { - if (completedProfile.Password != null) - { - payload.Password = new ChangePasswordPayload(completedProfile.Password); - } - if (completedProfile.PhoneNumber != null) - { - payload.Phone = new Modification(new PhonePayload(countryCode: null, completedProfile.PhoneNumber, extension: null, isVerified: false)); - } - payload.CompleteProfile(); - payload.SetMultiFactorAuthenticationMode(completedProfile.MultiFactorAuthenticationMode); - } - + UpdateUserPayload payload = profile.ToUpdateUserPayload(); RequestContext context = new(userId.ToString(), cancellationToken); return await _userClient.UpdateAsync(userId, payload, context) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); } @@ -83,7 +76,7 @@ public async Task UpdateEmailAsync(Guid userId, Email email, CancellationT { UpdateUserPayload payload = new() { - Email = new Modification(new EmailPayload(email.Address, email.IsVerified)) + Email = new Modification(email.ToEmailPayload()) }; RequestContext context = new(userId.ToString(), cancellationToken); return await _userClient.UpdateAsync(userId, payload, context) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); @@ -93,7 +86,7 @@ public async Task UpdatePhoneAsync(Guid userId, Phone phone, CancellationT { UpdateUserPayload payload = new() { - Phone = new Modification(new PhonePayload(phone.CountryCode, phone.Number, phone.Extension, phone.IsVerified)) + Phone = new Modification(phone.ToPhonePayload()) }; RequestContext context = new(userId.ToString(), cancellationToken); return await _userClient.UpdateAsync(userId, payload, context) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); diff --git a/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/SignInCommandTests.cs b/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/SignInCommandTests.cs index 863b77e..1d0ed0b 100644 --- a/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/SignInCommandTests.cs +++ b/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/SignInCommandTests.cs @@ -1,10 +1,12 @@ -using Logitar.Master.Contracts.Accounts; +using Logitar.Master.Application.Constants; +using Logitar.Master.Contracts.Accounts; using Logitar.Portal.Contracts; using Logitar.Portal.Contracts.Messages; using Logitar.Portal.Contracts.Passwords; using Logitar.Portal.Contracts.Sessions; using Logitar.Portal.Contracts.Tokens; using Logitar.Portal.Contracts.Users; +using Logitar.Security.Claims; using Moq; namespace Logitar.Master.Application.Accounts.Commands; @@ -25,7 +27,6 @@ public async Task It_should_complete_the_user_profile() { Password = "P@s$W0rD", MultiFactorAuthenticationMode = MultiFactorAuthenticationMode.Phone, - PhoneNumber = "(514) 845-4636", Birthdate = Faker.Person.DateOfBirth, Gender = Faker.Person.Gender.ToString().ToLower() } @@ -45,7 +46,10 @@ public async Task It_should_complete_the_user_profile() { HasPassword = true, Email = user.Email, - Phone = new Phone(countryCode: "CA", "(514) 845-4636", extension: null, e164Formatted: "+15148454636"), + Phone = new Phone(countryCode: "CA", "(514) 845-4636", extension: null, e164Formatted: "+15148454636") + { + IsVerified = true + }, FirstName = Faker.Person.FirstName, LastName = Faker.Person.LastName, FullName = Faker.Person.FullName, @@ -56,12 +60,17 @@ public async Task It_should_complete_the_user_profile() }; updatedUser.CustomAttributes.Add(new CustomAttribute("MultiFactorAuthenticationMode", MultiFactorAuthenticationMode.Phone.ToString())); updatedUser.CustomAttributes.Add(new CustomAttribute("ProfileCompletedOn", DateTime.Now.ToString("O", CultureInfo.InvariantCulture))); - UserService.Setup(x => x.SaveProfileAsync(user.Id, payload.Profile, CancellationToken)).ReturnsAsync(updatedUser); + UserService.Setup(x => x.CompleteProfileAsync(user.Id, payload.Profile, updatedUser.Phone, CancellationToken)).ReturnsAsync(updatedUser); ValidatedToken validatedToken = new() { Subject = user.Id.ToString() }; + Assert.NotNull(updatedUser.Phone.CountryCode); + validatedToken.Claims.Add(new TokenClaim(ClaimNames.PhoneCountryCode, updatedUser.Phone.CountryCode)); + validatedToken.Claims.Add(new TokenClaim(ClaimNames.PhoneNumberRaw, updatedUser.Phone.Number)); + validatedToken.Claims.Add(new TokenClaim(Rfc7519ClaimNames.PhoneNumber, updatedUser.Phone.E164Formatted)); + validatedToken.Claims.Add(new TokenClaim(Rfc7519ClaimNames.IsPhoneVerified, "true")); TokenService.Setup(x => x.ValidateAsync(payload.Profile.Token, "profile+jwt", CancellationToken)).ReturnsAsync(validatedToken); Session session = new(updatedUser);