From b107a4a4f2b94d0d7242952274b1b633c71024de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Fri, 22 Feb 2019 22:17:08 +0100 Subject: [PATCH] #218 - Client-side profile change. --- .../Bootstrap/BootstrapTask.cs | 4 +- .../Commands/Handlers/UserHandler.cs | 76 ++++++++++++---- src/Money.UI.Backend/Hubs/ApiHub.cs | 3 +- .../Models/ClaimsPrincipalExtensions.cs | 1 + .../Bootstrap/BootstrapTask.cs | 20 ++--- src/Money.UI.Blazor/Commands/ChangeEmail.cs | 31 +++++++ .../DemoUserCantBeChangedException.cs | 15 ++++ .../Commands/EmailChangeFailedException.cs | 15 ++++ .../Components/ExceptionPanel.cshtml | 4 + src/Money.UI.Blazor/Events/EmailChanged.cs | 24 +++++ .../Models/Api/CommandMapper.cs | 1 + src/Money.UI.Blazor/Models/Api/QueryMapper.cs | 3 + .../Models/User/ProfileModel.cs | 37 ++++++++ src/Money.UI.Blazor/Pages/User/Profile.cshtml | 28 +++--- .../Pages/User/Profile.cshtml.cs | 87 +++++++++++++++++++ src/Money.UI.Blazor/Queries/GetProfile.cs | 17 ++++ .../Services/MessageBuilder.cs | 2 + .../Services/UserMiddleware.cs | 40 +++++++++ 18 files changed, 364 insertions(+), 44 deletions(-) create mode 100644 src/Money.UI.Blazor/Commands/ChangeEmail.cs create mode 100644 src/Money.UI.Blazor/Commands/DemoUserCantBeChangedException.cs create mode 100644 src/Money.UI.Blazor/Commands/EmailChangeFailedException.cs create mode 100644 src/Money.UI.Blazor/Events/EmailChanged.cs create mode 100644 src/Money.UI.Blazor/Models/User/ProfileModel.cs create mode 100644 src/Money.UI.Blazor/Pages/User/Profile.cshtml.cs create mode 100644 src/Money.UI.Blazor/Queries/GetProfile.cs create mode 100644 src/Money.UI.Blazor/Services/UserMiddleware.cs diff --git a/src/Money.UI.Backend/Bootstrap/BootstrapTask.cs b/src/Money.UI.Backend/Bootstrap/BootstrapTask.cs index f732fe35..be11a1a4 100644 --- a/src/Money.UI.Backend/Bootstrap/BootstrapTask.cs +++ b/src/Money.UI.Backend/Bootstrap/BootstrapTask.cs @@ -179,7 +179,9 @@ private void Domain() bootstrapTask.Initialize(); - commandDispatcher.Handlers.AddAll(new UserHandler(services.BuildServiceProvider().GetRequiredService>(), eventDispatcher)); + UserHandler userHandler = new UserHandler(services.BuildServiceProvider().GetRequiredService>(), eventDispatcher); + commandDispatcher.Handlers.AddAll(userHandler); + queryDispatcher.AddAll(userHandler); } private void ReadModels() diff --git a/src/Money.UI.Backend/Commands/Handlers/UserHandler.cs b/src/Money.UI.Backend/Commands/Handlers/UserHandler.cs index 01a95df0..c8c088d1 100644 --- a/src/Money.UI.Backend/Commands/Handlers/UserHandler.cs +++ b/src/Money.UI.Backend/Commands/Handlers/UserHandler.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.Identity; using Money.Events; using Money.Models; +using Money.Queries; using Neptuo; using Neptuo.Collections.Specialized; +using Neptuo.Commands; using Neptuo.Commands.Handlers; using Neptuo.Events; using Neptuo.Models; using Neptuo.Models.Keys; +using Neptuo.Queries.Handlers; using System; using System.Collections.Generic; using System.Linq; @@ -15,7 +18,7 @@ namespace Money.Commands.Handlers { - public class UserHandler : ICommandHandler> + public class UserHandler : ICommandHandler>, ICommandHandler>, IQueryHandler { private readonly UserManager userManager; private readonly IEventDispatcher eventDispatcher; @@ -28,25 +31,66 @@ public UserHandler(UserManager userManager, IEventDispatcher ev this.eventDispatcher = eventDispatcher; } - public async Task HandleAsync(Envelope command) + private T DecorateException(ICommand command, StringKey userKey, T ex) + where T : AggregateRootException + { + AggregateRootExceptionDecorator decorator = new AggregateRootExceptionDecorator(ex); + decorator.SetCommandKey(command.Key); + decorator.SetSourceCommandKey(command.Key); + decorator.SetKey(userKey); + return ex; + } + + private async Task<(ApplicationUser Model, StringKey Key)> GetUserAsync(Envelope envelope) + { + StringKey userKey = envelope.Metadata.Get("UserKey"); + return (await GetUserAsync(userKey), userKey); + } + + private async Task GetUserAsync(StringKey userKey) { - StringKey userKey = command.Metadata.Get("UserKey"); - ApplicationUser user = await userManager.FindByIdAsync(userKey.Identifier); - if (user == null) + ApplicationUser model = await userManager.FindByIdAsync(userKey.Identifier); + if (model == null) throw new InvalidOperationException($"Unable to load user with ID '{userKey.Identifier}'."); - IdentityResult result = await userManager.ChangePasswordAsync(user, command.Body.Current, command.Body.New); + return model; + } + + private void EnsureNotDemo(Envelope envelope, (ApplicationUser Model, StringKey Key) user) + where T : ICommand + { + if (user.Model.IsDemo()) + throw DecorateException(envelope.Body, user.Key, new DemoUserCantBeChangedException()); + } + + public async Task HandleAsync(Envelope command) + { + var user = await GetUserAsync(command); + EnsureNotDemo(command, user); + + var result = await userManager.ChangePasswordAsync(user.Model, command.Body.Current, command.Body.New); + if (!result.Succeeded) + throw DecorateException(command.Body, user.Key, new PasswordChangeFailedException(String.Join(Environment.NewLine, result.Errors.Select(e => e.Description)))); + + await eventDispatcher.PublishAsync(new PasswordChanged(GuidKey.Create(Guid.NewGuid(), "PasswordChanged"), user.Key)); + } + + public async Task HandleAsync(Envelope command) + { + var user = await GetUserAsync(command); + EnsureNotDemo(command, user); + + var result = await userManager.SetEmailAsync(user.Model, command.Body.Email); if (!result.Succeeded) - { - var ex = new PasswordChangeFailedException(String.Join(Environment.NewLine, result.Errors.Select(e => e.Description))); - AggregateRootExceptionDecorator decorator = new AggregateRootExceptionDecorator(ex); - decorator.SetCommandKey(command.Body.Key); - decorator.SetSourceCommandKey(command.Body.Key); - decorator.SetKey(userKey); - throw ex; - } - - await eventDispatcher.PublishAsync(new PasswordChanged(GuidKey.Create(Guid.NewGuid(), "PasswordChanged"), userKey)); + throw DecorateException(command.Body, user.Key, new EmailChangeFailedException()); + + await eventDispatcher.PublishAsync(new EmailChanged(GuidKey.Create(Guid.NewGuid(), "EmailChanged"), user.Key, command.Body.Email)); + } + + public async Task HandleAsync(GetProfile query) + { + var user = await GetUserAsync(query.UserKey.AsStringKey()); + return new ProfileModel(user.UserName, user.Email); } } } diff --git a/src/Money.UI.Backend/Hubs/ApiHub.cs b/src/Money.UI.Backend/Hubs/ApiHub.cs index 051e0a73..9197a6b4 100644 --- a/src/Money.UI.Backend/Hubs/ApiHub.cs +++ b/src/Money.UI.Backend/Hubs/ApiHub.cs @@ -26,7 +26,7 @@ public class ApiHub : Hub, IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, - IEventHandler + IEventHandler, IEventHandler { private readonly FormatterContainer formatters; @@ -128,6 +128,7 @@ private Task RaiseEvent(T payload) Task IEventHandler.HandleAsync(OutcomeWhenChanged payload) => RaiseEvent(payload); Task IEventHandler.HandleAsync(PasswordChanged payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(EmailChanged payload) => RaiseEvent(payload); public void Handle(AggregateRootException exception) { diff --git a/src/Money.UI.Backend/Models/ClaimsPrincipalExtensions.cs b/src/Money.UI.Backend/Models/ClaimsPrincipalExtensions.cs index 422d3fe7..112db747 100644 --- a/src/Money.UI.Backend/Models/ClaimsPrincipalExtensions.cs +++ b/src/Money.UI.Backend/Models/ClaimsPrincipalExtensions.cs @@ -13,5 +13,6 @@ public static class ClaimsPrincipalExtensions public const string DemoUserPassword = "demo"; public static bool IsDemo(this ClaimsPrincipal user) => user.Identity.Name == DemoUserName; + public static bool IsDemo(this ApplicationUser user) => user.UserName == DemoUserName; } } diff --git a/src/Money.UI.Blazor/Bootstrap/BootstrapTask.cs b/src/Money.UI.Blazor/Bootstrap/BootstrapTask.cs index cae72bde..8ed45c6f 100644 --- a/src/Money.UI.Blazor/Bootstrap/BootstrapTask.cs +++ b/src/Money.UI.Blazor/Bootstrap/BootstrapTask.cs @@ -116,16 +116,16 @@ private void Domain() private void QueryMiddlewares(IEventHandlerCollection handlers) { - CategoryMiddleware categoryMiddleware = new CategoryMiddleware(); - CurrencyMiddleware currencyMiddleware = new CurrencyMiddleware(); - - handlers - .AddAll(categoryMiddleware) - .AddAll(currencyMiddleware); - - services - .AddSingleton(categoryMiddleware) - .AddSingleton(currencyMiddleware); + void AddMiddlewareAndEventHandler(T handler) + where T : HttpQueryDispatcher.IMiddleware + { + handlers.AddAll(handler); + services.AddSingleton(handler); + } + + AddMiddlewareAndEventHandler(new CategoryMiddleware()); + AddMiddlewareAndEventHandler(new CurrencyMiddleware()); + AddMiddlewareAndEventHandler(new UserMiddleware()); } } } diff --git a/src/Money.UI.Blazor/Commands/ChangeEmail.cs b/src/Money.UI.Blazor/Commands/ChangeEmail.cs new file mode 100644 index 00000000..64f27893 --- /dev/null +++ b/src/Money.UI.Blazor/Commands/ChangeEmail.cs @@ -0,0 +1,31 @@ +using Neptuo; +using Neptuo.Commands; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Commands +{ + /// + /// A command for changing user's email. + /// + public class ChangeEmail : Command + { + /// + /// Gets an user's email. + /// + public string Email { get; } + + /// + /// Creates a new instance. + /// + /// An user's email. + public ChangeEmail(string email) + { + Ensure.NotNull(email, "email"); + Email = email; + } + } +} diff --git a/src/Money.UI.Blazor/Commands/DemoUserCantBeChangedException.cs b/src/Money.UI.Blazor/Commands/DemoUserCantBeChangedException.cs new file mode 100644 index 00000000..9e9ab034 --- /dev/null +++ b/src/Money.UI.Blazor/Commands/DemoUserCantBeChangedException.cs @@ -0,0 +1,15 @@ +using Neptuo.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Commands +{ + /// + /// An excception raised when demo user tries to change it's account. + /// + public class DemoUserCantBeChangedException : AggregateRootException + { } +} diff --git a/src/Money.UI.Blazor/Commands/EmailChangeFailedException.cs b/src/Money.UI.Blazor/Commands/EmailChangeFailedException.cs new file mode 100644 index 00000000..a41d8145 --- /dev/null +++ b/src/Money.UI.Blazor/Commands/EmailChangeFailedException.cs @@ -0,0 +1,15 @@ +using Neptuo.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Commands +{ + /// + /// An exception raised when user email change fails. + /// + public class EmailChangeFailedException : AggregateRootException + { } +} diff --git a/src/Money.UI.Blazor/Components/ExceptionPanel.cshtml b/src/Money.UI.Blazor/Components/ExceptionPanel.cshtml index a852021b..da330c22 100644 --- a/src/Money.UI.Blazor/Components/ExceptionPanel.cshtml +++ b/src/Money.UI.Blazor/Components/ExceptionPanel.cshtml @@ -42,8 +42,12 @@ message = MessageBuilder.CantDeleteDefaultCurrency(); else if (e is CantDeleteLastCurrencyException) message = MessageBuilder.CantDeleteLastCurrency(); + else if (e is DemoUserCantBeChangedException) + message = MessageBuilder.DemoUserCantBeChanged(); else if (e is PasswordChangeFailedException passwordChangeFailed) message = MessageBuilder.PasswordChangeFailed(passwordChangeFailed.ErrorDescription); + else if (e is EmailChangeFailedException) + message = MessageBuilder.EmailChangeFailed(); Message = message; } diff --git a/src/Money.UI.Blazor/Events/EmailChanged.cs b/src/Money.UI.Blazor/Events/EmailChanged.cs new file mode 100644 index 00000000..9d9559e2 --- /dev/null +++ b/src/Money.UI.Blazor/Events/EmailChanged.cs @@ -0,0 +1,24 @@ +using Neptuo.Models.Keys; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Events +{ + /// + /// An event raised when user email has been changed. + /// + public class EmailChanged : UserEvent + { + public string Email { get; } + + public EmailChanged(IKey key, IKey aggregateKey, string email) + : base(key, aggregateKey, 0) + { + UserKey = aggregateKey; + Email = email; + } + } +} diff --git a/src/Money.UI.Blazor/Models/Api/CommandMapper.cs b/src/Money.UI.Blazor/Models/Api/CommandMapper.cs index 2030f7e0..572c6cb2 100644 --- a/src/Money.UI.Blazor/Models/Api/CommandMapper.cs +++ b/src/Money.UI.Blazor/Models/Api/CommandMapper.cs @@ -32,6 +32,7 @@ public CommandMapper() Add("currency-delete"); Add("user-change-password"); + Add("user-change-email"); } } } diff --git a/src/Money.UI.Blazor/Models/Api/QueryMapper.cs b/src/Money.UI.Blazor/Models/Api/QueryMapper.cs index 9221e552..855544cb 100644 --- a/src/Money.UI.Blazor/Models/Api/QueryMapper.cs +++ b/src/Money.UI.Blazor/Models/Api/QueryMapper.cs @@ -1,4 +1,5 @@ using Money.Models.Queries; +using Money.Queries; using System; using System.Collections.Generic; using System.Linq; @@ -30,6 +31,8 @@ public QueryMapper() Add("year-outcome-from-category"); Add("search-outcomes"); + + Add("user-profile"); } } } diff --git a/src/Money.UI.Blazor/Models/User/ProfileModel.cs b/src/Money.UI.Blazor/Models/User/ProfileModel.cs new file mode 100644 index 00000000..37c41688 --- /dev/null +++ b/src/Money.UI.Blazor/Models/User/ProfileModel.cs @@ -0,0 +1,37 @@ +using Neptuo; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Models +{ + /// + /// An user profile information. + /// + public class ProfileModel + { + /// + /// Gets an username. + /// + public string UserName { get; set; } + + /// + /// Gets an user's email. + /// + public string Email { get; set; } + + /// + /// Creates a new instance. + /// + /// An username. + /// An user's email. + public ProfileModel(string userName, string email) + { + Ensure.NotNull(userName, "userName"); + UserName = userName; + Email = email; + } + } +} diff --git a/src/Money.UI.Blazor/Pages/User/Profile.cshtml b/src/Money.UI.Blazor/Pages/User/Profile.cshtml index e81d82b5..7fe81695 100644 --- a/src/Money.UI.Blazor/Pages/User/Profile.cshtml +++ b/src/Money.UI.Blazor/Pages/User/Profile.cshtml @@ -1,33 +1,29 @@ @page "/user" +@inherits ProfileBase
+ + + @if (IsSuccess) + { + + } +
-
+
- +
-
- - -
- +
-
- -@functions -{ - private async Task OnFormSubmit() - { - - } -} \ No newline at end of file + \ No newline at end of file diff --git a/src/Money.UI.Blazor/Pages/User/Profile.cshtml.cs b/src/Money.UI.Blazor/Pages/User/Profile.cshtml.cs new file mode 100644 index 00000000..2129940b --- /dev/null +++ b/src/Money.UI.Blazor/Pages/User/Profile.cshtml.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Blazor.Components; +using Money.Commands; +using Money.Events; +using Money.Models; +using Money.Models.Loading; +using Money.Queries; +using Neptuo.Commands; +using Neptuo.Events; +using Neptuo.Events.Handlers; +using Neptuo.Queries; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Pages +{ + public class ProfileBase : BlazorComponent, IEventHandler, IDisposable + { + private ProfileModel model; + + [Inject] + public IQueryDispatcher Queries { get; set; } + + [Inject] + public ICommandDispatcher Commands { get; set; } + + [Inject] + public IEventHandlerCollection EventHandlers { get; set; } + + public string UserName { get; private set; } + public string Email { get; set; } + + public bool IsSuccess { get; set; } + + protected override async Task OnInitAsync() + { + BindEvents(); + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + model = await Queries.QueryAsync(new GetProfile()); + Email = model.Email; + UserName = model.UserName; + } + + protected async Task OnFormSubmit() + { + IsSuccess = false; + + if (Email != model.Email) + await Commands.HandleAsync(new ChangeEmail(Email)); + } + + public void Dispose() + { + UnBindEvents(); + } + + #region Events + + private void BindEvents() + { + EventHandlers + .Add(this); + } + + private void UnBindEvents() + { + EventHandlers + .Remove(this); + } + + async Task IEventHandler.HandleAsync(EmailChanged payload) + { + IsSuccess = true; + await ReloadAsync(); + + StateHasChanged(); + } + + #endregion + } +} diff --git a/src/Money.UI.Blazor/Queries/GetProfile.cs b/src/Money.UI.Blazor/Queries/GetProfile.cs new file mode 100644 index 00000000..084e1146 --- /dev/null +++ b/src/Money.UI.Blazor/Queries/GetProfile.cs @@ -0,0 +1,17 @@ +using Money.Models; +using Money.Models.Queries; +using Neptuo.Queries; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Queries +{ + /// + /// Gets a user profile information. + /// + public class GetProfile : UserQuery, IQuery + { } +} diff --git a/src/Money.UI.Blazor/Services/MessageBuilder.cs b/src/Money.UI.Blazor/Services/MessageBuilder.cs index c7db1c45..8a1f87b2 100644 --- a/src/Money.UI.Blazor/Services/MessageBuilder.cs +++ b/src/Money.UI.Blazor/Services/MessageBuilder.cs @@ -19,6 +19,8 @@ public class MessageBuilder public string OutcomeAlreadyDeleted() => "The outcome is already deleted."; public string OutcomeAlreadyHasCategory() => "The outcome already has this category."; + public string DemoUserCantBeChanged() => "A demo user can't modified."; public string PasswordChangeFailed(string error) => error.Replace(Environment.NewLine, "
"); + public string EmailChangeFailed() => "A user email change failed."; } } diff --git a/src/Money.UI.Blazor/Services/UserMiddleware.cs b/src/Money.UI.Blazor/Services/UserMiddleware.cs new file mode 100644 index 00000000..9e953caa --- /dev/null +++ b/src/Money.UI.Blazor/Services/UserMiddleware.cs @@ -0,0 +1,40 @@ +using Money.Events; +using Money.Models; +using Money.Queries; +using Neptuo.Events.Handlers; +using Neptuo.Queries; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Services +{ + internal class UserMiddleware : HttpQueryDispatcher.IMiddleware, + IEventHandler + { + private ProfileModel profile; + + public async Task ExecuteAsync(object query, HttpQueryDispatcher dispatcher, HttpQueryDispatcher.Next next) + { + if (query is GetProfile) + { + if (profile == null) + profile = (ProfileModel)await next(query); + + return profile; + } + + return await next(query); + } + + Task IEventHandler.HandleAsync(EmailChanged payload) + { + if (profile != null) + profile.Email = payload.Email; + + return Task.CompletedTask; + } + } +}