diff --git a/src/Squidex.Read.MongoDb/Users/MongoUserStore.cs b/src/Squidex.Read.MongoDb/Users/MongoUserStore.cs index e884cfe94..c5c8ccccb 100644 --- a/src/Squidex.Read.MongoDb/Users/MongoUserStore.cs +++ b/src/Squidex.Read.MongoDb/Users/MongoUserStore.cs @@ -146,11 +146,6 @@ namespace Squidex.Read.MongoDb.Users return innerStore.SetPasswordHashAsync((WrappedIdentityUser)user, passwordHash, cancellationToken); } - public Task HasPasswordAsync(IUser user, CancellationToken cancellationToken) - { - return innerStore.HasPasswordAsync((WrappedIdentityUser)user, cancellationToken); - } - public Task AddToRoleAsync(IUser user, string roleName, CancellationToken cancellationToken) { return innerStore.AddToRoleAsync((WrappedIdentityUser)user, roleName, cancellationToken); @@ -325,5 +320,10 @@ namespace Squidex.Read.MongoDb.Users { return innerStore.GetTokenAsync((WrappedIdentityUser)user, loginProvider, name, cancellationToken); } + + public Task HasPasswordAsync(IUser user, CancellationToken cancellationToken) + { + return Task.FromResult(!string.IsNullOrWhiteSpace(((WrappedIdentityUser)user).PasswordHash)); + } } } diff --git a/src/Squidex.Read/Users/UserManagerExtensions.cs b/src/Squidex.Read/Users/UserManagerExtensions.cs index 7b262f6e2..74a4be1d4 100644 --- a/src/Squidex.Read/Users/UserManagerExtensions.cs +++ b/src/Squidex.Read/Users/UserManagerExtensions.cs @@ -11,7 +11,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; -using Squidex.Core.Identity; using Squidex.Infrastructure; // ReSharper disable ImplicitlyCapturedClosure @@ -83,7 +82,7 @@ namespace Squidex.Read.Users if (!string.IsNullOrWhiteSpace(displayName)) { - user.SetClaim(SquidexClaimTypes.SquidexDisplayName, displayName); + user.SetDisplayName(displayName); } await DoChecked(() => userManager.UpdateAsync(user), "Cannot update user."); diff --git a/src/Squidex/Controllers/UI/Account/AccountController.cs b/src/Squidex/Controllers/UI/Account/AccountController.cs index 26a20390a..a869bc1ef 100644 --- a/src/Squidex/Controllers/UI/Account/AccountController.cs +++ b/src/Squidex/Controllers/UI/Account/AccountController.cs @@ -9,6 +9,7 @@ using System; using System.Linq; using System.Runtime.CompilerServices; +using System.Security; using System.Security.Claims; using System.Text; using System.Threading.Tasks; @@ -59,20 +60,6 @@ namespace Squidex.Controllers.UI.Account this.identityOptions = identityOptions; this.signInManager = signInManager; } - - [HttpGet] - [Route("account/forbidden")] - public IActionResult Forbidden() - { - return View("Error"); - } - - [HttpGet] - [Route("account/accessdenied")] - public IActionResult AccessDenied() - { - return View("LockedOut"); - } [HttpGet] [Route("client-callback-silent/")] @@ -89,15 +76,22 @@ namespace Squidex.Controllers.UI.Account } [HttpGet] - [Route("account/logout-completed/")] - public IActionResult LogoutCompleted() + [Route("account/forbidden")] + public IActionResult Forbidden() { - return View(); + throw new SecurityException("User is not allowed to login."); + } + + [HttpGet] + [Route("account/accessdenied")] + public IActionResult AccessDenied() + { + return View("LockedOut"); } [HttpGet] - [Route("account/error/")] - public IActionResult Error() + [Route("account/logout-completed/")] + public IActionResult LogoutCompleted() { return View(); } diff --git a/src/Squidex/Controllers/UI/Error/ErrorController.cs b/src/Squidex/Controllers/UI/Error/ErrorController.cs new file mode 100644 index 000000000..c193ae839 --- /dev/null +++ b/src/Squidex/Controllers/UI/Error/ErrorController.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// ErrorController.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; + +namespace Squidex.Controllers.UI.Error +{ + public class ErrorController : Controller + { + [Route("error")] + public IActionResult Error() + { + return View(); + } + } +} diff --git a/src/Squidex/Controllers/UI/Profile/ChangePasswordModel.cs b/src/Squidex/Controllers/UI/Profile/ChangePasswordModel.cs new file mode 100644 index 000000000..3707e8e18 --- /dev/null +++ b/src/Squidex/Controllers/UI/Profile/ChangePasswordModel.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// ChangePasswordModel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Controllers.UI.Profile +{ + public class ChangePasswordModel + { + [Required(ErrorMessage = "Old Password is required.")] + public string OldPassword { get; set; } + + [Required(ErrorMessage = "Password is required.")] + public string Password { get; set; } + + [Compare(nameof(Password), ErrorMessage = "Passwords must be identitical.")] + public string PasswordConfirm { get; set; } + } +} diff --git a/src/Squidex/Controllers/UI/Profile/ChangeProfileModel.cs b/src/Squidex/Controllers/UI/Profile/ChangeProfileModel.cs new file mode 100644 index 000000000..2969c0dfa --- /dev/null +++ b/src/Squidex/Controllers/UI/Profile/ChangeProfileModel.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// ChangeProfileModel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Controllers.UI.Profile +{ + public class ChangeProfileModel + { + [Required(ErrorMessage = "Email is required.")] + [EmailAddress(ErrorMessage = "Email is not valid.")] + public string Email { get; set; } + + [Required(ErrorMessage = "DisplayName is required.")] + public string DisplayName { get; set; } + } +} diff --git a/src/Squidex/Controllers/UI/Profile/ProfileController.cs b/src/Squidex/Controllers/UI/Profile/ProfileController.cs new file mode 100644 index 000000000..114ace48d --- /dev/null +++ b/src/Squidex/Controllers/UI/Profile/ProfileController.cs @@ -0,0 +1,123 @@ +// ========================================================================== +// ProfileController.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NSwag.Annotations; +using Squidex.Config.Identity; +using Squidex.Infrastructure.Reflection; +using Squidex.Read.Users; + +namespace Squidex.Controllers.UI.Profile +{ + [Authorize] + [SwaggerIgnore] + public class ProfileController : Controller + { + private readonly UserManager userManager; + private readonly IOptions identityOptions; + + public ProfileController(UserManager userManager, IOptions identityOptions) + { + this.userManager = userManager; + this.identityOptions = identityOptions; + } + + [HttpGet] + [Route("/account/profile")] + public async Task Profile(string successMessage = null) + { + var user = await userManager.GetUserAsync(User); + + ViewBag.SuccessMessage = successMessage; + + return View(await GetProfileVM(user)); + } + + [HttpPost] + [Route("/account/profile")] + public Task Profile(ChangeProfileModel model) + { + return MakeChangeAsync(async user => + { + user.UpdateEmail(model.Email); + user.SetDisplayName(model.DisplayName); + + return await userManager.UpdateAsync(user); + }, "Account updated successfully. Please logout and login again to see the changes."); + } + + [HttpPost] + [Route("/account/setpassword")] + public Task SetPassword(SetPasswordModel model) + { + return MakeChangeAsync(user => userManager.AddPasswordAsync(user, model.Password), + "Password set successfully."); + } + + [HttpPost] + [Route("/account/changepassword")] + public Task ChangePassword(ChangePasswordModel model) + { + return MakeChangeAsync(user => userManager.ChangePasswordAsync(user, model.OldPassword, model.Password), + "Password changed successfully."); + } + + private async Task MakeChangeAsync(Func> action, string successMessage, ChangeProfileModel model = null) + { + var user = await userManager.GetUserAsync(User); + + if (!ModelState.IsValid) + { + return View("Profile", await GetProfileVM(user, model)); + } + + try + { + var result = await action(user); + + if (result.Succeeded) + { + return RedirectToAction(nameof(Profile), new { successMessage }); + } + + ViewBag.ErrorMessage = string.Join(". ", result.Errors.Select(x => x.Description)); + } + catch + { + ViewBag.ErrorMessage = "An unexpected exception occurred."; + } + + return View("Profile", await GetProfileVM(user, model)); + } + + private async Task GetProfileVM(IUser user, ChangeProfileModel model = null) + { + var result = new ProfileVM + { + Email = user.Email, + DisplayName = user.DisplayName(), + PictureUrl = user.PictureUrl(), + HasPassword = await userManager.HasPasswordAsync(user), + HasPasswordAuth = identityOptions.Value.AllowPasswordAuth + }; + + if (model != null) + { + SimpleMapper.Map(model, result); + } + + return result; + } + } +} diff --git a/src/Squidex/Controllers/UI/Profile/ProfileVM.cs b/src/Squidex/Controllers/UI/Profile/ProfileVM.cs new file mode 100644 index 000000000..3d2d34171 --- /dev/null +++ b/src/Squidex/Controllers/UI/Profile/ProfileVM.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// ProfileVM.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Controllers.UI.Profile +{ + public sealed class ProfileVM + { + public string Email { get; set; } + + public string DisplayName { get; set; } + + public string PictureUrl { get; set; } + + public bool HasPassword { get; set; } + + public bool HasPasswordAuth { get; set; } + } +} diff --git a/src/Squidex/Controllers/UI/Profile/SetPasswordModel.cs b/src/Squidex/Controllers/UI/Profile/SetPasswordModel.cs new file mode 100644 index 000000000..321364acc --- /dev/null +++ b/src/Squidex/Controllers/UI/Profile/SetPasswordModel.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// SetPasswordModel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Controllers.UI.Profile +{ + public class SetPasswordModel + { + [Required(ErrorMessage = "Password is required.")] + public string Password { get; set; } + + [Compare(nameof(Password), ErrorMessage = "Passwords must be identitical.")] + public string PasswordConfirm { get; set; } + } +} diff --git a/src/Squidex/Startup.cs b/src/Squidex/Startup.cs index 9cd4aae12..a4927caef 100644 --- a/src/Squidex/Startup.cs +++ b/src/Squidex/Startup.cs @@ -36,7 +36,8 @@ namespace Squidex { "/client-callback-popup", "/client-callback-silent", - "/account" + "/account", + "/error" }; private IConfigurationRoot Configuration { get; } @@ -137,6 +138,10 @@ namespace Squidex { identityApp.UseDeveloperExceptionPage(); } + else + { + identityApp.UseExceptionHandler("/error"); + } identityApp.UseMyIdentity(); identityApp.UseMyIdentityServer(); @@ -177,7 +182,6 @@ namespace Squidex { if (Environment.IsDevelopment()) { - app.UseDeveloperExceptionPage(); app.UseWebpackProxy(); app.Use((context, next) => diff --git a/src/Squidex/Views/Account/Error.cshtml b/src/Squidex/Views/Account/Error.cshtml deleted file mode 100644 index 5c6f9bf15..000000000 --- a/src/Squidex/Views/Account/Error.cshtml +++ /dev/null @@ -1,9 +0,0 @@ -@{ - ViewBag.Title = "Login failed"; -} - -

Login failed

- -

- We are really sorry, something went wrong when you tried to login. -

\ No newline at end of file diff --git a/src/Squidex/Views/Account/Login.cshtml b/src/Squidex/Views/Account/Login.cshtml index 74790ccf1..d040bcace 100644 --- a/src/Squidex/Views/Account/Login.cshtml +++ b/src/Squidex/Views/Account/Login.cshtml @@ -9,29 +9,31 @@ ViewBag.Title = type; } -