diff --git a/backend/src/Squidex.Domain.Users/UserManagerExtensions.cs b/backend/src/Squidex.Domain.Users/UserManagerExtensions.cs index 30cb86f19..c14291680 100644 --- a/backend/src/Squidex.Domain.Users/UserManagerExtensions.cs +++ b/backend/src/Squidex.Domain.Users/UserManagerExtensions.cs @@ -133,13 +133,7 @@ namespace Squidex.Domain.Users try { await DoChecked(() => userManager.CreateAsync(user), "Cannot create user."); - - var claims = values.ToClaims(true); - - if (claims.Count > 0) - { - await DoChecked(() => userManager.AddClaimsAsync(user, claims), "Cannot add user."); - } + await DoChecked(() => values.SyncClaims(userManager, user), "Cannot add user."); if (!string.IsNullOrWhiteSpace(values.Password)) { @@ -172,9 +166,12 @@ namespace Squidex.Domain.Users public static Task GenerateClientSecretAsync(this UserManager userManager, IdentityUser user) { - var claims = new List { new Claim(SquidexClaimTypes.ClientSecret, RandomHash.New()) }; + var update = new UserValues + { + ClientSecret = RandomHash.New() + }; - return userManager.SyncClaimsAsync(user, claims); + return update.SyncClaims(userManager, user); } public static async Task UpdateSafeAsync(this UserManager userManager, IdentityUser user, UserValues values) @@ -204,7 +201,7 @@ namespace Squidex.Domain.Users await DoChecked(() => userManager.SetUserNameAsync(user, values.Email), "Cannot update email."); } - await DoChecked(() => userManager.SyncClaimsAsync(user, values.ToClaims(false)), "Cannot update user."); + await DoChecked(() => values.SyncClaims(userManager, user), "Cannot update user."); if (!string.IsNullOrWhiteSpace(values.Password)) { @@ -251,36 +248,9 @@ namespace Squidex.Domain.Users } } - public static async Task SyncClaimsAsync(this UserManager userManager, IdentityUser user, List claims) + public static Task SyncClaims(this UserManager userManager, IdentityUser user, UserValues values) { - if (claims.Any()) - { - var oldClaims = await userManager.GetClaimsAsync(user); - - var oldClaimsToRemove = new List(); - - foreach (var oldClaim in oldClaims) - { - if (claims.Any(x => x.Type == oldClaim.Type)) - { - oldClaimsToRemove.Add(oldClaim); - } - } - - if (oldClaimsToRemove.Count > 0) - { - var result = await userManager.RemoveClaimsAsync(user, oldClaimsToRemove); - - if (!result.Succeeded) - { - return result; - } - } - - return await userManager.AddClaimsAsync(user, claims.Where(x => !string.IsNullOrWhiteSpace(x.Value))); - } - - return IdentityResult.Success; + return values.SyncClaims(userManager, user); } } } diff --git a/backend/src/Squidex.Domain.Users/UserValues.cs b/backend/src/Squidex.Domain.Users/UserValues.cs index 7ab4bf989..8e0f82b6d 100644 --- a/backend/src/Squidex.Domain.Users/UserValues.cs +++ b/backend/src/Squidex.Domain.Users/UserValues.cs @@ -5,9 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; using Squidex.Infrastructure.Security; using Squidex.Shared.Identity; @@ -21,67 +24,120 @@ namespace Squidex.Domain.Users public string? Password { get; set; } + public string? ClientSecret { get; set; } + public string Email { get; set; } + public bool? Hidden { get; set; } + public bool? Invited { get; set; } public bool? Consent { get; set; } public bool? ConsentForEmails { get; set; } - public bool? Hidden { get; set; } + public PermissionSet? Permissions { get; set; } - public PermissionSet Permissions { get; set; } + public List? CustomClaims { get; set; } - public List ToClaims(bool initial) - { - return ToClaimsCore(initial).ToList(); - } + public List<(string Name, string Value)>? Properties { get; set; } - private IEnumerable ToClaimsCore(bool initial) + internal async Task SyncClaims(UserManager userManager, IdentityUser user) { - if (!string.IsNullOrWhiteSpace(DisplayName)) + var current = await userManager.GetClaimsAsync(user); + + var claimsToRemove = new List(); + var claimsToAdd = new List(); + + void RemoveClaims(Func predicate) { - yield return new Claim(SquidexClaimTypes.DisplayName, DisplayName); + claimsToRemove.AddRange(current.Where(predicate)); } - if (!string.IsNullOrWhiteSpace(PictureUrl)) + void AddClaim(string type, string value) { - yield return new Claim(SquidexClaimTypes.PictureUrl, PictureUrl); + claimsToAdd.Add(new Claim(type, value)); } - if (Hidden.HasValue) + void SyncString(string type, string? value) { - yield return new Claim(SquidexClaimTypes.Hidden, Hidden.ToString()); + if (value != null) + { + RemoveClaims(x => x.Type == type); + + if (!string.IsNullOrWhiteSpace(value)) + { + AddClaim(type, value); + } + } } - if (Invited.HasValue) + void SyncBoolean(string type, bool? value) { - yield return new Claim(SquidexClaimTypes.Invited, Invited.ToString()); + if (value != null) + { + RemoveClaims(x => x.Type == type); + + if (value == true) + { + AddClaim(type, value.ToString()!); + } + } } - if (Consent.HasValue) + SyncString(SquidexClaimTypes.ClientSecret, ClientSecret); + SyncString(SquidexClaimTypes.DisplayName, DisplayName); + SyncString(SquidexClaimTypes.PictureUrl, PictureUrl); + + SyncBoolean(SquidexClaimTypes.Hidden, Hidden); + SyncBoolean(SquidexClaimTypes.Invited, Invited); + SyncBoolean(SquidexClaimTypes.Consent, Consent); + SyncBoolean(SquidexClaimTypes.ConsentForEmails, ConsentForEmails); + + if (CustomClaims != null) { - yield return new Claim(SquidexClaimTypes.Consent, Consent.ToString()); + foreach (var claim in CustomClaims) + { + SyncString(claim.Type, claim.Value); + } } - if (ConsentForEmails.HasValue) + if (Permissions != null) { - yield return new Claim(SquidexClaimTypes.ConsentForEmails, ConsentForEmails.ToString()); + RemoveClaims(x => x.Type == SquidexClaimTypes.Permissions); + + foreach (var permission in Permissions) + { + AddClaim(SquidexClaimTypes.Permissions, permission.Id); + } } - if (Permissions != null) + if (Properties != null) { - if (!initial) + RemoveClaims(x => x.Type.StartsWith(SquidexClaimTypes.CustomPrefix, StringComparison.OrdinalIgnoreCase)); + + foreach (var (name, value) in Properties) { - yield return new Claim(SquidexClaimTypes.Permissions, string.Empty); + AddClaim($"{SquidexClaimTypes.CustomPrefix}:{name}", value); } + } - foreach (var permission in Permissions) + if (claimsToRemove.Count > 0) + { + var result = await userManager.RemoveClaimsAsync(user, claimsToRemove); + + if (!result.Succeeded) { - yield return new Claim(SquidexClaimTypes.Permissions, permission.Id); + return result; } } + + if (claimsToAdd.Count > 0) + { + return await userManager.AddClaimsAsync(user, claimsToAdd); + } + + return IdentityResult.Success; } } } diff --git a/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs b/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs index 84078c4a2..92cc3989a 100644 --- a/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs +++ b/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs @@ -21,6 +21,8 @@ namespace Squidex.Shared.Identity public static readonly string Invited = "urn:squidex:invited"; + public static readonly string CustomPrefix = "urn:squidex:custom"; + public static readonly string Permissions = "urn:squidex:permissions"; public static readonly string PermissionsClient = "client_urn:squidex:permissions"; diff --git a/backend/src/Squidex.Shared/Users/UserExtensions.cs b/backend/src/Squidex.Shared/Users/UserExtensions.cs index 7a2e7c2b5..9d458a8cb 100644 --- a/backend/src/Squidex.Shared/Users/UserExtensions.cs +++ b/backend/src/Squidex.Shared/Users/UserExtensions.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Linq; using Squidex.Infrastructure.Security; using Squidex.Shared.Identity; @@ -76,7 +77,14 @@ namespace Squidex.Shared.Users public static string[] GetClaimValues(this IUser user, string type) { - return user.Claims.Where(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase)).Select(x => x.Value).ToArray(); + return user.Claims.Where(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value).ToArray(); + } + + public static List<(string Name, string Value)> GetCustomProperties(this IUser user) + { + return user.Claims.Where(x => x.Type.StartsWith(SquidexClaimTypes.CustomPrefix, StringComparison.OrdinalIgnoreCase)) + .Select(x => (x.Type.Substring(SquidexClaimTypes.CustomPrefix.Length + 1), x.Value)).ToList(); } public static bool HasClaim(this IUser user, string type) diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs index eab17d6ff..d31a09668 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs @@ -24,6 +24,7 @@ using Squidex.Config; using Squidex.Domain.Users; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Shared.Identity; using Squidex.Shared.Users; @@ -361,36 +362,27 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account private Task AddClaimsAsync(UserWithClaims user, ExternalLoginInfo externalLogin, string email, bool isFirst = false) { - var newClaims = new List(); - - void AddClaim(Claim claim) - { - newClaims.Add(claim); - - user.Claims.Add(claim); - } - - foreach (var squidexClaim in externalLogin.Principal.GetSquidexClaims()) + var update = new UserValues { - AddClaim(squidexClaim); - } + CustomClaims = externalLogin.Principal.GetSquidexClaims().ToList() + }; if (!user.HasPictureUrl()) { - AddClaim(new Claim(SquidexClaimTypes.PictureUrl, GravatarHelper.CreatePictureUrl(email))); + update.PictureUrl = GravatarHelper.CreatePictureUrl(email); } if (!user.HasDisplayName()) { - AddClaim(new Claim(SquidexClaimTypes.DisplayName, email)); + update.DisplayName = email; } if (isFirst) { - AddClaim(new Claim(SquidexClaimTypes.Permissions, Permissions.Admin)); + update.Permissions = new PermissionSet(Permissions.Admin); } - return MakeIdentityOperation(() => userManager.SyncClaimsAsync(user.Identity, newClaims)); + return MakeIdentityOperation(() => userManager.SyncClaims(user.Identity, update)); } private IActionResult RedirectToLogoutUrl(LogoutRequest context) diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs index c97af286b..869323308 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs @@ -13,7 +13,6 @@ namespace Squidex.Areas.IdentityServer.Controllers.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.")] diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangePropertiesModel.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangePropertiesModel.cs new file mode 100644 index 000000000..99d0aaaed --- /dev/null +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangePropertiesModel.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Users; + +namespace Squidex.Areas.IdentityServer.Controllers.Profile +{ + public class ChangePropertiesModel + { + public List Properties { get; set; } + + public UserValues ToValues() + { + var properties = Properties?.Select(x => x.ToTuple()).ToList() ?? new List<(string Name, string Value)>(); + + return new UserValues { Properties = properties }; + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs index 6a58446e4..3bb135cdd 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs @@ -56,7 +56,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile { var user = await userManager.GetUserWithClaimsAsync(User); - return View(await GetProfileVM(user, successMessage: successMessage)); + return View(await GetProfileVM(user, successMessage: successMessage)); } [HttpPost] @@ -76,7 +76,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Route("/account/profile/login-add-callback/")] public Task AddLoginCallback() { - return MakeChangeAsync(user => AddLoginAsync(user), + return MakeChangeAsync(u => AddLoginAsync(u), "Login added successfully."); } @@ -84,39 +84,47 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Route("/account/profile/update/")] public Task UpdateProfile(ChangeProfileModel model) { - return MakeChangeAsync(user => userManager.UpdateSafeAsync(user.Identity, model.ToValues()), - "Account updated successfully."); + return MakeChangeAsync(u => userManager.UpdateSafeAsync(u, model.ToValues()), + "Account updated successfully.", model); + } + + [HttpPost] + [Route("/account/profile/properties/")] + public Task UpdateProperties(ChangePropertiesModel model) + { + return MakeChangeAsync(u => userManager.UpdateSafeAsync(u, model.ToValues()), + "Account updated successfully.", model); } [HttpPost] [Route("/account/profile/login-remove/")] public Task RemoveLogin(RemoveLoginModel model) { - return MakeChangeAsync(user => userManager.RemoveLoginAsync(user.Identity, model.LoginProvider, model.ProviderKey), - "Login provider removed successfully."); + return MakeChangeAsync(u => userManager.RemoveLoginAsync(u, model.LoginProvider, model.ProviderKey), + "Login provider removed successfully.", model); } [HttpPost] [Route("/account/profile/password-set/")] public Task SetPassword(SetPasswordModel model) { - return MakeChangeAsync(user => userManager.AddPasswordAsync(user.Identity, model.Password), - "Password set successfully."); + return MakeChangeAsync(u => userManager.AddPasswordAsync(u, model.Password), + "Password set successfully.", model); } [HttpPost] [Route("/account/profile/password-change/")] public Task ChangePassword(ChangePasswordModel model) { - return MakeChangeAsync(user => userManager.ChangePasswordAsync(user.Identity, model.OldPassword, model.Password), - "Password changed successfully."); + return MakeChangeAsync(u => userManager.ChangePasswordAsync(u, model.OldPassword, model.Password), + "Password changed successfully.", model); } [HttpPost] [Route("/account/profile/generate-client-secret/")] public Task GenerateClientSecret() { - return MakeChangeAsync(user => userManager.GenerateClientSecretAsync(user.Identity), + return MakeChangeAsync(user => userManager.GenerateClientSecretAsync(user), "Client secret generated successfully."); } @@ -124,18 +132,18 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Route("/account/profile/upload-picture/")] public Task UploadPicture(List file) { - return MakeChangeAsync(user => UpdatePictureAsync(file, user), + return MakeChangeAsync(user => UpdatePictureAsync(file, user), "Picture uploaded successfully."); } - private async Task AddLoginAsync(UserWithClaims user) + private async Task AddLoginAsync(IdentityUser user) { var externalLogin = await signInManager.GetExternalLoginInfoWithDisplayNameAsync(userManager.GetUserId(User)); - return await userManager.AddLoginAsync(user.Identity, externalLogin); + return await userManager.AddLoginAsync(user, externalLogin); } - private async Task UpdatePictureAsync(List file, UserWithClaims user) + private async Task UpdatePictureAsync(List file, IdentityUser user) { if (file.Count != 1) { @@ -158,10 +166,10 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile await userPictureStore.UploadAsync(user.Id, thumbnailStream); } - return await userManager.UpdateSafeAsync(user.Identity, new UserValues { PictureUrl = SquidexClaimTypes.PictureUrlStore }); + return await userManager.UpdateSafeAsync(user, new UserValues { PictureUrl = SquidexClaimTypes.PictureUrlStore }); } - private async Task MakeChangeAsync(Func> action, string successMessage, ChangeProfileModel? model = null) + private async Task MakeChangeAsync(Func> action, string successMessage, T? model = null) where T : class { var user = await userManager.GetUserWithClaimsAsync(User); @@ -178,7 +186,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile string errorMessage; try { - var result = await action(user); + var result = await action(user.Identity); if (result.Succeeded) { @@ -197,7 +205,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile return View(nameof(Profile), await GetProfileVM(user, model, errorMessage)); } - private async Task GetProfileVM(UserWithClaims? user, ChangeProfileModel? model = null, string? errorMessage = null, string? successMessage = null) + private async Task GetProfileVM(UserWithClaims? user, T? model = null, string? errorMessage = null, string? successMessage = null) where T : class { if (user == null) { @@ -219,9 +227,9 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile ExternalLogins = taskForLogins.Result, ExternalProviders = taskForProviders.Result, DisplayName = user.DisplayName()!, - IsHidden = user.IsHidden(), HasPassword = taskForPassword.Result, HasPasswordAuth = identityOptions.AllowPasswordAuth, + IsHidden = user.IsHidden(), SuccessMessage = successMessage }; @@ -230,6 +238,11 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile SimpleMapper.Map(model, result); } + if (result.Properties == null) + { + result.Properties = user.GetCustomProperties().Select(UserProperty.FromTuple).ToList(); + } + return result; } } diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs index d2f377e8d..03940028d 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs @@ -30,6 +30,8 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile public bool HasPasswordAuth { get; set; } + public List Properties { get; set; } + public IList ExternalLogins { get; set; } public IList ExternalProviders { get; set; } diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/UserProperty.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/UserProperty.cs new file mode 100644 index 000000000..8d5b6e80e --- /dev/null +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/UserProperty.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.IdentityServer.Controllers.Profile +{ + public sealed class UserProperty + { + [Required(ErrorMessage = "Name is required.")] + public string Name { get; set; } + + [Required(ErrorMessage = "Value is required.")] + public string Value { get; set; } + + public (string Name, string Value) ToTuple() + { + return (Name, Value); + } + + public static UserProperty FromTuple((string Name, string Value) value) + { + return new UserProperty { Name = value.Name, Value = value.Value }; + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml index 2aed37e37..31b33c81f 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml @@ -4,8 +4,18 @@ ViewBag.Class = "profile-lg"; ViewBag.Title = "Profile"; + + void RenderValidation(string field) + { + @if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) + { +
+ @Html.ValidationMessage(field) +
+ } + } } - +

Edit Profile

Personal Information

@@ -23,7 +33,7 @@ @Model.ErrorMessage } - +
@@ -42,41 +52,31 @@
- - @if (ViewContext.ViewData.ModelState["Email"]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) - { -
- -
- } - + @{ RenderValidation("Email"); } + +
- @if (ViewContext.ViewData.ModelState["DisplayName"]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) - { -
- -
- } + @{ RenderValidation("DisplayName"); } - +
- + - +
- + @if (Model.ExternalProviders.Any()) {
@@ -84,9 +84,9 @@ - - - + + + @foreach (var login in Model.ExternalLogins) { @@ -101,8 +101,8 @@ @if (Model.ExternalLogins.Count > 1 || Model.HasPassword) { - - + + + + + } + + +
+ +
+ + + + +