diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI.csproj b/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI.csproj index cc0a3614..8ae8ec00 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI.csproj +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI.csproj @@ -33,7 +33,7 @@ - + diff --git a/Lombiq.Hosting.Tenants.EnvironmentRobots.Tests.UI/Lombiq.Hosting.Tenants.EnvironmentRobots.Tests.UI.csproj b/Lombiq.Hosting.Tenants.EnvironmentRobots.Tests.UI/Lombiq.Hosting.Tenants.EnvironmentRobots.Tests.UI.csproj index 4ae8a30e..599dcff6 100644 --- a/Lombiq.Hosting.Tenants.EnvironmentRobots.Tests.UI/Lombiq.Hosting.Tenants.EnvironmentRobots.Tests.UI.csproj +++ b/Lombiq.Hosting.Tenants.EnvironmentRobots.Tests.UI/Lombiq.Hosting.Tenants.EnvironmentRobots.Tests.UI.csproj @@ -21,7 +21,7 @@ - + diff --git a/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI.csproj b/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI.csproj index 2030e18a..8da04880 100644 --- a/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI.csproj +++ b/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI.csproj @@ -33,7 +33,7 @@ - + diff --git a/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI.csproj b/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI.csproj index 142f9dd5..3d8aae8a 100644 --- a/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI.csproj +++ b/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI.csproj @@ -21,7 +21,7 @@ - + diff --git a/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Lombiq.Hosting.Tenants.Maintenance.Tests.UI.csproj b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Lombiq.Hosting.Tenants.Maintenance.Tests.UI.csproj index a1102866..6429c89d 100644 --- a/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Lombiq.Hosting.Tenants.Maintenance.Tests.UI.csproj +++ b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Lombiq.Hosting.Tenants.Maintenance.Tests.UI.csproj @@ -21,7 +21,7 @@ - + diff --git a/Lombiq.Hosting.Tenants.Management.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs b/Lombiq.Hosting.Tenants.Management.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs new file mode 100644 index 00000000..c4665351 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs @@ -0,0 +1,63 @@ +using Lombiq.Tests.UI.Extensions; +using Lombiq.Tests.UI.Services; +using Newtonsoft.Json.Linq; +using OpenQA.Selenium; +using Shouldly; +using System.Threading.Tasks; + +namespace Lombiq.Hosting.Tenants.Management.Tests.UI.Extensions; + +public static class TestCaseUITestContextExtensions +{ + public static async Task TestShellSettingsEditorFeatureAsync(this UITestContext context) + { + await context.SignInDirectlyAsync(); + await context.GoToAdminRelativeUrlAsync("/Tenants/Edit/Default"); + + // Expected JSON string. +#pragma warning disable JSON002 // Probable JSON string detected + await context.FillInEditorThenCheckValueAsync( + "{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{\"FirstTestKey\": \"FirstTestValue\",\"SecondTestKey\": \"SecondTestValue\"}}}}", + "FirstTestKey", + "FirstTestValue"); + + await context.FillInEditorThenCheckValueAsync( + "{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{\"NewKey\": \"NewValue\",\"SecondTestKey\": \"SecondTestValue\"}}}}", + "NewKey", + "NewValue"); + + await context.FillInEditorThenCheckValueAsync( + "{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{\"SecondTestKey\": \"SecondTestValue\"}}}}", + "NewKey", + string.Empty); + + CheckEditorValue(context, "SecondTestKey", "SecondTestValue"); + + await context.FillInEditorThenCheckValueAsync( + "{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{}}}}", + "SecondTestKey", + string.Empty); +#pragma warning restore JSON002 // Probable JSON string detected + + await context.FillInEditorThenCheckValueAsync( + string.Empty, + "SecondTestKey", + string.Empty); + } + + private static async Task FillInEditorThenCheckValueAsync(this UITestContext context, string text, string keyToCheck, string expectedValue) + { + context.FillInMonacoEditor("Json_editor", text); + await context.ClickReliablyOnAsync(By.XPath("//button[contains(.,'Save settings')]")); + CheckEditorValue(context, keyToCheck, expectedValue); + } + + private static void CheckEditorValue(this UITestContext context, string keyToCheck, string expectedValue) + { + var editorText = context.GetMonacoEditorText("Json_editor"); + var editorJson = string.IsNullOrEmpty(editorText) ? "{}" : editorText; + + var editorValue = JObject.Parse(editorJson); + editorValue.SelectToken($"TestKey.TestSubKey.TestSubOptions.{keyToCheck}")?.ToString().ShouldBeAsString(expectedValue); + } +} diff --git a/Lombiq.Hosting.Tenants.Management.Tests.UI/License.md b/Lombiq.Hosting.Tenants.Management.Tests.UI/License.md new file mode 100644 index 00000000..d57e1305 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management.Tests.UI/License.md @@ -0,0 +1,13 @@ +Copyright © 2021, [Lombiq Technologies Ltd.](https://lombiq.com) + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Lombiq.Hosting.Tenants.Management.Tests.UI/Lombiq.Hosting.Tenants.Management.Tests.UI.csproj b/Lombiq.Hosting.Tenants.Management.Tests.UI/Lombiq.Hosting.Tenants.Management.Tests.UI.csproj new file mode 100644 index 00000000..d9ae33f2 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management.Tests.UI/Lombiq.Hosting.Tenants.Management.Tests.UI.csproj @@ -0,0 +1,33 @@ + + + + net6.0 + + + + Lombiq Hosting - Tenants Management for Orchard Core - UI Test Extensions + Lombiq Technologies + Copyright © 2021, Lombiq Technologies Ltd. + Lombiq Hosting - Tenants Management for Orchard Core - UI Test Extensions: Extension methods that test tenants management for Orchard Core. + NuGetIcon.png + OrchardCore;Lombiq;AspNetCore;Multitenancy;SaaS + https://github.com/Lombiq/Hosting-Tenants + https://github.com/Lombiq/Hosting-Tenants/blob/dev/Lombiq.Hosting.Tenants.Management.Tests.UI/Readme.md + License.md + + + + + + + + + + + + + + + + + diff --git a/Lombiq.Hosting.Tenants.Management.Tests.UI/NuGetIcon.png b/Lombiq.Hosting.Tenants.Management.Tests.UI/NuGetIcon.png new file mode 100644 index 00000000..162a0050 Binary files /dev/null and b/Lombiq.Hosting.Tenants.Management.Tests.UI/NuGetIcon.png differ diff --git a/Lombiq.Hosting.Tenants.Management.Tests.UI/Readme.md b/Lombiq.Hosting.Tenants.Management.Tests.UI/Readme.md new file mode 100644 index 00000000..8eb9cbf0 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management.Tests.UI/Readme.md @@ -0,0 +1,7 @@ +# Lombiq Hosting - Tenants Management for Orchard Core - UI Test Extensions + +## About + +Extension methods that test tenants management for Orchard Core, with the help of [Lombiq UI Testing Toolbox for Orchard Core](https://github.com/Lombiq/UI-Testing-Toolbox). + +Call these from a UI test project to verify the module's basic features; as seen in [Open-Source Orchard Core Extensions](https://github.com/Lombiq/Open-Source-Orchard-Core-Extensions). diff --git a/Lombiq.Hosting.Tenants.Management/Constants/FeatureNames.cs b/Lombiq.Hosting.Tenants.Management/Constants/FeatureNames.cs index 7c962806..111ebb79 100644 --- a/Lombiq.Hosting.Tenants.Management/Constants/FeatureNames.cs +++ b/Lombiq.Hosting.Tenants.Management/Constants/FeatureNames.cs @@ -6,4 +6,5 @@ public static class FeatureNames public const string ForbiddenTenantNames = Module + "." + nameof(ForbiddenTenantNames); public const string HideRecipesFromSetup = Module + "." + nameof(HideRecipesFromSetup); + public const string ShellSettingsEditor = Module + "." + nameof(ShellSettingsEditor); } diff --git a/Lombiq.Hosting.Tenants.Management/Controllers/ShellSettingsEditorController.cs b/Lombiq.Hosting.Tenants.Management/Controllers/ShellSettingsEditorController.cs new file mode 100644 index 00000000..8b4df56e --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management/Controllers/ShellSettingsEditorController.cs @@ -0,0 +1,147 @@ +using Lombiq.Hosting.Tenants.Management.Constants; +using Lombiq.Hosting.Tenants.Management.Models; +using Lombiq.Hosting.Tenants.Management.Service; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.Extensions.Configuration; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Tenants.Controllers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using static OrchardCore.Tenants.Permissions; + +namespace Lombiq.Hosting.Tenants.Management.Controllers; + +[Feature(FeatureNames.ShellSettingsEditor)] +public class ShellSettingsEditorController : Controller +{ + private readonly IAuthorizationService _authorizationService; + private readonly IShellHost _shellHost; + private readonly IShellConfigurationSources _shellConfigurationSources; + private readonly IDistributedLock _distributedLock; + private readonly INotifier _notifier; + private readonly IHtmlLocalizer H; + + public ShellSettingsEditorController( + IAuthorizationService authorizationService, + IShellHost shellHost, + IShellConfigurationSources shellConfigurationSources, + IDistributedLock distributedLock, + INotifier notifier, + IHtmlLocalizer htmlLocalizer) + { + _authorizationService = authorizationService; + _shellHost = shellHost; + _shellConfigurationSources = shellConfigurationSources; + _distributedLock = distributedLock; + _notifier = notifier; + H = htmlLocalizer; + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(ShellSettingsEditorViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ManageTenants) || + !_shellHost.TryGetSettings(model.TenantId, out var shellSettings)) + { + return NotFound(); + } + + model.Json ??= "{}"; + if (!IsValidJson(model.Json)) + { + await _notifier.ErrorAsync(H["Please provide valid JSON input for shell settings."]); + TempData["ValidationErrorJson"] = model.Json; + + return RedirectToAction( + nameof(AdminController.Edit), + typeof(AdminController).ControllerName(), + new + { + area = "OrchardCore.Tenants", + id = model.TenantId, + }); + } + + var tenantConfiguration = new JsonConfigurationParser().ParseConfiguration(model.Json); + var newTenantConfiguration = new Dictionary(); + + var tenantSettingsPrefix = $"{model.TenantId}Prefix:"; + var currentSettings = shellSettings.ShellConfiguration.AsEnumerable() + .Where(item => item.Value != null && + item.Key.Contains(tenantSettingsPrefix)) + .ToDictionary(key => key.Key.Replace(tenantSettingsPrefix, string.Empty), value => value.Value); + + foreach (var key in tenantConfiguration.Keys) + { + var tenantSettingsPrefixWithKey = $"{tenantSettingsPrefix}{key}"; + if (shellSettings[key] != tenantConfiguration[key]) + { + newTenantConfiguration[tenantSettingsPrefixWithKey] = tenantConfiguration[key]; + newTenantConfiguration[key] = tenantConfiguration[key]; + } + } + + var deletableKeys = currentSettings + .Where(item => !tenantConfiguration.ContainsKey(item.Key)) + .Select(item => item.Key); + + foreach (var key in deletableKeys) + { + var tenantSettingsPrefixWithKey = $"{tenantSettingsPrefix}{key}"; + newTenantConfiguration[key] = null; + newTenantConfiguration[tenantSettingsPrefixWithKey] = null; + } + + var (locker, locked) = + await _distributedLock.TryAcquireLockAsync( + "LOMBIQ_HOSTING_TENANTS_MANAGEMENT_SHELL_SETTINGS_EDITOR_LOCK", + TimeSpan.FromSeconds(10)); + + if (!locked) + { + throw new TimeoutException($"Failed to acquire a lock before saving settings to the tenant: {model.TenantId}."); + } + + await using var acquiredLock = locker; + + // We are using the shell configuration sources directly because using IShellHost.UpdateShellSettingsAsync would + // not save settings that has a key with multiple sections, see + // https://github.com/OrchardCMS/OrchardCore/issues/14481. Once this is fixed, we can get rid of the locking and + // retrieve and save the shell settings settings with IShellHost. + await _shellConfigurationSources.SaveAsync(shellSettings.Name, newTenantConfiguration); + await _shellHost.UpdateShellSettingsAsync(shellSettings); + + return RedirectToAction( + nameof(AdminController.Edit), + typeof(AdminController).ControllerName(), + new + { + area = "OrchardCore.Tenants", + id = model.TenantId, + }); + } + + private static bool IsValidJson(string json) + { + try + { + JsonDocument.Parse(json); + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/Lombiq.Hosting.Tenants.Management/Filters/ShellSettingsEditorFilter.cs b/Lombiq.Hosting.Tenants.Management/Filters/ShellSettingsEditorFilter.cs new file mode 100644 index 00000000..1866e363 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management/Filters/ShellSettingsEditorFilter.cs @@ -0,0 +1,75 @@ +using Lombiq.Hosting.Tenants.Management.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.Layout; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Tenants.Controllers; +using System.Threading.Tasks; + +namespace Lombiq.Hosting.Tenants.Management.Filters; + +public class ShellSettingsEditorFilter : IAsyncResultFilter +{ + private readonly ILayoutAccessor _layoutAccessor; + private readonly IShapeFactory _shapeFactory; + private readonly IShellHost _shellHost; + + public ShellSettingsEditorFilter( + ILayoutAccessor layoutAccessor, + IShapeFactory shapeFactory, + IShellHost shellHost) + { + _layoutAccessor = layoutAccessor; + _shapeFactory = shapeFactory; + _shellHost = shellHost; + } + + public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + var actionRouteController = context.ActionDescriptor.RouteValues["Controller"]; + var actionRouteArea = context.ActionDescriptor.RouteValues["Area"]; + var actionRouteValue = context.ActionDescriptor.RouteValues["Action"]; + + if (actionRouteController == typeof(AdminController).ControllerName() && + actionRouteArea == $"{nameof(OrchardCore)}.{nameof(OrchardCore.Tenants)}" && + actionRouteValue is nameof(AdminController.Edit) && + context.Result is ViewResult) + { + var tenantName = context.RouteData.Values["Id"].ToString(); + if (!_shellHost.TryGetSettings(tenantName, out var shellSettings)) + { + await next(); + return; + } + + var layout = await _layoutAccessor.GetLayoutAsync(); + var contentZone = layout.Zones["Content"]; + + (context.Controller as Controller) + !.TempData + .TryGetValue( + "ValidationErrorJson", + out var validationErrorJson); + + var editableItems = shellSettings.ShellConfiguration.AsJsonNode(); + var editorJson = string.IsNullOrEmpty(validationErrorJson?.ToString()) + ? editableItems[$"{tenantName}Prefix"]?.ToJsonString() + : validationErrorJson.ToString(); + + await contentZone.AddAsync( + await _shapeFactory.CreateAsync( + "ShellSettingsEditor", + viewModel => + { + viewModel.Json = editorJson; + viewModel.TenantId = tenantName; + }), + "10"); + } + + await next(); + } +} diff --git a/Lombiq.Hosting.Tenants.Management/Manifest.cs b/Lombiq.Hosting.Tenants.Management/Manifest.cs index 00b19d67..c2bd9e33 100644 --- a/Lombiq.Hosting.Tenants.Management/Manifest.cs +++ b/Lombiq.Hosting.Tenants.Management/Manifest.cs @@ -26,3 +26,12 @@ DefaultTenantOnly = true, Dependencies = new[] { "OrchardCore.Setup" } )] + +[assembly: Feature( + Id = ShellSettingsEditor, + Name = "Lombiq Hosting - Tenants Management - Shell Settings Editor", + Description = "Adds a shell settings editor to the tenant editor page.", + Category = "Hosting", + DefaultTenantOnly = true, + Dependencies = new[] { "OrchardCore.Tenants" } +)] diff --git a/Lombiq.Hosting.Tenants.Management/Models/ShellSettingsEditorViewModel.cs b/Lombiq.Hosting.Tenants.Management/Models/ShellSettingsEditorViewModel.cs new file mode 100644 index 00000000..8ae2eda8 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management/Models/ShellSettingsEditorViewModel.cs @@ -0,0 +1,7 @@ +namespace Lombiq.Hosting.Tenants.Management.Models; + +public class ShellSettingsEditorViewModel +{ + public string Json { get; set; } + public string TenantId { get; set; } +} diff --git a/Lombiq.Hosting.Tenants.Management/Readme.md b/Lombiq.Hosting.Tenants.Management/Readme.md index 94730620..548eda2f 100644 --- a/Lombiq.Hosting.Tenants.Management/Readme.md +++ b/Lombiq.Hosting.Tenants.Management/Readme.md @@ -4,14 +4,15 @@ ## About -With the help of this module, you can set restrictions on tenant creation. +With the help of this module, you can set restrictions on tenant creation and set tenant level shell settings in the tenant editor. ## Documentation -This module contains two features: +This module contains these features: - `Lombiq.Hosting.Tenants.Management.ForbiddenTenantNames` - `Lombiq.Hosting.Tenants.Management.HideRecipesFromSetup` +- `Lombiq.Hosting.Tenants.Management.ShellSettingsEditor` ### `Lombiq.Hosting.Tenants.Management.ForbiddenTenantNames` @@ -42,3 +43,7 @@ public void ConfigureServices(IServiceCollection services) => ``` **NOTE:** This extension method not only sets the tags you want to hide but also registers the feature as a setup feature. If you just want to use the default `HideFromSetupScreen` tag then just call the extension method without any parameter. + +### `Lombiq.Hosting.Tenants.Management.ShellSettingsEditor` + +Adds a shell settings editor to the tenant editor page where you can set values that are not already present in the current ShellSetting for the given tenant. Only those settings will be displayed that were added from this editor. diff --git a/Lombiq.Hosting.Tenants.Management/Service/JsonConfigurationParser.cs b/Lombiq.Hosting.Tenants.Management/Service/JsonConfigurationParser.cs new file mode 100644 index 00000000..4acae3a0 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management/Service/JsonConfigurationParser.cs @@ -0,0 +1,112 @@ +// This file is a copy and slight modification of Microsoft.Extensions.Configuration.Json.JsonConfigurationFileParser +// https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Configuration.Json/src/JsonConfigurationFileParser.cs. +// Their recommended way of using this class is to copy it: https://github.com/dotnet/runtime/issues/73946. +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace Lombiq.Hosting.Tenants.Management.Service; + +public class JsonConfigurationParser +{ + private readonly Dictionary _configurationData = new(StringComparer.OrdinalIgnoreCase); + private readonly Stack _paths = new(); + + public IDictionary ParseConfiguration(string inputJson) + { + var jsonDocumentOptions = new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + using var doc = JsonDocument.Parse(inputJson, jsonDocumentOptions); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + throw new FormatException( + $"Top-level JSON element must be an object. Instead, '{doc.RootElement.ValueKind}' was found."); + } + + VisitObjectElement(doc.RootElement); + + return _configurationData; + } + + private void VisitObjectElement(JsonElement element) + { + var isEmpty = true; + + foreach (var property in element.EnumerateObject()) + { + isEmpty = false; + EnterContext(property.Name); + VisitValue(property.Value); + ExitContext(); + } + + SetNullIfElementIsEmpty(isEmpty); + } + + private void VisitArrayElement(JsonElement element) + { + var index = 0; + + foreach (var arrayElement in element.EnumerateArray()) + { + EnterContext(index.ToTechnicalString()); + VisitValue(arrayElement); + ExitContext(); + index++; + } + + SetNullIfElementIsEmpty(isEmpty: index == 0); + } + + private void SetNullIfElementIsEmpty(bool isEmpty) + { + if (isEmpty && _paths.Count > 0) + { + _configurationData[_paths.Peek()] = null; + } + } + + private void VisitValue(JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.Object: + VisitObjectElement(value); + break; + + case JsonValueKind.Array: + VisitArrayElement(value); + break; + + case JsonValueKind.Number: + case JsonValueKind.String: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Null: + var key = _paths.Peek(); + if (_configurationData.ContainsKey(key)) + { + throw new FormatException($"A duplicate key '{key}' was found."); + } + + _configurationData[key] = value.ToString(); + break; + + case JsonValueKind.Undefined: + default: + throw new FormatException($"Unsupported JSON token '{value.ValueKind}' was found."); + } + } + + private void EnterContext(string context) => + _paths.Push(_paths.Count > 0 ? + _paths.Peek() + ConfigurationPath.KeyDelimiter + context : + context); + + private void ExitContext() => _paths.Pop(); +} diff --git a/Lombiq.Hosting.Tenants.Management/Startup.cs b/Lombiq.Hosting.Tenants.Management/Startup.cs index fc08e610..4547afbe 100644 --- a/Lombiq.Hosting.Tenants.Management/Startup.cs +++ b/Lombiq.Hosting.Tenants.Management/Startup.cs @@ -36,3 +36,11 @@ public class HideRecipesFromSetupStartup : StartupBase public override void ConfigureServices(IServiceCollection services) => services.Decorate(); } + +[Feature(FeatureNames.ShellSettingsEditor)] +public class ShellSettingsEditorStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) => + services.Configure(options => + options.Filters.Add(typeof(ShellSettingsEditorFilter))); +} diff --git a/Lombiq.Hosting.Tenants.Management/Views/ShellSettingsEditor.cshtml b/Lombiq.Hosting.Tenants.Management/Views/ShellSettingsEditor.cshtml new file mode 100644 index 00000000..03d9dbf7 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management/Views/ShellSettingsEditor.cshtml @@ -0,0 +1,79 @@ +@using Lombiq.Hosting.Tenants.Management.Controllers +@using Newtonsoft.Json +@using OrchardCore +@using OrchardCore.Mvc.Core.Utilities +@using static Lombiq.Hosting.Tenants.Management.Constants.FeatureNames + +@model Lombiq.Hosting.Tenants.Management.Models.ShellSettingsEditorViewModel + +
+
+ +
+ + @T["JSON key-value pairs should be set here, in the same way you'd configure the tenant-specific section in an appsettings.json file."] + +
+ +
+
+
+ + +
+
+ + + diff --git a/Lombiq.Hosting.Tenants.Management/Views/_ViewImports.cshtml b/Lombiq.Hosting.Tenants.Management/Views/_ViewImports.cshtml new file mode 100644 index 00000000..30d2b303 --- /dev/null +++ b/Lombiq.Hosting.Tenants.Management/Views/_ViewImports.cshtml @@ -0,0 +1,6 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement +@addTagHelper *, OrchardCore.Contents.TagHelpers diff --git a/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI.csproj b/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI.csproj index 5c2f897b..2b5f8217 100644 --- a/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI.csproj +++ b/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI.csproj @@ -33,7 +33,7 @@
- + diff --git a/Lombiq.Hosting.Tenants.sln b/Lombiq.Hosting.Tenants.sln index 13e391d4..f5477f1e 100644 --- a/Lombiq.Hosting.Tenants.sln +++ b/Lombiq.Hosting.Tenants.sln @@ -31,6 +31,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.Hosting.Tenants.Emai EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI", "Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI\Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI.csproj", "{C8E20AF1-4D5D-464D-9BA8-AA237871C8DF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.Hosting.Tenants.Management.Tests.UI", "Lombiq.Hosting.Tenants.Management.Tests.UI\Lombiq.Hosting.Tenants.Management.Tests.UI.csproj", "{4BCB85E4-A12F-4AB0-B445-68D7AFE9722D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,6 +95,10 @@ Global {C8E20AF1-4D5D-464D-9BA8-AA237871C8DF}.Debug|Any CPU.Build.0 = Debug|Any CPU {C8E20AF1-4D5D-464D-9BA8-AA237871C8DF}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8E20AF1-4D5D-464D-9BA8-AA237871C8DF}.Release|Any CPU.Build.0 = Release|Any CPU + {4BCB85E4-A12F-4AB0-B445-68D7AFE9722D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BCB85E4-A12F-4AB0-B445-68D7AFE9722D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BCB85E4-A12F-4AB0-B445-68D7AFE9722D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BCB85E4-A12F-4AB0-B445-68D7AFE9722D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE