diff --git a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs index ba9809f657..42019f7c99 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs @@ -568,7 +568,7 @@ public class EntityController : UmbracoAuthorizedJsonController [HttpGet] public UrlAndAnchors GetUrlAndAnchors(int id, string? culture = "*") { - culture ??= ClientCulture(); + culture = culture is null or "*" ? ClientCulture() : culture; var url = _publishedUrlProvider.GetUrl(id, culture: culture); IEnumerable anchorValues = _contentService.GetAnchorValuesFromRTEs(id, culture); diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index 916ff3d495..02eb9cda8e 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -35,6 +35,7 @@ using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Cms.Web.Common.Models; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; @@ -795,22 +796,44 @@ public class UsersController : BackOfficeNotificationsController return ValidationProblem("The current user cannot disable itself"); } - IUser[] users = _userService.GetUsersById(userIds).ToArray(); + var users = _userService.GetUsersById(userIds).ToList(); + List skippedUsers = new(); foreach (IUser u in users) { + if (u.UserState is UserState.Invited) + { + _logger.LogWarning("Could not disable invited user {Username}", u.Name); + skippedUsers.Add(u); + continue; + } + u.IsApproved = false; u.InvitedDate = null; } - _userService.Save(users); + users = users.Except(skippedUsers).ToList(); - if (users.Length > 1) + if (users.Any()) { - return Ok(_localizedTextService.Localize("speechBubbles", "disableUsersSuccess", - new[] { userIds.Length.ToString() })); + _userService.Save(users); + } + else + { + return Ok(new DisabledUsersModel()); } - return Ok(_localizedTextService.Localize("speechBubbles", "disableUserSuccess", new[] { users[0].Name })); + var disabledUsersModel = new DisabledUsersModel + { + DisabledUserIds = users.Select(x => x.Id), + }; + + var message= users.Count > 1 + ? _localizedTextService.Localize("speechBubbles", "disableUsersSuccess", new[] { userIds.Length.ToString() }) + : _localizedTextService.Localize("speechBubbles", "disableUserSuccess", new[] { users[0].Name }); + + var header = _localizedTextService.Localize("general", "success"); + disabledUsersModel.Notifications.Add(new BackOfficeNotification(header, message, NotificationStyle.Success)); + return Ok(disabledUsersModel); } /// diff --git a/src/Umbraco.Web.BackOffice/Install/InstallAuthorizeAttribute.cs b/src/Umbraco.Web.BackOffice/Install/InstallAuthorizeAttribute.cs index 2c6d5102e8..6d58e902d4 100644 --- a/src/Umbraco.Web.BackOffice/Install/InstallAuthorizeAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Install/InstallAuthorizeAttribute.cs @@ -42,7 +42,7 @@ public class InstallAuthorizeAttribute : TypeFilterAttribute // Only authorize when the installer is enabled context.Result = new ForbidResult(new AuthenticationProperties() { - RedirectUri = _linkGenerator.GetBackOfficeUrl(_hostingEnvironment) + RedirectUri = _linkGenerator.GetUmbracoBackOfficeUrl(_hostingEnvironment) }); } else if (_runtimeState.Level == RuntimeLevel.Upgrade && (await context.HttpContext.AuthenticateBackOfficeAsync()).Succeeded == false) diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index edfe999d20..dc94fa3dc8 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -149,7 +149,7 @@ public static partial class UmbracoBuilderExtensions // WebRootFileProviderFactory is just a wrapper around the IWebHostEnvironment.WebRootFileProvider, // therefore no need to register it as singleton - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Must be added here because DbProviderFactories is netstandard 2.1 so cannot exist in Infra for now diff --git a/src/Umbraco.Web.Common/Extensions/LinkGeneratorExtensions.cs b/src/Umbraco.Web.Common/Extensions/LinkGeneratorExtensions.cs index ce6e8c7816..1c589ebe2a 100644 --- a/src/Umbraco.Web.Common/Extensions/LinkGeneratorExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/LinkGeneratorExtensions.cs @@ -12,33 +12,36 @@ namespace Umbraco.Extensions; public static class LinkGeneratorExtensions { + /// + /// Gets the Umbraco backoffice URL (if Umbraco is installed). + /// + /// The link generator. + /// + /// The Umbraco backoffice URL. + /// + public static string? GetUmbracoBackOfficeUrl(this LinkGenerator linkGenerator) + => linkGenerator.GetPathByAction("Default", "BackOffice", new { area = Constants.Web.Mvc.BackOfficeArea }); + + /// + /// Gets the Umbraco backoffice URL (if Umbraco is installed) or application virtual path (in most cases just "/"). + /// + /// The link generator. + /// The hosting environment. + /// + /// The Umbraco backoffice URL. + /// + public static string GetUmbracoBackOfficeUrl(this LinkGenerator linkGenerator, IHostingEnvironment hostingEnvironment) + => GetUmbracoBackOfficeUrl(linkGenerator) ?? hostingEnvironment.ApplicationVirtualPath; + /// /// Return the back office url if the back office is installed /// + /// + /// This method contained a bug that would result in always returning "/". + /// + [Obsolete("Use the GetUmbracoBackOfficeUrl extension method instead. This method will be removed in Umbraco 13.")] public static string? GetBackOfficeUrl(this LinkGenerator linkGenerator, IHostingEnvironment hostingEnvironment) - { - Type? backOfficeControllerType; - try - { - backOfficeControllerType = Assembly.Load("Umbraco.Web.BackOffice") - .GetType("Umbraco.Web.BackOffice.Controllers.BackOfficeController"); - if (backOfficeControllerType == null) - { - return "/"; // this would indicate that the installer is installed without the back office - } - } - catch - { - return - hostingEnvironment - .ApplicationVirtualPath; // this would indicate that the installer is installed without the back office - } - - return linkGenerator.GetPathByAction( - "Default", - ControllerExtensions.GetControllerName(backOfficeControllerType), - new { area = Constants.Web.Mvc.BackOfficeApiArea }); - } + => "/"; /// /// Return the Url for a Web Api service diff --git a/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs b/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs index 6eacc2ef24..972dc67e19 100644 --- a/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs @@ -22,21 +22,27 @@ namespace Umbraco.Extensions; public static class UrlHelperExtensions { + /// + /// Gets the Umbraco backoffice URL (if Umbraco is installed). + /// + /// The URL helper. + /// + /// The Umbraco backoffice URL. + /// + public static string? GetUmbracoBackOfficeUrl(this IUrlHelper urlHelper) + => urlHelper.Action("Default", "BackOffice", new { area = Constants.Web.Mvc.BackOfficeArea }); + /// /// Return the back office url if the back office is installed /// /// /// + /// + /// This method contained a bug that would result in always returning "/". + /// + [Obsolete("Use the GetUmbracoBackOfficeUrl extension method instead. This method will be removed in Umbraco 13.")] public static string? GetBackOfficeUrl(this IUrlHelper url) - { - var backOfficeControllerType = Type.GetType("Umbraco.Web.BackOffice.Controllers"); - if (backOfficeControllerType == null) - { - return "/"; // this would indicate that the installer is installed without the back office - } - - return url.Action("Default", ControllerExtensions.GetControllerName(backOfficeControllerType), new { area = Constants.Web.Mvc.BackOfficeApiArea }); - } + => "/"; /// /// Return the Url for a Web Api service diff --git a/src/Umbraco.Web.Common/FileProviders/ContentAndWebRootFileProviderFactory.cs b/src/Umbraco.Web.Common/FileProviders/ContentAndWebRootFileProviderFactory.cs new file mode 100644 index 0000000000..7c3831b919 --- /dev/null +++ b/src/Umbraco.Web.Common/FileProviders/ContentAndWebRootFileProviderFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Web.Common.FileProviders; + +public class ContentAndWebRootFileProviderFactory : IManifestFileProviderFactory +{ + private readonly IWebHostEnvironment _webHostEnvironment; + + /// + /// Initializes a new instance of the class. + /// + /// The web hosting environment an application is running in. + public ContentAndWebRootFileProviderFactory(IWebHostEnvironment webHostEnvironment) + { + _webHostEnvironment = webHostEnvironment; + } + + /// + /// Creates a new instance, pointing at WebRootPath and ContentRootPath. + /// + /// + /// The newly created instance. + /// + public IFileProvider Create() => new CompositeFileProvider(_webHostEnvironment.WebRootFileProvider, _webHostEnvironment.ContentRootFileProvider); +} diff --git a/src/Umbraco.Web.Common/Models/DisabledUsersModel.cs b/src/Umbraco.Web.Common/Models/DisabledUsersModel.cs new file mode 100644 index 0000000000..f6712ab193 --- /dev/null +++ b/src/Umbraco.Web.Common/Models/DisabledUsersModel.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Web.Common.Models; + +[DataContract] +public class DisabledUsersModel : INotificationModel +{ + public List Notifications { get; } = new(); + + [DataMember(Name = "disabledUserIds")] + public IEnumerable DisabledUserIds { get; set; } = Enumerable.Empty(); +} diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/tags/umbtagseditor.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/tags/umbtagseditor.directive.js index 8238a2be66..7ae96e0cd5 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/tags/umbtagseditor.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/tags/umbtagseditor.directive.js @@ -92,7 +92,7 @@ var sources = { //see: https://github.com/twitter/typeahead.js/blob/master/doc/jquery_typeahead.md#options // name = the data set name, we'll make this the tag group name + culture - name: vm.config.group + (vm.culture ? vm.culture : ""), + name: (vm.config.group + (vm.culture ? vm.culture : "")).replace(/\W/g, '-'), display: "text", //source: tagsHound source: function (query, syncCallback, asyncCallback) { diff --git a/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js b/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js index fb8cf12ea2..36c1259cc3 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js @@ -170,9 +170,6 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s })); }); } - else { - styleFormats = fallbackStyles; - } return $q.all(promises).then(function () { // Always push our Umbraco RTE stylesheet @@ -375,7 +372,6 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s autoresize_bottom_margin: 10, content_css: styles.stylesheets, style_formats: styles.styleFormats, - style_formats_autohide: true, language: getLanguage(), //this would be for a theme other than inlite @@ -450,9 +446,20 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s } } + // if we have style_formats at this point they originate from the RTE CSS config. we don't want any custom + // style_formats to interfere with the RTE CSS config, so let's explicitly remove the custom style_formats. + if(tinyMceConfig.customConfig.style_formats && config.style_formats && config.style_formats.length){ + delete tinyMceConfig.customConfig.style_formats; + } + Utilities.extend(config, tinyMceConfig.customConfig); } + if(!config.style_formats || !config.style_formats.length){ + // if we have no style_formats at this point we'll revert to using the default ones (fallbackStyles) + config.style_formats = fallbackStyles; + } + return config; }); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js index dfdc9aa779..09facf96b5 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js @@ -21,10 +21,6 @@ function multiUrlPickerController($scope, localizationService, entityResource, i $scope.renderModel = []; - if ($scope.preview) { - return; - } - if ($scope.model.config && parseInt($scope.model.config.maxNumber) !== 1 && $scope.umbProperty) { var propertyActions = [ removeAllEntriesAction @@ -83,7 +79,7 @@ function multiUrlPickerController($scope, localizationService, entityResource, i $scope.sortableOptions.disabled = $scope.renderModel.length === 1 || $scope.readonly; removeAllEntriesAction.isDisabled = $scope.renderModel.length === 0 || $scope.readonly; - + //Update value $scope.model.value = $scope.renderModel; } @@ -93,7 +89,7 @@ function multiUrlPickerController($scope, localizationService, entityResource, i if (!$scope.allowRemove) return; $scope.renderModel.splice($index, 1); - + setDirty(); }; @@ -208,15 +204,23 @@ function multiUrlPickerController($scope, localizationService, entityResource, i $scope.model.config.minNumber = 1; } - _.each($scope.model.value, function (item) { + const ids = []; + $scope.model.value.forEach(item => { // we must reload the "document" link URLs to match the current editor culture - if (item.udi && item.udi.indexOf("/document/") > 0) { + if (item.udi && item.udi.indexOf("/document/") > 0 && ids.indexOf(item.udi) < 0) { + ids.push(item.udi); item.url = null; - entityResource.getUrlByUdi(item.udi).then(data => { - item.url = data; - }); } }); + + if(ids.length){ + entityResource.getUrlsByIds(ids, "Document").then(function(urlMap){ + Object.keys(urlMap).forEach((udi) => { + const items = $scope.model.value.filter(item => item.udi === udi); + items.forEach(item => item.url = urlMap[udi]); + }) + }); + } } init(); diff --git a/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js b/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js index 99cbe31f66..823f7ee6fd 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js @@ -334,7 +334,7 @@ vm.disableUserButtonState = "busy"; usersResource.disableUsers(vm.selection).then(function (data) { // update userState - vm.selection.forEach(function (userId) { + data.disabledUserIds.forEach(function (userId) { var user = getUserFromArrayById(userId, vm.users); if (user) { user.userState = "Disabled"; @@ -808,6 +808,7 @@ if (user.userDisplayState && user.userDisplayState.key === "Invited") { vm.allowEnableUser = false; + vm.allowDisableUser = false; } if (user.userDisplayState && user.userDisplayState.key === "LockedOut") { diff --git a/src/Umbraco.Web.Website/Models/RegisterModelBuilder.cs b/src/Umbraco.Web.Website/Models/RegisterModelBuilder.cs index 8ce6ab5595..b3736e63e8 100644 --- a/src/Umbraco.Web.Website/Models/RegisterModelBuilder.cs +++ b/src/Umbraco.Web.Website/Models/RegisterModelBuilder.cs @@ -56,6 +56,7 @@ public class RegisterModelBuilder : MemberModelBuilderBase var model = new RegisterModel { + RedirectUrl = _redirectUrl, MemberTypeAlias = providedOrDefaultMemberTypeAlias, UsernameIsEmail = _usernameIsEmail, MemberProperties = _lookupProperties diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/UsersControllerTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/UsersControllerTests.cs index 6fcd1d7eee..c89841260a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/UsersControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/UsersControllerTests.cs @@ -1,14 +1,9 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; using System.Net.Mime; using System.Text; -using System.Threading.Tasks; using Newtonsoft.Json; using NUnit.Framework; using Umbraco.Cms.Core; @@ -21,7 +16,7 @@ using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Integration.TestServerTest; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Formatters; -using Umbraco.Extensions; +using Umbraco.Cms.Web.Common.Models; namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers; @@ -231,4 +226,69 @@ public class UsersControllerTests : UmbracoTestServerTestBase Assert.AreEqual($"Unlocked {users.Count()} users", actual.Message); }); } + + [Test] + public async Task Cannot_Disable_Invited_User() + { + var userService = GetRequiredService(); + + var user = new UserBuilder() + .AddUserGroup() + .WithAlias("writer") // Needs to be an existing alias + .Done() + .Build(); + + user.LastLoginDate = default; + user.InvitedDate = DateTime.Now; + userService.Save(user); + var createdUser = userService.GetByEmail("test@test.com"); + + // Act + var url = PrepareApiControllerUrl(x => x.PostDisableUsers(new []{createdUser.Id})); + var response = await Client.PostAsync(url, null); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + body = body.TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix); + }); + } + + [Test] + public async Task Can_Disable_Active_User() + { + var userService = GetRequiredService(); + + var user = new UserBuilder() + .AddUserGroup() + .WithAlias("writer") // Needs to be an existing alias + .Done() + .Build(); + + user.IsApproved = true; + userService.Save(user); + + var createdUser = userService.GetByEmail("test@test.com"); + + // Act + var url = PrepareApiControllerUrl(x => x.PostDisableUsers(new[] { createdUser.Id })); + var response = await Client.PostAsync(url, null); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + body = body.TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix); + var affectedUsers = JsonConvert.DeserializeObject(body, new JsonSerializerSettings { ContractResolver = new IgnoreRequiredAttributesResolver() }); + Assert.AreEqual(affectedUsers!.DisabledUserIds.First(), createdUser!.Id); + + var disabledUser = userService.GetByEmail("test@test.com"); + Assert.AreEqual(disabledUser!.UserState, UserState.Disabled); + }); + } }