diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs index 882525c8d073..daf5e1b1984f 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs @@ -42,31 +42,42 @@ public RequestRedirectService( { requestedPath = requestedPath.EnsureStartsWith("/"); + IPublishedContent? startItem = GetStartItem(); + // must append the root content url segment if it is not hidden by config, because // the URL tracking is based on the actual URL, including the root content url segment - if (_globalSettings.HideTopLevelNodeFromPath == false) + if (_globalSettings.HideTopLevelNodeFromPath == false && startItem?.UrlSegment != null) { - IPublishedContent? startItem = GetStartItem(); - if (startItem?.UrlSegment != null) - { - requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; - } + requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; } var culture = _requestCultureService.GetRequestedCulture(); - // append the configured domain content ID to the path if we have a domain bound request, - // because URL tracking registers the tracked url like "{domain content ID}/{content path}" - Uri contentRoute = GetDefaultRequestUri(requestedPath); - DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); - if (domainAndUri != null) + // important: redirect URLs are always tracked without trailing slashes + requestedPath = requestedPath.TrimEnd("/"); + IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture); + + // if a redirect URL was not found, try by appending the start item ID because URL tracking might have tracked + // a redirect with "{root content ID}/{content path}" + if (redirectUrl is null && startItem is not null) { - requestedPath = GetContentRoute(domainAndUri, contentRoute); - culture ??= domainAndUri.Culture; + redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl($"{startItem.Id}{requestedPath}", culture); + } + + // still no redirect URL found - try looking for a configured domain if we have a domain bound request, + // because URL tracking might have tracked a redirect with "{domain content ID}/{content path}" + if (redirectUrl is null) + { + Uri contentRoute = GetDefaultRequestUri(requestedPath); + DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); + if (domainAndUri is not null) + { + requestedPath = GetContentRoute(domainAndUri, contentRoute); + culture ??= domainAndUri.Culture; + redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture); + } } - // important: redirect URLs are always tracked without trailing slashes - IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath.TrimEnd("/"), culture); IPublishedContent? content = redirectUrl != null ? _apiPublishedContentCache.GetById(redirectUrl.ContentKey) : null; diff --git a/src/Umbraco.Core/Cache/RepositoryCachePolicyOptions.cs b/src/Umbraco.Core/Cache/RepositoryCachePolicyOptions.cs index ba7b251aa0fc..fa334e5c4a21 100644 --- a/src/Umbraco.Core/Cache/RepositoryCachePolicyOptions.cs +++ b/src/Umbraco.Core/Cache/RepositoryCachePolicyOptions.cs @@ -11,6 +11,7 @@ public class RepositoryCachePolicyOptions public RepositoryCachePolicyOptions(Func<int> performCount) { PerformCount = performCount; + CacheNullValues = false; GetAllCacheValidateCount = true; GetAllCacheAllowZeroCount = false; } @@ -21,6 +22,7 @@ public RepositoryCachePolicyOptions(Func<int> performCount) public RepositoryCachePolicyOptions() { PerformCount = null; + CacheNullValues = false; GetAllCacheValidateCount = false; GetAllCacheAllowZeroCount = false; } @@ -30,6 +32,11 @@ public RepositoryCachePolicyOptions() /// </summary> public Func<int>? PerformCount { get; set; } + /// <summary> + /// True if the Get method will cache null results so that the db is not hit for repeated lookups + /// </summary> + public bool CacheNullValues { get; set; } + /// <summary> /// True/false as to validate the total item count when all items are returned from cache, the default is true but this /// means that a db lookup will occur - though that lookup will probably be significantly less expensive than the diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs index e3b7ddef9bb0..5e02336ef5e8 100644 --- a/src/Umbraco.Core/Composing/TypeFinder.cs +++ b/src/Umbraco.Core/Composing/TypeFinder.cs @@ -34,7 +34,7 @@ public class TypeFinder : ITypeFinder "ServiceStack.", "SqlCE4Umbraco,", "Superpower,", // used by Serilog "System.", "TidyNet,", "TidyNet.", "WebDriver,", "itextsharp,", "mscorlib,", "NUnit,", "NUnit.", "NUnit3.", "Selenium.", "ImageProcessor", "MiniProfiler.", "Owin,", "SQLite", - "ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension + "ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", "ReSharperTestRunnerArm32", "ReSharperTestRunnerArm64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension }; private static readonly ConcurrentDictionary<string, Type?> TypeNamesCache = new(); diff --git a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs index be86cf1f2b7c..127b7d9330df 100644 --- a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs @@ -16,6 +16,7 @@ public class ModelsBuilderSettings internal const string StaticModelsDirectory = "~/umbraco/models"; internal const bool StaticAcceptUnsafeModelsDirectory = false; internal const int StaticDebugLevel = 0; + internal const bool StaticIncludeVersionNumberInGeneratedModels = true; private bool _flagOutOfDateModels = true; /// <summary> @@ -78,4 +79,16 @@ public bool FlagOutOfDateModels /// <remarks>0 means minimal (safe on live site), anything else means more and more details (maybe not safe).</remarks> [DefaultValue(StaticDebugLevel)] public int DebugLevel { get; set; } = StaticDebugLevel; + + /// <summary> + /// Gets or sets a value indicating whether the version number should be included in generated models. + /// </summary> + /// <remarks> + /// By default this is written to the <see cref="System.CodeDom.Compiler.GeneratedCodeAttribute"/> output in + /// generated code for each property of the model. This can be useful for debugging purposes but isn't essential, + /// and it has the causes the generated code to change every time Umbraco is upgraded. In turn, this leads + /// to unnecessary code file changes that need to be checked into source control. Default is <c>true</c>. + /// </remarks> + [DefaultValue(StaticIncludeVersionNumberInGeneratedModels)] + public bool IncludeVersionNumberInGeneratedModels { get; set; } = StaticIncludeVersionNumberInGeneratedModels; } diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index f1005e5d1b93..e68162e6efe0 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -19,6 +19,8 @@ public class SecuritySettings internal const bool StaticAllowEditInvariantFromNonDefault = false; internal const bool StaticAllowConcurrentLogins = false; internal const string StaticAuthCookieName = "UMB_UCONTEXT"; + internal const bool StaticUsernameIsEmail = true; + internal const bool StaticMemberRequireUniqueEmail = true; internal const string StaticAllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+\\"; @@ -58,7 +60,14 @@ public class SecuritySettings /// <summary> /// Gets or sets a value indicating whether the user's email address is to be considered as their username. /// </summary> - public bool UsernameIsEmail { get; set; } = true; + [DefaultValue(StaticUsernameIsEmail)] + public bool UsernameIsEmail { get; set; } = StaticUsernameIsEmail; + + /// <summary> + /// Gets or sets a value indicating whether the member's email address must be unique. + /// </summary> + [DefaultValue(StaticMemberRequireUniqueEmail)] + public bool MemberRequireUniqueEmail { get; set; } = StaticMemberRequireUniqueEmail; /// <summary> /// Gets or sets the set of allowed characters for a username diff --git a/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml b/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml index 8f5477bca476..aa70da23c8e6 100644 --- a/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml +++ b/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml @@ -5,7 +5,7 @@ @using Umbraco.Extensions @{ - var isLoggedIn = Context.User?.Identity?.IsAuthenticated ?? false; + var isLoggedIn = Context.User.GetMemberIdentity()?.IsAuthenticated ?? false; var logoutModel = new PostRedirectModel(); // You can modify this to redirect to a different URL instead of the current one logoutModel.RedirectUrl = null; @@ -15,7 +15,7 @@ { <div class="login-status"> - <p>Welcome back <strong>@Context?.User?.Identity?.Name</strong>!</p> + <p>Welcome back <strong>@Context.User?.GetMemberIdentity()?.Name</strong>!</p> @using (Html.BeginUmbracoForm<UmbLoginStatusController>("HandleLogout", new { RedirectUrl = logoutModel.RedirectUrl })) { diff --git a/src/Umbraco.Core/PublishedCache/ITagQuery.cs b/src/Umbraco.Core/PublishedCache/ITagQuery.cs index e0c6a135c90e..8df363dbe447 100644 --- a/src/Umbraco.Core/PublishedCache/ITagQuery.cs +++ b/src/Umbraco.Core/PublishedCache/ITagQuery.cs @@ -33,7 +33,7 @@ public interface ITagQuery /// <summary> /// Gets all document tags. /// </summary> - /// /// <remarks> + /// <remarks> /// If no culture is specified, it retrieves tags with an invariant culture. /// If a culture is specified, it only retrieves tags for that culture. /// Use "*" to retrieve tags for all cultures. diff --git a/src/Umbraco.Core/Services/IMemberService.cs b/src/Umbraco.Core/Services/IMemberService.cs index a1be0b4a4cba..7d78a979c836 100644 --- a/src/Umbraco.Core/Services/IMemberService.cs +++ b/src/Umbraco.Core/Services/IMemberService.cs @@ -210,6 +210,21 @@ IMember CreateMemberWithIdentity(string username, string email, string name, str /// </returns> IMember? GetById(int id); + /// <summary> + /// Get an list of <see cref="IMember"/> for all members with the specified email. + /// </summary> + //// <param name="email">Email to use for retrieval</param> + /// <returns> + /// <see cref="IEnumerable{IMember}" /> + /// </returns> + IEnumerable<IMember> GetMembersByEmail(string email) + => + // TODO (V16): Remove this default implementation. + // The following is very inefficient, but will return the correct data, so probably better than throwing a NotImplementedException + // in the default implentation here, for, presumably rare, cases where a custom IMemberService implementation has been registered and + // does not override this method. + GetAllMembers().Where(x => x.Email.Equals(email)); + /// <summary> /// Gets all Members for the specified MemberType alias /// </summary> diff --git a/src/Umbraco.Core/Services/MemberService.cs b/src/Umbraco.Core/Services/MemberService.cs index 43b5b8f28ba0..493ab313a73a 100644 --- a/src/Umbraco.Core/Services/MemberService.cs +++ b/src/Umbraco.Core/Services/MemberService.cs @@ -389,16 +389,23 @@ public IEnumerable<IMember> GetAll( } /// <summary> - /// Get an <see cref="IMember"/> by email + /// Get an <see cref="IMember"/> by email. If RequireUniqueEmailForMembers is set to false, then the first member found with the specified email will be returned. /// </summary> /// <param name="email">Email to use for retrieval</param> /// <returns><see cref="IMember"/></returns> - public IMember? GetByEmail(string email) + public IMember? GetByEmail(string email) => GetMembersByEmail(email).FirstOrDefault(); + + /// <summary> + /// Get an list of <see cref="IMember"/> for all members with the specified email. + /// </summary> + /// <param name="email">Email to use for retrieval</param> + /// <returns><see cref="IEnumerable{IMember}"/></returns> + public IEnumerable<IMember> GetMembersByEmail(string email) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); scope.ReadLock(Constants.Locks.MemberTree); IQuery<IMember> query = Query<IMember>().Where(x => x.Email.Equals(email)); - return _memberRepository.Get(query)?.FirstOrDefault(); + return _memberRepository.Get(query); } /// <summary> diff --git a/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs b/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs index 7f7f8d678422..9494ed2eea58 100644 --- a/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs +++ b/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs @@ -24,6 +24,8 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB private static readonly TEntity[] _emptyEntities = new TEntity[0]; // const private readonly RepositoryCachePolicyOptions _options; + private const string NullRepresentationInCache = "*NULL*"; + public DefaultRepositoryCachePolicy(IAppPolicyCache cache, IScopeAccessor scopeAccessor, RepositoryCachePolicyOptions options) : base(cache, scopeAccessor) => _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -116,6 +118,7 @@ public override void Delete(TEntity entity, Action<TEntity> persistDeleted) { // whatever happens, clear the cache var cacheKey = GetEntityCacheKey(entity.Id); + Cache.Clear(cacheKey); // if there's a GetAllCacheAllowZeroCount cache, ensure it is cleared @@ -127,20 +130,36 @@ public override void Delete(TEntity entity, Action<TEntity> persistDeleted) public override TEntity? Get(TId? id, Func<TId?, TEntity?> performGet, Func<TId[]?, IEnumerable<TEntity>?> performGetAll) { var cacheKey = GetEntityCacheKey(id); + TEntity? fromCache = Cache.GetCacheItem<TEntity>(cacheKey); - // if found in cache then return else fetch and cache - if (fromCache != null) + // If found in cache then return immediately. + if (fromCache is not null) { return fromCache; } + // Because TEntity can never be a string, we will never be in a position where the proxy value collides withs a real value. + // Therefore this point can only be reached if there is a proxy null value => becomes null when cast to TEntity above OR the item simply does not exist. + // If we've cached a "null" value, return null. + if (_options.CacheNullValues && Cache.GetCacheItem<string>(cacheKey) == NullRepresentationInCache) + { + return null; + } + + // Otherwise go to the database to retrieve. TEntity? entity = performGet(id); if (entity != null && entity.HasIdentity) { + // If we've found an identified entity, cache it for subsequent retrieval. InsertEntity(cacheKey, entity); } + else if (entity is null && _options.CacheNullValues) + { + // If we've not found an entity, and we're caching null values, cache a "null" value. + InsertNull(cacheKey); + } return entity; } @@ -248,6 +267,15 @@ protected string GetEntityCacheKey(TId? id) protected virtual void InsertEntity(string cacheKey, TEntity entity) => Cache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true); + protected virtual void InsertNull(string cacheKey) + { + // We can't actually cache a null value, as in doing so wouldn't be able to distinguish between + // a value that does exist but isn't yet cached, or a value that has been explicitly cached with a null value. + // Both would return null when we retrieve from the cache and we couldn't distinguish between the two. + // So we cache a special value that represents null, and then we can check for that value when we retrieve from the cache. + Cache.Insert(cacheKey, () => NullRepresentationInCache, TimeSpan.FromMinutes(5), true); + } + protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities) { if (ids?.Length == 0 && entities?.Length == 0 && _options.GetAllCacheAllowZeroCount) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs index 6ee48ce0e7a3..f45b5d371b66 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs @@ -1,4 +1,4 @@ -using NPoco; +using NPoco; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using ColumnInfo = Umbraco.Cms.Infrastructure.Persistence.SqlSyntax.ColumnInfo; @@ -153,16 +153,26 @@ JOIN sys.columns columns "); var currentConstraintName = Database.ExecuteScalar<string>(constraintNameQuery); - - // only rename the constraint if necessary + // Only rename the constraint if necessary. if (currentConstraintName == expectedConstraintName) { return; } - Sql<ISqlContext> renameConstraintQuery = Database.SqlContext.Sql( - $"EXEC sp_rename N'{currentConstraintName}', N'{expectedConstraintName}', N'OBJECT'"); - Database.Execute(renameConstraintQuery); + if (currentConstraintName is null) + { + // Constraint does not exist, so we need to create it. + Sql<ISqlContext> createConstraintStatement = Database.SqlContext.Sql(@$" +ALTER TABLE umbracoContentVersion ADD CONSTRAINT [DF_umbracoContentVersion_versionDate] DEFAULT (getdate()) FOR [versionDate]"); + Database.Execute(createConstraintStatement); + } + else + { + // Constraint exists, and differs from the expected name, so we need to rename it. + Sql<ISqlContext> renameConstraintQuery = Database.SqlContext.Sql( + $"EXEC sp_rename N'{currentConstraintName}', N'{expectedConstraintName}', N'OBJECT'"); + Database.Execute(renameConstraintQuery); + } } private void UpdateExternalLoginIndexes(IEnumerable<Tuple<string, string, string, bool>> indexes) diff --git a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs index 22160b0ef49f..7484741b5812 100644 --- a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs +++ b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs @@ -143,14 +143,17 @@ public void WriteClrType(StringBuilder sb, Type type) // // note that the blog post above clearly states that "Nor should it be applied at the type level if the type being generated is a partial class." // and since our models are partial classes, we have to apply the attribute against the individual members, not the class itself. - private static void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) => sb.AppendFormat( + private void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) => sb.AppendFormat( "{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"Umbraco.ModelsBuilder.Embedded\", \"{1}\")]\n", - tabs, ApiVersion.Current.Version); + tabs, + Config.IncludeVersionNumberInGeneratedModels ? ApiVersion.Current.Version : null); // writes an attribute that specifies that an output may be null. // (useful for consuming projects with nullable reference types enabled) private static void WriteMaybeNullAttribute(StringBuilder sb, string tabs, bool isReturn = false) => - sb.AppendFormat("{0}[{1}global::System.Diagnostics.CodeAnalysis.MaybeNull]\n", tabs, + sb.AppendFormat( + "{0}[{1}global::System.Diagnostics.CodeAnalysis.MaybeNull]\n", + tabs, isReturn ? "return: " : string.Empty); private static string MixinStaticGetterName(string clrName) => string.Format("Get{0}", clrName); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs index 909c9cfec23e..bf4799e938a0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs @@ -102,11 +102,10 @@ protected override IRepositoryCachePolicy<IDictionaryItem, int> CreateCachePolic var options = new RepositoryCachePolicyOptions { // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, int>(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, int>(GlobalIsolatedCache, ScopeAccessor, options); } protected IDictionaryItem ConvertFromDto(DictionaryDto dto) @@ -190,11 +189,10 @@ protected override IRepositoryCachePolicy<IDictionaryItem, Guid> CreateCachePoli var options = new RepositoryCachePolicyOptions { // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, Guid>(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, Guid>(GlobalIsolatedCache, ScopeAccessor, options); } } @@ -228,12 +226,13 @@ protected override IRepositoryCachePolicy<IDictionaryItem, string> CreateCachePo { var options = new RepositoryCachePolicyOptions { + // allow null to be cached + CacheNullValues = true, // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, string>(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, string>(GlobalIsolatedCache, ScopeAccessor, options); } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs index ecc6600d4c97..a722bea2a03a 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs @@ -387,7 +387,9 @@ private static IEnumerable<TaggedEntity> Map(IEnumerable<TaggedEntityDto> dtos) }).ToList(); /// <inheritdoc /> - public IEnumerable<ITag> GetTagsForEntityType(TaggableObjectTypes objectType, string? group = null, + public IEnumerable<ITag> GetTagsForEntityType( + TaggableObjectTypes objectType, + string? group = null, string? culture = null) { Sql<ISqlContext> sql = GetTagsSql(culture, true); @@ -401,6 +403,9 @@ public IEnumerable<ITag> GetTagsForEntityType(TaggableObjectTypes objectType, st .Where<NodeDto>(dto => dto.NodeObjectType == nodeObjectType); } + sql = sql + .Where<NodeDto>(dto => !dto.Trashed); + if (group.IsNullOrWhiteSpace() == false) { sql = sql diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs index 8dbe6ad5b351..64e349c245f1 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs @@ -8,12 +8,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; @@ -38,6 +40,9 @@ public sealed class RichTextEditorPastedImages private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly string _tempFolderAbsolutePath; private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IEntityService _entityService; + private readonly IUserService _userService; + private readonly AppCaches _appCaches; private readonly ContentSettings _contentSettings; private readonly Dictionary<string, GuidUdi> _uploadedImages = new(); @@ -67,6 +72,7 @@ public RichTextEditorPastedImages( { } + [Obsolete("Use the non-obsolete constructor. Scheduled for removal in v14")] public RichTextEditorPastedImages( IUmbracoContextAccessor umbracoContextAccessor, ILogger<RichTextEditorPastedImages> logger, @@ -79,6 +85,39 @@ public RichTextEditorPastedImages( IPublishedUrlProvider publishedUrlProvider, IImageUrlGenerator imageUrlGenerator, IOptions<ContentSettings> contentSettings) + : this( + umbracoContextAccessor, + logger, + hostingEnvironment, + mediaService, + contentTypeBaseServiceProvider, + mediaFileManager, + mediaUrlGenerators, + shortStringHelper, + publishedUrlProvider, + imageUrlGenerator, + StaticServiceProvider.Instance.GetRequiredService<IEntityService>(), + StaticServiceProvider.Instance.GetRequiredService<IUserService>(), + StaticServiceProvider.Instance.GetRequiredService<AppCaches>(), + contentSettings) + { + } + + public RichTextEditorPastedImages( + IUmbracoContextAccessor umbracoContextAccessor, + ILogger<RichTextEditorPastedImages> logger, + IHostingEnvironment hostingEnvironment, + IMediaService mediaService, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + MediaFileManager mediaFileManager, + MediaUrlGeneratorCollection mediaUrlGenerators, + IShortStringHelper shortStringHelper, + IPublishedUrlProvider publishedUrlProvider, + IImageUrlGenerator imageUrlGenerator, + IEntityService entityService, + IUserService userService, + AppCaches appCaches, + IOptions<ContentSettings> contentSettings) { _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); @@ -92,6 +131,9 @@ public RichTextEditorPastedImages( _shortStringHelper = shortStringHelper; _publishedUrlProvider = publishedUrlProvider; _imageUrlGenerator = imageUrlGenerator; + _entityService = entityService; + _userService = userService; + _appCaches = appCaches; _contentSettings = contentSettings.Value; _tempFolderAbsolutePath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads); @@ -270,7 +312,7 @@ private void PersistMediaItem(Guid mediaParentFolder, int userId, HtmlNode img, : Constants.Conventions.MediaTypes.Image; IMedia mediaFile = mediaParentFolder == Guid.Empty - ? _mediaService.CreateMedia(mediaItemName, Constants.System.Root, mediaType, userId) + ? _mediaService.CreateMedia(mediaItemName, GetDefaultMediaRoot(userId), mediaType, userId) : _mediaService.CreateMedia(mediaItemName, mediaParentFolder, mediaType, userId); var fileInfo = new FileInfo(absoluteTempImagePath); @@ -354,4 +396,11 @@ private void PersistMediaItem(Guid mediaParentFolder, int userId, HtmlNode img, } private bool IsValidPath(string imagePath) => imagePath.StartsWith(_tempFolderAbsolutePath); + + private int GetDefaultMediaRoot(int userId) + { + IUser user = _userService.GetUserById(userId) ?? throw new ArgumentException("User could not be found"); + var userStartNodes = user.CalculateMediaStartNodeIds(_entityService, _appCaches); + return userStartNodes?.FirstOrDefault() ?? Constants.System.Root; + } } diff --git a/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs b/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs index 6dcd3ef9b0b3..27662f979a1b 100644 --- a/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs +++ b/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs @@ -15,6 +15,7 @@ internal class FileSystemMainDomLock : IMainDomLock private readonly string _lockFilePath; private readonly ILogger<FileSystemMainDomLock> _logger; private readonly string _releaseSignalFilePath; + private bool _disposed; private Task? _listenForReleaseSignalFileTask; private FileStream? _lockFileStream; @@ -88,16 +89,14 @@ public Task ListenAsync() ListeningLoop, _cancellationTokenSource.Token, TaskCreationOptions.LongRunning, - TaskScheduler.Default); + TaskScheduler.Default) + .Unwrap(); // Because ListeningLoop is an async method, we need to use Unwrap to return the inner task. return _listenForReleaseSignalFileTask; } - public void Dispose() - { - _lockFileStream?.Close(); - _lockFileStream = null; - } + /// <summary>Releases the resources used by this <see cref="FileSystemMainDomLock" />.</summary> + public void Dispose() => Dispose(true); public void CreateLockReleaseSignalFile() => File.Open(_releaseSignalFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, @@ -107,7 +106,27 @@ public void CreateLockReleaseSignalFile() => public void DeleteLockReleaseSignalFile() => File.Delete(_releaseSignalFilePath); - private void ListeningLoop() + /// <summary>Releases the resources used by this <see cref="FileSystemMainDomLock" />.</summary> + /// <param name="disposing">true to release both managed resources.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _logger.LogInformation($"{nameof(FileSystemMainDomLock)} Disposing..."); + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + ReleaseLock(); + _disposed = true; + } + } + + private void ReleaseLock() + { + _lockFileStream?.Close(); + _lockFileStream = null; + } + + private async Task ListeningLoop() { while (true) { @@ -126,12 +145,12 @@ private void ListeningLoop() { _logger.LogDebug("Found lock release signal file, releasing lock on {lockFilePath}", _lockFilePath); } - _lockFileStream?.Close(); - _lockFileStream = null; + + ReleaseLock(); break; } - Thread.Sleep(_globalSettings.CurrentValue.MainDomReleaseSignalPollingInterval); + await Task.Delay(_globalSettings.CurrentValue.MainDomReleaseSignalPollingInterval, _cancellationTokenSource.Token); } } } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 0d2767dd25c8..21f8978e7188 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -281,7 +281,20 @@ public override Task<IdentityResult> DeleteAsync( cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - IUser? user = _userService.GetUserById(UserIdToInt(userId)); + // In the external login flow - see BackOfficeController.ExternalSignInAsync - we can have a situation where an + // error has occured but the user is signed in. For that reason, at the end of the process, if errors are + // recorded, the user is signed out. + // Before signing out, we request the user in order to update the security stamp - see UmbracoSignInManager.SignOutAsync. + // But we can have a situation where the signed in principal has the ID from the external provider, which may not be something + // we can parse to an integer. + // If that's the case, return null rather than throwing an exception. Without an Umbraco user, we can't update the security stamp, + // so no need to fail here. + if (!TryUserIdToInt(userId, out var userIdAsInt)) + { + return Task.FromResult((BackOfficeIdentityUser?)null)!; + } + + IUser? user = _userService.GetUserById(userIdAsInt); if (user == null) { return Task.FromResult((BackOfficeIdentityUser?)null)!; diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs index 35a8f2eea9f0..544a8dbd745a 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs @@ -33,18 +33,29 @@ protected UmbracoUserStore(IdentityErrorDescriber describer) protected static int UserIdToInt(string? userId) { - if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + if (TryUserIdToInt(userId, out int result)) { return result; } + throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); + } + + protected static bool TryUserIdToInt(string? userId, out int result) + { + if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)) + { + return true; + } + if (Guid.TryParse(userId, out Guid key)) { // Reverse the IntExtensions.ToGuid - return BitConverter.ToInt32(key.ToByteArray(), 0); + result = BitConverter.ToInt32(key.ToByteArray(), 0); + return true; } - throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); + return false; } protected static string UserIdToString(int userId) => string.Intern(userId.ToString(CultureInfo.InvariantCulture)); diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs index abd1b2b78738..e6a131950e38 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs @@ -789,8 +789,7 @@ public async Task<IActionResult> PostAddFile([FromForm] string path, [FromForm] continue; } - using var stream = new MemoryStream(); - await formFile.CopyToAsync(stream); + await using var stream = formFile.OpenReadStream(); if (_fileStreamSecurityValidator != null && _fileStreamSecurityValidator.IsConsideredSafe(stream) == false) { tempFiles.Notifications.Add(new BackOfficeNotification( diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs index 8851a73a2b07..d03fa87a4ac6 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs @@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.ContentApps; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Dictionary; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; @@ -26,7 +29,6 @@ using Umbraco.Cms.Web.BackOffice.ModelBinders; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; @@ -55,6 +57,7 @@ public class MemberController : ContentControllerBase private readonly ITwoFactorLoginService _twoFactorLoginService; private readonly IShortStringHelper _shortStringHelper; private readonly IUmbracoMapper _umbracoMapper; + private readonly SecuritySettings _securitySettings; /// <summary> /// Initializes a new instance of the <see cref="MemberController" /> class. @@ -75,6 +78,7 @@ public class MemberController : ContentControllerBase /// <param name="passwordChanger">The password changer</param> /// <param name="scopeProvider">The core scope provider</param> /// <param name="twoFactorLoginService">The two factor login service</param> + /// <param name="securitySettings">The security settings</param> [ActivatorUtilitiesConstructor] public MemberController( ICultureDictionary cultureDictionary, @@ -92,7 +96,8 @@ public MemberController( IJsonSerializer jsonSerializer, IPasswordChanger<MemberIdentityUser> passwordChanger, ICoreScopeProvider scopeProvider, - ITwoFactorLoginService twoFactorLoginService) + ITwoFactorLoginService twoFactorLoginService, + IOptions<SecuritySettings> securitySettings) : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer) { _propertyEditors = propertyEditors; @@ -108,9 +113,49 @@ public MemberController( _passwordChanger = passwordChanger; _scopeProvider = scopeProvider; _twoFactorLoginService = twoFactorLoginService; + _securitySettings = securitySettings.Value; } - [Obsolete("Use constructor that also takes an ITwoFactorLoginService. Scheduled for removal in V13")] + [Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")] + public MemberController( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + PropertyEditorCollection propertyEditors, + IUmbracoMapper umbracoMapper, + IMemberService memberService, + IMemberTypeService memberTypeService, + IMemberManager memberManager, + IDataTypeService dataTypeService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IJsonSerializer jsonSerializer, + IPasswordChanger<MemberIdentityUser> passwordChanger, + ICoreScopeProvider scopeProvider, + ITwoFactorLoginService twoFactorLoginService) + : this( + cultureDictionary, + loggerFactory, + shortStringHelper, + eventMessages, + localizedTextService, + propertyEditors, + umbracoMapper, + memberService, + memberTypeService, + memberManager, + dataTypeService, + backOfficeSecurityAccessor, + jsonSerializer, + passwordChanger, + scopeProvider, + twoFactorLoginService, + StaticServiceProvider.Instance.GetRequiredService<IOptions<SecuritySettings>>()) + { + } + + [Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")] public MemberController( ICultureDictionary cultureDictionary, ILoggerFactory loggerFactory, @@ -461,7 +506,7 @@ private async Task<ActionResult<bool>> CreateMemberAsync(MemberSave contentItem) } // now re-look up the member, which will now exist - IMember? member = _memberService.GetByEmail(contentItem.Email); + IMember? member = _memberService.GetByUsername(contentItem.Username); if (member is null) { @@ -678,6 +723,17 @@ private async Task<bool> ValidateMemberDataAsync(MemberSave contentItem) return false; } + // User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create + // as the setting is applied to the IdentityOptions, but we need to check ourselves for updates. + var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; + if (contentItem.Username.Any(c => allowedUserNameCharacters.Contains(c) == false)) + { + ModelState.AddPropertyError( + new ValidationResult("Username contains invalid characters"), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); + return false; + } + if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace()) { IdentityResult validPassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); @@ -699,13 +755,16 @@ private async Task<bool> ValidateMemberDataAsync(MemberSave contentItem) return false; } - IMember? byEmail = _memberService.GetByEmail(contentItem.Email); - if (byEmail != null && byEmail.Key != contentItem.Key) + if (_securitySettings.MemberRequireUniqueEmail) { - ModelState.AddPropertyError( - new ValidationResult("Email address is already in use", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); - return false; + IMember? byEmail = _memberService.GetByEmail(contentItem.Email); + if (byEmail != null && byEmail.Key != contentItem.Key) + { + ModelState.AddPropertyError( + new ValidationResult("Email address is already in use", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); + return false; + } } return true; diff --git a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs index f1531ecb8d8f..676e18a58905 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs @@ -1,10 +1,13 @@ using System.Globalization; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Features; using Umbraco.Cms.Core.Hosting; @@ -28,7 +31,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; [DisableBrowserCache] [Area(Constants.Web.Mvc.BackOfficeArea)] -public class PreviewController : Controller +public partial class PreviewController : Controller { private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; private readonly ICookieManager _cookieManager; @@ -40,7 +43,9 @@ public class PreviewController : Controller private readonly IRuntimeMinifier _runtimeMinifier; private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly ICompositeViewEngine _viewEngines; + private readonly WebRoutingSettings _webRoutingSettings; + [Obsolete("Please use the non-obsolete constructor.")] public PreviewController( UmbracoFeatures features, IOptionsSnapshot<GlobalSettings> globalSettings, @@ -52,9 +57,38 @@ public PreviewController( IRuntimeMinifier runtimeMinifier, ICompositeViewEngine viewEngines, IUmbracoContextAccessor umbracoContextAccessor) + : this( + features, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService<IOptionsSnapshot<WebRoutingSettings>>(), + publishedSnapshotService, + backofficeSecurityAccessor, + localizationService, + hostingEnvironment, + cookieManager, + runtimeMinifier, + viewEngines, + umbracoContextAccessor) + { + } + + [ActivatorUtilitiesConstructor] + public PreviewController( + UmbracoFeatures features, + IOptionsSnapshot<GlobalSettings> globalSettings, + IOptionsSnapshot<WebRoutingSettings> webRoutingSettings, + IPublishedSnapshotService publishedSnapshotService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILocalizationService localizationService, + IHostingEnvironment hostingEnvironment, + ICookieManager cookieManager, + IRuntimeMinifier runtimeMinifier, + ICompositeViewEngine viewEngines, + IUmbracoContextAccessor umbracoContextAccessor) { _features = features; _globalSettings = globalSettings.Value; + _webRoutingSettings = webRoutingSettings.Value; _publishedSnapshotService = publishedSnapshotService; _backofficeSecurityAccessor = backofficeSecurityAccessor; _localizationService = localizationService; @@ -181,6 +215,43 @@ public ActionResult End(string? redir = null) // Expire Client-side cookie that determines whether the user has accepted to be in Preview Mode when visiting the website. _cookieManager.ExpireCookie(Constants.Web.AcceptPreviewCookieName); + // are we attempting a redirect to the default route (by ID with optional culture)? + Match match = DefaultPreviewRedirectRegex().Match(redir ?? string.Empty); + if (match.Success) + { + var id = int.Parse(match.Groups["id"].Value); + + // first try to resolve the published URL + if (_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext) && + umbracoContext.Content is not null) + { + IPublishedContent? publishedContent = umbracoContext.Content.GetById(id); + if (publishedContent is null) + { + // content is not published, redirect to root + return Redirect("/"); + } + + var culture = publishedContent.ContentType.VariesByCulture() + && match.Groups.TryGetValue("culture", out Group? group) + ? group.Value + : null; + + var publishedUrl = publishedContent.Url(culture); + if (WebPath.IsWellFormedWebPath(publishedUrl, UriKind.RelativeOrAbsolute)) + { + return Redirect(publishedUrl); + } + } + + // could not resolve the published URL - are we allowed to route content by ID? + if (_webRoutingSettings.DisableFindContentByIdPath) + { + // no we are not - redirect to root instead + return Redirect("/"); + } + } + if (WebPath.IsWellFormedWebPath(redir, UriKind.Relative) && Uri.TryCreate(redir, UriKind.Relative, out Uri? url)) { @@ -189,4 +260,7 @@ public ActionResult End(string? redir = null) return Redirect("/"); } + + [GeneratedRegex("^\\/(?<id>\\d*)(\\?culture=(?<culture>[\\w-]*))?$")] + private static partial Regex DefaultPreviewRedirectRegex(); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index c855a87ea4b2..960afa365e9a 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -322,7 +322,7 @@ public ActionResult<string[]> PostClearAvatar(int id) /// <returns></returns> [OutgoingEditorModelEvent] [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public ActionResult<IEnumerable<UserDisplay?>> GetByIds([FromJsonPath] int[] ids) + public ActionResult<IEnumerable<UserDisplay?>> GetByIds([FromQuery] int[] ids) { if (ids == null) { @@ -714,6 +714,15 @@ private async Task SendUserInviteEmailAsync(UserBasic? userDisplay, string? from var hasErrors = false; + // User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create + // as the setting is applied to the BackOfficeIdentityOptions, but we need to check ourselves for updates. + var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; + if (userSave.Username.Any(c => allowedUserNameCharacters.Contains(c) == false)) + { + ModelState.AddModelError("Username", "Username contains invalid characters"); + hasErrors = true; + } + // we need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed var hasDenyLocalLogin = _externalLogins.HasDenyLocalLogin(); if (hasDenyLocalLogin) diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs index 0945d3459b0a..fd3bfe71bc31 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs @@ -202,6 +202,7 @@ private static void CreatePolicies(AuthorizationOptions options, string backOffi { policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); policy.Requirements.Add(new AdminUsersRequirement()); + policy.Requirements.Add(new AdminUsersRequirement("ids")); policy.Requirements.Add(new AdminUsersRequirement("userIds")); }); diff --git a/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs b/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs index 6b29803e0521..68d37bba3c32 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs @@ -2,8 +2,12 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; @@ -16,27 +20,29 @@ namespace Umbraco.Cms.Web.BackOffice.Filters; /// <summary> /// Validator for <see cref="ContentItemSave" /> /// </summary> -internal class - MemberSaveModelValidator : ContentModelValidator<IMember, MemberSave, IContentProperties<ContentPropertyBasic>> +internal class MemberSaveModelValidator : ContentModelValidator<IMember, MemberSave, IContentProperties<ContentPropertyBasic>> { private readonly IBackOfficeSecurity? _backofficeSecurity; private readonly IMemberService _memberService; private readonly IMemberTypeService _memberTypeService; private readonly IShortStringHelper _shortStringHelper; + private readonly SecuritySettings _securitySettings; public MemberSaveModelValidator( - ILogger<MemberSaveModelValidator> logger, - IBackOfficeSecurity? backofficeSecurity, - IMemberTypeService memberTypeService, - IMemberService memberService, - IShortStringHelper shortStringHelper, - IPropertyValidationService propertyValidationService) - : base(logger, propertyValidationService) + ILogger<MemberSaveModelValidator> logger, + IBackOfficeSecurity? backofficeSecurity, + IMemberTypeService memberTypeService, + IMemberService memberService, + IShortStringHelper shortStringHelper, + IPropertyValidationService propertyValidationService, + SecuritySettings securitySettings) + : base(logger, propertyValidationService) { _backofficeSecurity = backofficeSecurity; _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _securitySettings = securitySettings; } public override bool ValidatePropertiesData( @@ -64,8 +70,7 @@ public override bool ValidatePropertiesData( $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); } - var validEmail = ValidateUniqueEmail(model); - if (validEmail == false) + if (_securitySettings.MemberRequireUniqueEmail && ValidateUniqueEmail(model) is false) { modelState.AddPropertyError( new ValidationResult("Email address is already in use", new[] { "value" }), diff --git a/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs index 61e119b66a0e..568d1e82402f 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -25,6 +27,7 @@ private sealed class MemberSaveValidationFilter : IActionFilter private readonly IMemberTypeService _memberTypeService; private readonly IPropertyValidationService _propertyValidationService; private readonly IShortStringHelper _shortStringHelper; + private readonly SecuritySettings _securitySettings; public MemberSaveValidationFilter( ILoggerFactory loggerFactory, @@ -32,16 +35,16 @@ public MemberSaveValidationFilter( IMemberTypeService memberTypeService, IMemberService memberService, IShortStringHelper shortStringHelper, - IPropertyValidationService propertyValidationService) + IPropertyValidationService propertyValidationService, + IOptions<SecuritySettings> securitySettings) { - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? - throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _propertyValidationService = propertyValidationService ?? - throw new ArgumentNullException(nameof(propertyValidationService)); + _loggerFactory = loggerFactory; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _memberTypeService = memberTypeService; + _memberService = memberService; + _shortStringHelper = shortStringHelper; + _propertyValidationService = propertyValidationService; + _securitySettings = securitySettings.Value; } public void OnActionExecuting(ActionExecutingContext context) @@ -53,7 +56,8 @@ public void OnActionExecuting(ActionExecutingContext context) _memberTypeService, _memberService, _shortStringHelper, - _propertyValidationService); + _propertyValidationService, + _securitySettings); //now do each validation step if (contentItemValidator.ValidateExistingContent(model, context)) { diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs index 4cdd8cef7c3c..0ef895e2072a 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs @@ -317,13 +317,7 @@ protected MenuItemCollection GetAllNodeMenuItems(IUmbracoEntity item) if (_emailSender.CanSendRequiredEmail()) { - menu.Items.Add(new MenuItem("notify", LocalizedTextService) - { - Icon = "icon-megaphone", - SeparatorBefore = true, - OpensDialog = true, - UseLegacyIcon = false - }); + AddActionNode<ActionNotify>(item, menu, hasSeparator: true, opensDialog: true, useLegacyIcon: false); } if ((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) diff --git a/src/Umbraco.Web.Common/Extensions/HttpContextExtensions.cs b/src/Umbraco.Web.Common/Extensions/HttpContextExtensions.cs index 0a84f318f6f1..fd46ef6903af 100644 --- a/src/Umbraco.Web.Common/Extensions/HttpContextExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/HttpContextExtensions.cs @@ -62,9 +62,16 @@ public static async Task<AuthenticateResult> AuthenticateBackOfficeAsync(this Ht // Update the HttpContext's user with the authenticated user's principal to ensure // that subsequent requests within the same context will recognize the user // as authenticated. - if (result.Succeeded) + if (result is { Succeeded: true, Principal.Identity: not null }) { - httpContext.User = result.Principal; + // We need to get existing identities that are not the backoffice kind and flow them to the new identity + // Otherwise we can't log in as both a member and a backoffice user + // For instance if you've enabled basic auth. + ClaimsPrincipal? authenticatedPrincipal = result.Principal; + IEnumerable<ClaimsIdentity> existingIdentities = httpContext.User.Identities.Where(x => x.IsAuthenticated && x.AuthenticationType != authenticatedPrincipal.Identity.AuthenticationType); + authenticatedPrincipal.AddIdentities(existingIdentities); + + httpContext.User = authenticatedPrincipal; } return result; diff --git a/src/Umbraco.Web.Common/Extensions/MemberClaimsPrincipalExtensions.cs b/src/Umbraco.Web.Common/Extensions/MemberClaimsPrincipalExtensions.cs new file mode 100644 index 000000000000..03205f7baa76 --- /dev/null +++ b/src/Umbraco.Web.Common/Extensions/MemberClaimsPrincipalExtensions.cs @@ -0,0 +1,18 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; + +namespace Umbraco.Extensions; + +public static class MemberClaimsPrincipalExtensions +{ + /// <summary> + /// Tries to get specifically the member identity from the ClaimsPrincipal + /// </summary> + /// <remarks> + /// The identity returned is the one with default authentication type. + /// </remarks> + /// <param name="principal">The principal to find the identity in.</param> + /// <returns>The default authenticated authentication type identity.</returns> + public static ClaimsIdentity? GetMemberIdentity(this ClaimsPrincipal principal) + => principal.Identities.FirstOrDefault(x => x.AuthenticationType == IdentityConstants.ApplicationScheme); +} diff --git a/src/Umbraco.Web.Common/RuntimeMinification/SmidgeOptionsSetup.cs b/src/Umbraco.Web.Common/RuntimeMinification/SmidgeOptionsSetup.cs index 37701928c61f..2452e30f661f 100644 --- a/src/Umbraco.Web.Common/RuntimeMinification/SmidgeOptionsSetup.cs +++ b/src/Umbraco.Web.Common/RuntimeMinification/SmidgeOptionsSetup.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using Smidge.Cache; using Smidge.Options; using Umbraco.Cms.Core.Configuration.Models; @@ -6,17 +7,31 @@ namespace Umbraco.Cms.Web.Common.RuntimeMinification; public class SmidgeOptionsSetup : IConfigureOptions<SmidgeOptions> { - private readonly IOptions<RuntimeMinificationSettings> _runtimeMinificatinoSettings; + private readonly IOptions<RuntimeMinificationSettings> _runtimeMinificationSettings; public SmidgeOptionsSetup(IOptions<RuntimeMinificationSettings> runtimeMinificatinoSettings) - => _runtimeMinificatinoSettings = runtimeMinificatinoSettings; + => _runtimeMinificationSettings = runtimeMinificatinoSettings; /// <summary> - /// Configures Smidge to use in-memory caching if configured that way or if certain cache busters are used + /// Configures Smidge to use in-memory caching if configured that way or if certain cache busters are used. + /// Also sets the cache buster type such that public facing bundles will use the configured method. /// </summary> - /// <param name="options"></param> + /// <param name="options">Instance of <see cref="SmidgeOptions"></see> to configure.</param> public void Configure(SmidgeOptions options) - => options.CacheOptions.UseInMemoryCache = _runtimeMinificatinoSettings.Value.UseInMemoryCache || - _runtimeMinificatinoSettings.Value.CacheBuster == + { + options.CacheOptions.UseInMemoryCache = _runtimeMinificationSettings.Value.UseInMemoryCache || + _runtimeMinificationSettings.Value.CacheBuster == RuntimeMinificationCacheBuster.Timestamp; + + Type cacheBusterType = _runtimeMinificationSettings.Value.CacheBuster switch + { + RuntimeMinificationCacheBuster.AppDomain => typeof(AppDomainLifetimeCacheBuster), + RuntimeMinificationCacheBuster.Version => typeof(UmbracoSmidgeConfigCacheBuster), + RuntimeMinificationCacheBuster.Timestamp => typeof(TimestampCacheBuster), + _ => throw new ArgumentOutOfRangeException("CacheBuster", $"{_runtimeMinificationSettings.Value.CacheBuster} is not a valid value for RuntimeMinificationCacheBuster."), + }; + + options.DefaultBundleOptions.DebugOptions.SetCacheBusterType(cacheBusterType); + options.DefaultBundleOptions.ProductionOptions.SetCacheBusterType(cacheBusterType); + } } diff --git a/src/Umbraco.Web.Common/RuntimeMinification/SmidgeRuntimeMinifier.cs b/src/Umbraco.Web.Common/RuntimeMinification/SmidgeRuntimeMinifier.cs index b06f8d06880a..cd0b80d1dd14 100644 --- a/src/Umbraco.Web.Common/RuntimeMinification/SmidgeRuntimeMinifier.cs +++ b/src/Umbraco.Web.Common/RuntimeMinification/SmidgeRuntimeMinifier.cs @@ -79,7 +79,7 @@ public SmidgeRuntimeMinifier( RuntimeMinificationCacheBuster.AppDomain => typeof(AppDomainLifetimeCacheBuster), RuntimeMinificationCacheBuster.Version => typeof(UmbracoSmidgeConfigCacheBuster), RuntimeMinificationCacheBuster.Timestamp => typeof(TimestampCacheBuster), - _ => throw new NotImplementedException(), + _ => throw new ArgumentOutOfRangeException("CacheBuster", $"{runtimeMinificationSettings.Value.CacheBuster} is not a valid value for RuntimeMinificationCacheBuster."), }; _cacheBusterType = cacheBusterType; diff --git a/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs index 0fcc41d9d02c..1c9b88b6cfdc 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs @@ -24,7 +24,7 @@ public void Configure(IdentityOptions options) options.SignIn.RequireConfirmedEmail = false; // not implemented options.SignIn.RequireConfirmedPhoneNumber = false; // not implemented - options.User.RequireUniqueEmail = true; + options.User.RequireUniqueEmail = _securitySettings.MemberRequireUniqueEmail; // Support validation of member names using Down-Level Logon Name format options.User.AllowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; diff --git a/src/Umbraco.Web.Common/Security/MemberManager.cs b/src/Umbraco.Web.Common/Security/MemberManager.cs index 46d07deb88f5..40146275dec1 100644 --- a/src/Umbraco.Web.Common/Security/MemberManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberManager.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; @@ -124,8 +125,11 @@ public virtual async Task<bool> IsMemberAuthorizedAsync( /// <inheritdoc /> public virtual bool IsLoggedIn() { - HttpContext? httpContext = _httpContextAccessor.HttpContext; - return httpContext?.User.Identity?.IsAuthenticated ?? false; + // We have to try and specifically find the member identity, it's entirely possible for there to be both backoffice and member. + ClaimsIdentity? memberIdentity = _httpContextAccessor.HttpContext?.User.GetMemberIdentity(); + + return memberIdentity is not null && + memberIdentity.IsAuthenticated; } /// <inheritdoc /> @@ -181,23 +185,27 @@ public virtual Task<IReadOnlyDictionary<string, bool>> IsProtectedAsync(IEnumera /// <inheritdoc /> public virtual async Task<MemberIdentityUser?> GetCurrentMemberAsync() { - if (_currentMember == null) + if (_currentMember is not null) { - if (!IsLoggedIn()) - { - return null; - } + return _currentMember; + } - _currentMember = await GetUserAsync(_httpContextAccessor.HttpContext?.User!); + if (IsLoggedIn() is false) + { + return null; } + // Create a principal the represents the member security context. + var memberPrincipal = new ClaimsPrincipal(_httpContextAccessor.HttpContext?.User.GetMemberIdentity()!); + _currentMember = await GetUserAsync(memberPrincipal); + return _currentMember; } public virtual IPublishedContent? AsPublishedMember(MemberIdentityUser user) => _store.GetPublishedMember(user); /// <summary> - /// This will check if the member has access to this path + /// This will check if the member has access to this path. /// </summary> /// <param name="path"></param> /// <returns></returns> diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js index 8c7836a2e69e..2a526ae2af4d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js @@ -90,9 +90,7 @@ Use this directive to render a button with a dropdown of alternative actions. **/ (function () { 'use strict'; - function ButtonGroupDirective() { - function controller($scope, localizationService) { $scope.toggleStyle = null; $scope.blockElement = false; @@ -125,18 +123,24 @@ Use this directive to render a button with a dropdown of alternative actions. // As the <localize /> directive doesn't support Angular expressions as fallback, we instead listen for changes // to the label key of the default button, and if detected, we update the button label with the localized value // received from the localization service - $scope.$watch("defaultButton.labelKey", function () { - if (!$scope.defaultButton.labelKey) return; + $scope.$watch("defaultButton", localizeDefaultButtonLabel); + $scope.$watch("defaultButton.labelKey", localizeDefaultButtonLabel); + + function localizeDefaultButtonLabel() { + if (!$scope.defaultButton?.labelKey) return; localizationService.localize($scope.defaultButton.labelKey).then(value => { if (value && value.indexOf("[") === 0) return; $scope.defaultButton.label = value; }); - }); + } // In a similar way, we must listen for changes to the sub buttons (or their label keys), and if detected, update // the label with the localized value received from the localization service - $scope.$watch("defaultButton.subButtons", function () { - if (!Array.isArray($scope.subButtons)) return; + $scope.$watch("subButtons", localizeSubButtons, true); + $scope.$watch("defaultButton.subButtons", localizeSubButtons, true); + + function localizeSubButtons() { + if (!$scope.subButtons || !Array.isArray($scope.subButtons)) return; $scope.subButtons.forEach(function (sub) { if (!sub.labelKey) return; localizationService.localize(sub.labelKey).then(value => { @@ -144,7 +148,7 @@ Use this directive to render a button with a dropdown of alternative actions. sub.label = value; }); }); - }, true); + } } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js index e76da32a545b..fe16d1cf9d2f 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js @@ -218,6 +218,10 @@ return !canEditCulture || !canEditSegment; } + + $scope.isPreview = function(property) { + return ((property.readonly || !$scope.allowUpdate) && !property.supportsReadOnly) || ($scope.propertyEditorDisabled(property) && $scope.allowUpdate); + } } var directive = { diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js index 11efb4b81150..073df54a7ed9 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js @@ -25,11 +25,12 @@ propertyAlias: "@", showInherit: "<", inheritsFrom: "<", - hideLabel: "<?" + hideLabel: "<?", + preview: "<?" } }); - + function UmbPropertyController($scope, userService, serverValidationManager, udiService, angularHelper) { @@ -55,7 +56,7 @@ // returns the validation path for the property to be used as the validation key for server side validation logic vm.getValidationPath = function () { - var parentValidationPath = vm.parentUmbProperty ? vm.parentUmbProperty.getValidationPath() : null; + var parentValidationPath = vm.parentUmbProperty ? vm.parentUmbProperty.getValidationPath() : null; var propAlias = vm.propertyAlias ? vm.propertyAlias : vm.property.alias; // the elementKey will be empty when this is not a nested property var valPath = vm.elementKey ? vm.elementKey + "/" + propAlias : propAlias; diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js index d91924a2eba3..3e0ac9062014 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js @@ -79,7 +79,7 @@ function publicAccessResource($http, umbRequestHelper) { publicAccess.groups = groups; } else if (Utilities.isArray(usernames) && usernames.length) { - publicAccess.usernames = usernames; + publicAccess.usernames = usernames.map(u => encodeURIComponent(u)); } else { throw "must supply either userName/password or roles"; diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js index 0b69bec3f5e0..c514be49a03f 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js @@ -318,12 +318,14 @@ */ function getUsers(userIds) { + var idQuery = ""; + userIds.forEach(id => idQuery += `ids=${id}&`); return umbRequestHelper.resourcePromise( $http.get( umbRequestHelper.getApiUrl( "userApiBaseUrl", "GetByIds", - { ids: userIds })), + idQuery)), "Failed to retrieve data for users " + userIds); } diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js index f69467b0a1d7..ede50dc93e86 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js @@ -67,7 +67,7 @@ if(browserInfo != null){ vm.systemInfo.push({name :"Browser", data: browserInfo.name + " " + browserInfo.version}); } - vm.systemInfo.push({name :"Browser OS", data: getPlatform()}); + vm.systemInfo.push({name :"Browser (user agent)", data: getPlatform()}); } ); tourService.getGroupedTours().then(function(groupedTours) { vm.tours = groupedTours; @@ -257,7 +257,7 @@ } function getPlatform() { - return window.navigator.platform; + return navigator.userAgent; } evts.push(eventsService.on("appState.tour.complete", function (event, tour) { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js index 673e1a5d3dd0..e3799def3c72 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js @@ -189,6 +189,7 @@ angular.module("umbraco").controller("Umbraco.Editors.LinkPickerController", startNodeId: startNodeId, startNodeIsVirtual: startNodeIsVirtual, dataTypeKey: dialogOptions.dataTypeKey, + disableFolderSelect: true, submit: function (model) { var media = model.selection[0]; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js index 05be10c5d010..aed213383980 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js @@ -3,15 +3,15 @@ angular.module("umbraco") function ($scope, localizationService, entityResource, editorService, overlayService, eventsService, mediaHelper) { var unsubscribe = []; - + const vm = this; - + vm.loading = true; vm.model = $scope.model; vm.mediaEntry = vm.model.mediaEntry; vm.currentCrop = null; vm.title = ""; - + vm.focalPointChanged = focalPointChanged; vm.onImageLoaded = onImageLoaded; vm.openMedia = openMedia; @@ -20,7 +20,7 @@ angular.module("umbraco") vm.deselectCrop = deselectCrop; vm.resetCrop = resetCrop; vm.submitAndClose = submitAndClose; - vm.close = close; + vm.close = close; function init() { @@ -58,6 +58,12 @@ angular.module("umbraco") return; } + // the focal point can be null in some cases - most often right after a save. this throws the crop + // thumbnails (previews) off, so let's enforce the default focal point. + if (!vm.mediaEntry.focalPoint){ + vm.mediaEntry.focalPoint = {left: 0.5, top: 0.5}; + } + vm.loading = true; entityResource.getById(vm.mediaEntry.mediaKey, "Media").then(function (mediaEntity) { @@ -85,12 +91,12 @@ angular.module("umbraco") }); }); } - + function onImageLoaded(isCroppable, hasDimensions) { vm.isCroppable = isCroppable; vm.hasDimensions = hasDimensions; } - + function repickMedia() { vm.model.propertyEditor.changeMediaFor(vm.model.mediaEntry, onMediaReplaced); } @@ -105,7 +111,7 @@ angular.module("umbraco") updateMedia(); } - + function openMedia() { const mediaEditor = { @@ -117,7 +123,7 @@ angular.module("umbraco") editorService.close(); } }; - + editorService.mediaEditor(mediaEditor); } @@ -131,23 +137,24 @@ angular.module("umbraco") // set form to dirty to track changes setDirty(); } - + function selectCrop(targetCrop) { vm.currentCrop = targetCrop; setDirty(); // TODO: start watchin values of crop, first when changed set to dirty. } - + function deselectCrop() { vm.currentCrop = null; } - + function resetCrop() { if (vm.currentCrop) { - $scope.$evalAsync( () => { - vm.model.propertyEditor.resetCrop(vm.currentCrop); - vm.forceUpdateCrop = Math.random(); - }); + vm.model.propertyEditor.resetCrop(vm.currentCrop); + // deselecting the crop here has a dual purpose: + // 1. it replicates the behaviour of the image cropper (e.g. on media items). + // 2. it ensures that the newly reset crop does not get overwritten by a new crop with default values. + deselectCrop(); } } @@ -160,7 +167,7 @@ angular.module("umbraco") vm.model.submit(vm.model); } } - + function close() { if (vm.model && vm.model.close) { @@ -169,7 +176,7 @@ angular.module("umbraco") const labelKeys = vm.model.createFlow === true ? ["mediaPicker_confirmCancelMediaEntryCreationHeadline", "mediaPicker_confirmCancelMediaEntryCreationMessage"] : ["prompt_discardChanges", "mediaPicker_confirmCancelMediaEntryHasChanges"]; - + localizationService.localizeMany(labelKeys).then(localizations => { const confirm = { title: localizations[0], @@ -196,7 +203,7 @@ angular.module("umbraco") } init(); - + $scope.$on("$destroy", function () { unsubscribe.forEach(x => x()); }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/content/umb-tabbed-content.html b/src/Umbraco.Web.UI.Client/src/views/components/content/umb-tabbed-content.html index 2dd0b83130aa..ccb2a5e0af3a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/content/umb-tabbed-content.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/content/umb-tabbed-content.html @@ -15,12 +15,13 @@ property="property" node="contentNodeModel" show-inherit="contentNodeModel.variants.length > 1 && property.variation !== 'CultureAndSegment'" - inherits-from="defaultVariant.displayName"> + inherits-from="defaultVariant.displayName" + preview="isPreview(property)"> <umb-property-editor model="property" node="contentNodeModel" - preview="((property.readonly || !allowUpdate) && !property.supportsReadOnly) || (propertyEditorDisabled(property) && allowUpdate)" + preview="isPreview(property)" allow-unlock="!property.readonly && allowUpdate && allowEditInvariantFromNonDefault" on-unlock="unlockInvariantValue(property)" ng-attr-readonly="{{property.readonly || !allowUpdate || undefined}}"> @@ -49,12 +50,13 @@ property="property" node="contentNodeModel" show-inherit="contentNodeModel.variants.length > 1 && property.variation !== 'CultureAndSegment'" - inherits-from="defaultVariant.displayName"> + inherits-from="defaultVariant.displayName" + preview="isPreview(property)"> <umb-property-editor model="property" node="contentNodeModel" - preview="((property.readonly || !allowUpdate) && !property.supportsReadOnly) || (propertyEditorDisabled(property) && allowUpdate)" + preview="isPreview(property)" allow-unlock="!property.readonly && allowUpdate && allowEditInvariantFromNonDefault" on-unlock="unlockInvariantValue(property)" ng-attr-readonly="{{property.readonly || !allowUpdate || undefined}}"> diff --git a/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html b/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html index e3a65a215bdf..e4166e8e98d0 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html @@ -9,7 +9,7 @@ <div class="control-header" ng-hide="(vm.hideLabel || vm.property.hideLabel) === true"> - <label data-element="property-label-{{vm.property.alias}}" class="control-label" for="{{vm.property.alias}}" ng-attr-title="{{vm.controlLabelTitle}}" aria-label="Property alias: {{vm.controlAriaLabel}}">{{vm.property.label}}<span ng-if="vm.property.validation.mandatory || vm.property.ncMandatory"><strong class="umb-control-required">*</strong></span></label> + <label data-element="property-label-{{vm.property.alias}}" class="control-label" for="{{vm.preview ? undefined : vm.property.alias}}" ng-attr-title="{{vm.controlLabelTitle}}" aria-label="Property alias: {{vm.controlAriaLabel}}">{{vm.property.label}}<span ng-if="vm.property.validation.mandatory || vm.property.ncMandatory"><strong class="umb-control-required">*</strong></span></label> <umb-property-actions actions="vm.propertyActions"></umb-property-actions> diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js index 0edcd67c1924..c9fc26e0535e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js @@ -781,7 +781,7 @@ function listViewController($scope, $interpolate, $routeParams, $injector, $time $scope.options.allowBulkDelete; if ($scope.isTrashed === false) { - getContentTypesCallback(id).then(function (listViewAllowedTypes) { + getContentTypesCallback($scope.contentId).then(function (listViewAllowedTypes) { $scope.listViewAllowedTypes = listViewAllowedTypes; var blueprints = false; diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js index 2794f0bd16fa..bb9006779dc9 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js @@ -83,6 +83,7 @@ } })); + vm.layout = []; // The layout object specific to this Block Editor, will be a direct reference from Property Model. vm.availableBlockTypes = []; // Available block entries of this property editor. vm.labels = {}; @@ -190,6 +191,9 @@ vm.containerHeight = "auto"; vm.containerOverflow = "inherit" + // Add client validation for the markup part. + unsubscribe.push($scope.$watch(() => vm.model?.value?.markup, validate)); + //queue file loading tinyMceAssets.forEach(function (tinyJsAsset) { assetPromises.push(assetsService.loadJs(tinyJsAsset, $scope)); @@ -337,6 +341,18 @@ } } + function validate() { + var isValid = !vm.model.validation.mandatory || ( + vm.model.value != null + && vm.model.value.markup != null + && vm.model.value.markup != "" + ); + vm.propertyForm.$setValidity("required", isValid); + if (vm.umbProperty) { + vm.umbProperty.setPropertyError(vm.model.validation.mandatoryMessage || "Value cannot be empty"); + } + }; + // Called when we save the value, the server may return an updated data and our value is re-synced // we need to deal with that here so that our model values are all in sync so we basically re-initialize. function onServerValueChanged(newVal, oldVal) { diff --git a/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html b/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html index eaa92b7a6e78..1eb6840fd3b5 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html +++ b/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html @@ -1,4 +1,4 @@ -<div ng-controller="Umbraco.Editors.Users.DetailsController as vm" class="umb-user-details-details"> +<div ng-controller="Umbraco.Editors.Users.DetailsController as vm" class="umb-user-details-details"> <div class="umb-user-details-details__main-content"> @@ -45,6 +45,8 @@ ng-model="model.user.username" umb-auto-focus name="username" required + autocomplete="off" + no-password-manager val-server-field="Username" /> <span ng-messages="userProfileForm.username.$error" show-validation-on-submit> <span class="help-inline" ng-message="required"><localize key="general_required">Required</localize></span> diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js index 4a12d5254d04..ce5de98d4c2b 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js @@ -247,6 +247,7 @@ function save() { if (!formHelper.submitForm({ scope: $scope })) { + vm.saveButtonState = 'error'; return; } diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html index f64ef5d69bce..5a4678c5eb92 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html @@ -5,7 +5,7 @@ <form name="editWebhookForm" novalidate val-form-manager> <umb-editor-view ng-if="!vm.loading"> - + <umb-editor-header name="vm.webhook.name" name-locked="true" @@ -15,7 +15,7 @@ hide-alias="true" hide-description="true"> </umb-editor-header> - + <umb-editor-container class="form-horizontal"> <div class="umb-package-details"> @@ -50,6 +50,8 @@ alias="webhookEvents" required="true"> + <input type="hidden" name="eventsValidator" ng-model="eventsValidator" ng-required="!vm.webhook.events.length" /> + <umb-node-preview ng-repeat="event in vm.webhook.events" ng-show="event" @@ -198,9 +200,9 @@ </umb-editor-footer-content-right> </umb-editor-footer> - + </umb-editor-view> </form> - + </div> diff --git a/src/Umbraco.Web.UI.Login/src/auth.element.ts b/src/Umbraco.Web.UI.Login/src/auth.element.ts index 3f5bfd1428d9..e195f3986a21 100644 --- a/src/Umbraco.Web.UI.Login/src/auth.element.ts +++ b/src/Umbraco.Web.UI.Login/src/auth.element.ts @@ -179,7 +179,7 @@ export default class UmbAuthElement extends LitElement { }); this._usernameLabel = createLabel({ forId: 'username-input', - localizeAlias: this.usernameIsEmail ? 'general_email' : 'auth_username', + localizeAlias: this.usernameIsEmail ? 'general_email' : 'general_username', localizeFallback: this.usernameIsEmail ? 'Email' : 'Username', }); this._passwordLabel = createLabel({forId: 'password-input', localizeAlias: 'general_password', localizeFallback: 'Password'}); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs index 1ce5eeefd99b..297bd5069900 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs @@ -638,6 +638,89 @@ public void Can_Get_Tags_For_Property_For_Group() } } + [Test] + public void Can_Get_Tags_For_Entity_Type_Excluding_Trashed_Entity() + { + var provider = ScopeProvider; + using (ScopeProvider.CreateScope()) + { + var template = TemplateBuilder.CreateTextPageTemplate(); + FileService.SaveTemplate(template); + + var contentType = ContentTypeBuilder.CreateSimpleContentType("test", "Test", defaultTemplateId: template.Id); + ContentTypeRepository.Save(contentType); + + var content1 = ContentBuilder.CreateSimpleContent(contentType); + content1.PublishCulture(CultureImpact.Invariant); + content1.PublishedState = PublishedState.Publishing; + DocumentRepository.Save(content1); + + var content2 = ContentBuilder.CreateSimpleContent(contentType); + content2.PublishCulture(CultureImpact.Invariant); + content2.PublishedState = PublishedState.Publishing; + content2.Trashed = true; + DocumentRepository.Save(content2); + + var mediaType = MediaTypeBuilder.CreateImageMediaType("image2"); + MediaTypeRepository.Save(mediaType); + + var media1 = MediaBuilder.CreateMediaImage(mediaType, -1); + MediaRepository.Save(media1); + + var media2 = MediaBuilder.CreateMediaImage(mediaType, -1); + media2.Trashed = true; + MediaRepository.Save(media2); + + var repository = CreateRepository(provider); + Tag[] tags = + { + new Tag {Text = "tag1", Group = "test"}, + new Tag {Text = "tag2", Group = "test1"}, + new Tag {Text = "tag3", Group = "test"} + }; + + Tag[] tags2 = +{ + new Tag {Text = "tag4", Group = "test"}, + new Tag {Text = "tag5", Group = "test1"}, + new Tag {Text = "tag6", Group = "test"} + }; + + repository.Assign( + content1.Id, + contentType.PropertyTypes.First().Id, + tags, + false); + + repository.Assign( + content2.Id, + contentType.PropertyTypes.First().Id, + tags2, + false); + + repository.Assign( + media1.Id, + contentType.PropertyTypes.First().Id, + tags, + false); + + repository.Assign( + media2.Id, + contentType.PropertyTypes.First().Id, + tags2, + false); + + var result1 = repository.GetTagsForEntityType(TaggableObjectTypes.Content).ToArray(); + var result2 = repository.GetTagsForEntityType(TaggableObjectTypes.Media).ToArray(); + var result3 = repository.GetTagsForEntityType(TaggableObjectTypes.All).ToArray(); + + const string ExpectedTags = "tag1,tag2,tag3"; + Assert.AreEqual(ExpectedTags, string.Join(",", result1.Select(x => x.Text))); + Assert.AreEqual(ExpectedTags, string.Join(",", result2.Select(x => x.Text))); + Assert.AreEqual(ExpectedTags, string.Join(",", result3.Select(x => x.Text))); + } + } + [Test] public void Can_Get_Tags_For_Entity_Type() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs index 6a9559f14c83..2cfcf526ed8a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs @@ -70,11 +70,13 @@ public void PostSaveMember_WhenModelStateIsNotValid_ExpectFailureResponse( IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IPasswordChanger<MemberIdentityUser> passwordChanger, IOptions<GlobalSettings> globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange SetupMemberTestData(out var fakeMemberData, out _, ContentSaveAction.SaveNew); + + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -84,7 +86,8 @@ public void PostSaveMember_WhenModelStateIsNotValid_ExpectFailureResponse( backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); sut.ModelState.AddModelError("key", "Invalid model state"); Mock.Get(umbracoMembersUserManager) @@ -116,7 +119,6 @@ public async Task PostSaveMember_SaveNew_NoCustomField_WhenAllIsSetupCorrectly_E IBackOfficeSecurity backOfficeSecurity, IPasswordChanger<MemberIdentityUser> passwordChanger, IOptions<GlobalSettings> globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -138,6 +140,8 @@ public async Task PostSaveMember_SaveNew_NoCustomField_WhenAllIsSetupCorrectly_E .Returns(() => member); Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny<string>())).Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -147,7 +151,8 @@ public async Task PostSaveMember_SaveNew_NoCustomField_WhenAllIsSetupCorrectly_E backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -170,7 +175,6 @@ public async Task PostSaveMember_SaveNew_CustomField_WhenAllIsSetupCorrectly_Exp IBackOfficeSecurity backOfficeSecurity, IPasswordChanger<MemberIdentityUser> passwordChanger, IOptions<GlobalSettings> globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -192,6 +196,8 @@ public async Task PostSaveMember_SaveNew_CustomField_WhenAllIsSetupCorrectly_Exp .Returns(() => member); Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny<string>())).Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -201,7 +207,8 @@ public async Task PostSaveMember_SaveNew_CustomField_WhenAllIsSetupCorrectly_Exp backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -256,6 +263,8 @@ public async Task PostSaveMember_SaveExisting_WhenAllIsSetupCorrectly_ExpectSucc .Returns(() => null) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -265,7 +274,8 @@ public async Task PostSaveMember_SaveExisting_WhenAllIsSetupCorrectly_ExpectSucc backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -316,6 +326,8 @@ public async Task PostSaveMember_SaveExisting_WhenAllIsSetupWithPasswordIncorrec .Returns(() => null) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -325,7 +337,8 @@ public async Task PostSaveMember_SaveExisting_WhenAllIsSetupWithPasswordIncorrec backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -382,7 +395,6 @@ public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_ExpectFailRespon IBackOfficeSecurity backOfficeSecurity, IPasswordChanger<MemberIdentityUser> passwordChanger, IOptions<GlobalSettings> globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -403,6 +415,8 @@ public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_ExpectFailRespon x => x.GetByEmail(It.IsAny<string>())) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -412,7 +426,8 @@ public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_ExpectFailRespon backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = sut.PostSave(fakeMemberData).Result; @@ -424,6 +439,66 @@ public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_ExpectFailRespon Assert.AreEqual(StatusCodes.Status400BadRequest, validation?.StatusCode); } + [Test] + [AutoMoqData] + public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_AndDuplicateEmailsAreAllowed_ExpectSuccessResponse( + [Frozen] IMemberManager umbracoMembersUserManager, + IMemberService memberService, + IMemberTypeService memberTypeService, + IMemberGroupService memberGroupService, + IDataTypeService dataTypeService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IBackOfficeSecurity backOfficeSecurity, + IPasswordChanger<MemberIdentityUser> passwordChanger, + IOptions<GlobalSettings> globalSettings, + ITwoFactorLoginService twoFactorLoginService) + { + // arrange + var member = SetupMemberTestData(out var fakeMemberData, out var memberDisplay, ContentSaveAction.SaveNew); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.CreateAsync(It.IsAny<MemberIdentityUser>(), It.IsAny<string>())) + .ReturnsAsync(() => IdentityResult.Success); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.ValidatePasswordAsync(It.IsAny<string>())) + .ReturnsAsync(() => IdentityResult.Success); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.GetRolesAsync(It.IsAny<MemberIdentityUser>())) + .ReturnsAsync(() => Array.Empty<string>()); + Mock.Get(memberTypeService).Setup(x => x.GetDefault()).Returns("fakeAlias"); + Mock.Get(backOfficeSecurityAccessor).Setup(x => x.BackOfficeSecurity).Returns(backOfficeSecurity); + Mock.Get(memberService).SetupSequence( + x => x.GetByEmail(It.IsAny<string>())) + .Returns(() => null) + .Returns(() => member); + Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny<string>())).Returns(() => member); + + Mock.Get(memberService).SetupSequence( + x => x.GetByEmail(It.IsAny<string>())) + .Returns(() => member); + + var securitySettings = Options.Create(new SecuritySettings { MemberRequireUniqueEmail = false }); + + var sut = CreateSut( + memberService, + memberTypeService, + memberGroupService, + umbracoMembersUserManager, + dataTypeService, + backOfficeSecurityAccessor, + passwordChanger, + globalSettings, + twoFactorLoginService, + securitySettings); + + // act + var result = sut.PostSave(fakeMemberData).Result; + var validation = result.Result as ValidationErrorResult; + + // assert + Assert.IsNull(result.Result); + Assert.IsNotNull(result.Value); + } + [Test] [AutoMoqData] public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectSuccessResponse( @@ -472,6 +547,9 @@ public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectS x => x.GetByEmail(It.IsAny<string>())) .Returns(() => null) .Returns(() => member); + + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -481,7 +559,8 @@ public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectS backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -512,6 +591,7 @@ public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectS /// <param name="passwordChanger">Password changer class</param> /// <param name="globalSettings">The global settings</param> /// <param name="twoFactorLoginService">The two factor login service</param> + /// <param name="securitySettings">The security settings</param> /// <returns>A member controller for the tests</returns> private MemberController CreateSut( IMemberService memberService, @@ -522,7 +602,8 @@ private MemberController CreateSut( IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IPasswordChanger<MemberIdentityUser> passwordChanger, IOptions<GlobalSettings> globalSettings, - ITwoFactorLoginService twoFactorLoginService) + ITwoFactorLoginService twoFactorLoginService, + IOptions<SecuritySettings> securitySettings) { var httpContextAccessor = new HttpContextAccessor(); @@ -623,7 +704,8 @@ private MemberController CreateSut( new ConfigurationEditorJsonSerializer(), passwordChanger, scopeProvider, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); } /// <summary> diff --git a/version.json b/version.json index 0d1b19886972..c976fce1001a 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "13.6.0", + "version": "13.7.0", "assemblyVersion": { "precision": "build" },