diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/RemoveSecuritySchemesDocumentFilter.cs b/src/Umbraco.Cms.Api.Common/OpenApi/RemoveSecuritySchemesDocumentFilter.cs
new file mode 100644
index 000000000000..8ab2041f7d46
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Common/OpenApi/RemoveSecuritySchemesDocumentFilter.cs
@@ -0,0 +1,25 @@
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Umbraco.Cms.Api.Common.OpenApi;
+
+///
+/// This filter explicitly removes all security schemes from a named OpenAPI document.
+///
+public class RemoveSecuritySchemesDocumentFilter : IDocumentFilter
+{
+ private readonly string _documentName;
+
+ public RemoveSecuritySchemesDocumentFilter(string documentName)
+ => _documentName = documentName;
+
+ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
+ {
+ if (context.DocumentName != _documentName)
+ {
+ return;
+ }
+
+ swaggerDoc.Components.SecuritySchemes.Clear();
+ }
+}
diff --git a/src/Umbraco.Cms.Api.Delivery/Caching/NoOutputCachePolicy.cs b/src/Umbraco.Cms.Api.Delivery/Caching/NoOutputCachePolicy.cs
new file mode 100644
index 000000000000..936cb72d2b9f
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Delivery/Caching/NoOutputCachePolicy.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.OutputCaching;
+
+namespace Umbraco.Cms.Api.Delivery.Caching;
+
+internal sealed class NoOutputCachePolicy : IOutputCachePolicy
+{
+ ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
+ {
+ context.EnableOutputCaching = false;
+
+ return ValueTask.CompletedTask;
+ }
+
+ ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
+ => ValueTask.CompletedTask;
+
+ ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
+ => ValueTask.CompletedTask;
+}
diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs
index 7fb301c87e84..9b87166300dc 100644
--- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs
+++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs
@@ -21,6 +21,7 @@ public void Configure(SwaggerGenOptions swaggerGenOptions)
});
swaggerGenOptions.DocumentFilter(DeliveryApiConfiguration.ApiName);
+ swaggerGenOptions.DocumentFilter(DeliveryApiConfiguration.ApiName);
swaggerGenOptions.OperationFilter();
swaggerGenOptions.OperationFilter();
diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs
index 11e8070f4cb6..1c821f9681fa 100644
--- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs
+++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs
@@ -17,33 +17,16 @@ namespace Umbraco.Cms.Api.Delivery.Configuration;
///
public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions : IConfigureOptions
{
- private const string AuthSchemeName = "Umbraco Member";
+ private const string AuthSchemeName = "UmbracoMember";
public void Configure(SwaggerGenOptions options)
{
- options.AddSecurityDefinition(
- AuthSchemeName,
- new OpenApiSecurityScheme
- {
- In = ParameterLocation.Header,
- Name = AuthSchemeName,
- Type = SecuritySchemeType.OAuth2,
- Description = "Umbraco Member Authentication",
- Flows = new OpenApiOAuthFlows
- {
- AuthorizationCode = new OpenApiOAuthFlow
- {
- AuthorizationUrl = new Uri(Paths.MemberApi.AuthorizationEndpoint, UriKind.Relative),
- TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative)
- }
- }
- });
-
// add security requirements for content API operations
+ options.DocumentFilter();
options.OperationFilter();
}
- private class DeliveryApiSecurityFilter : SwaggerFilterBase, IOperationFilter
+ private class DeliveryApiSecurityFilter : SwaggerFilterBase, IOperationFilter, IDocumentFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
@@ -70,5 +53,31 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context)
}
};
}
+
+ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
+ {
+ if (context.DocumentName != DeliveryApiConfiguration.ApiName)
+ {
+ return;
+ }
+
+ swaggerDoc.Components.SecuritySchemes.Add(
+ AuthSchemeName,
+ new OpenApiSecurityScheme
+ {
+ In = ParameterLocation.Header,
+ Name = AuthSchemeName,
+ Type = SecuritySchemeType.OAuth2,
+ Description = "Umbraco Member Authentication",
+ Flows = new OpenApiOAuthFlows
+ {
+ AuthorizationCode = new OpenApiOAuthFlow
+ {
+ AuthorizationUrl = new Uri(Paths.MemberApi.AuthorizationEndpoint, UriKind.Relative),
+ TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative)
+ }
+ }
+ });
+ }
}
}
diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs
index 34f8b058e64e..77f2cae93c3b 100644
--- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs
+++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs
@@ -105,7 +105,7 @@ private static IUmbracoBuilder AddOutputCache(this IUmbracoBuilder builder)
builder.Services.AddOutputCache(options =>
{
- options.AddBasePolicy(_ => { });
+ options.AddBasePolicy(build => build.AddPolicy());
if (outputCacheSettings.ContentDuration.TotalSeconds > 0)
{
diff --git a/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs
index 74376a3ed2c4..dfac63763cd0 100644
--- a/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs
+++ b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs
@@ -6,6 +6,7 @@ namespace Umbraco.Cms.Core.Configuration;
///
/// Typed configuration options for content dashboard settings.
///
+[Obsolete("Scheduled for removal in v16, dashboard manipulation is now done trough frontend extensions.")]
[UmbracoOptions(Constants.Configuration.ConfigContentDashboard)]
public class ContentDashboardSettings
{
diff --git a/src/Umbraco.Core/Models/Blocks/IBlockReference.cs b/src/Umbraco.Core/Models/Blocks/IBlockReference.cs
index 2505183efa6d..5ca0fd47886b 100644
--- a/src/Umbraco.Core/Models/Blocks/IBlockReference.cs
+++ b/src/Umbraco.Core/Models/Blocks/IBlockReference.cs
@@ -18,7 +18,13 @@ public interface IBlockReference
///
/// The content UDI.
///
+ [Obsolete("Use ContentKey instead. Will be removed in V18.")]
Udi ContentUdi { get; }
+
+ ///
+ /// Gets the content key.
+ ///
+ public Guid ContentKey { get; set; }
}
///
diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs
index 2a7e49dbda3d..c1df8c2f7775 100644
--- a/src/Umbraco.Core/Services/ContentEditingService.cs
+++ b/src/Umbraco.Core/Services/ContentEditingService.cs
@@ -1,6 +1,6 @@
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Umbraco.Cms.Core.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.Membership;
@@ -14,42 +14,13 @@ namespace Umbraco.Cms.Core.Services;
internal sealed class ContentEditingService
: ContentEditingServiceWithSortingBase, IContentEditingService
{
+ private readonly PropertyEditorCollection _propertyEditorCollection;
private readonly ITemplateService _templateService;
private readonly ILogger _logger;
private readonly IUserService _userService;
private readonly ILocalizationService _localizationService;
private readonly ILanguageService _languageService;
-
- [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 16.")]
- public ContentEditingService(
- IContentService contentService,
- IContentTypeService contentTypeService,
- PropertyEditorCollection propertyEditorCollection,
- IDataTypeService dataTypeService,
- ITemplateService templateService,
- ILogger logger,
- ICoreScopeProvider scopeProvider,
- IUserIdKeyResolver userIdKeyResolver,
- ITreeEntitySortingService treeEntitySortingService,
- IContentValidationService contentValidationService)
- : this(
- contentService,
- contentTypeService,
- propertyEditorCollection,
- dataTypeService,
- templateService,
- logger,
- scopeProvider,
- userIdKeyResolver,
- treeEntitySortingService,
- contentValidationService,
- StaticServiceProvider.Instance.GetRequiredService(),
- StaticServiceProvider.Instance.GetRequiredService(),
- StaticServiceProvider.Instance.GetRequiredService()
- )
- {
-
- }
+ private readonly ContentSettings _contentSettings;
public ContentEditingService(
IContentService contentService,
@@ -64,14 +35,17 @@ public ContentEditingService(
IContentValidationService contentValidationService,
IUserService userService,
ILocalizationService localizationService,
- ILanguageService languageService)
+ ILanguageService languageService,
+ IOptions contentSettings)
: base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider, userIdKeyResolver, contentValidationService, treeEntitySortingService)
{
+ _propertyEditorCollection = propertyEditorCollection;
_templateService = templateService;
_logger = logger;
_userService = userService;
_localizationService = localizationService;
_languageService = languageService;
+ _contentSettings = contentSettings.Value;
}
public async Task GetAsync(Guid key)
@@ -154,6 +128,8 @@ private async Task EnsureOnlyAllowedFieldsAreUpdated(IContent contentW
var allowedCultures = (await _languageService.GetIsoCodesByIdsAsync(allowedLanguageIds)).ToHashSet();
+ ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync();
+
foreach (var culture in contentWithPotentialUnallowedChanges.EditedCultures ?? contentWithPotentialUnallowedChanges.PublishedCultures)
{
if (allowedCultures.Contains(culture))
@@ -161,21 +137,44 @@ private async Task EnsureOnlyAllowedFieldsAreUpdated(IContent contentW
continue;
}
-
// else override the updates values with the original values.
foreach (IProperty property in contentWithPotentialUnallowedChanges.Properties)
{
- if (property.PropertyType.VariesByCulture() is false)
+ // if the property varies by culture, simply overwrite the edited property value with the current property value
+ if (property.PropertyType.VariesByCulture())
{
+ var currentValue = existingContent?.Properties.First(x => x.Alias == property.Alias).GetValue(culture, null, false);
+ property.SetValue(currentValue, culture, null);
continue;
}
- var value = existingContent?.Properties.First(x=>x.Alias == property.Alias).GetValue(culture, null, false);
- property.SetValue(value, culture, null);
+ // if the property does not vary by culture and the data editor supports variance within invariant property values,
+ // we need perform a merge between the edited property value and the current property value
+ if (_propertyEditorCollection.TryGet(property.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor) && dataEditor.CanMergePartialPropertyValues(property.PropertyType))
+ {
+ var currentValue = existingContent?.Properties.First(x => x.Alias == property.Alias).GetValue(null, null, false);
+ var editedValue = contentWithPotentialUnallowedChanges.Properties.First(x => x.Alias == property.Alias).GetValue(null, null, false);
+ var mergedValue = dataEditor.MergePartialPropertyValueForCulture(currentValue, editedValue, culture);
+
+ // If we are not allowed to edit invariant properties, overwrite the edited property value with the current property value.
+ if (_contentSettings.AllowEditInvariantFromNonDefault is false && culture == defaultLanguage?.IsoCode)
+ {
+ mergedValue = dataEditor.MergePartialPropertyValueForCulture(currentValue, mergedValue, null);
+ }
+
+ property.SetValue(mergedValue, null, null);
+ }
+
+ // If property does not support merging, we still need to overwrite if we are not allowed to edit invariant properties.
+ else if (_contentSettings.AllowEditInvariantFromNonDefault is false && culture == defaultLanguage?.IsoCode)
+ {
+ var currentValue = existingContent?.Properties.First(x => x.Alias == property.Alias).GetValue(null, null, false);
+ property.SetValue(currentValue, null, null);
+ }
}
}
- return contentWithPotentialUnallowedChanges;
+ return contentWithPotentialUnallowedChanges;
}
public async Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey)
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs
index 0f1fd3070ad9..bd8d702efac1 100644
--- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs
@@ -151,25 +151,16 @@ private bool Handle(IPropertyType[] propertyTypes, IDictionary l
var progress = 0;
- Parallel.ForEachAsync(updateBatch, async (update, token) =>
+ void HandleUpdateBatch(UpdateBatch update)
{
- //Foreach here, but we need to suppress the flow before each task, but not the actual await of the task
- Task task;
- using (ExecutionContext.SuppressFlow())
- {
- task = Task.Run(
- () =>
- {
- using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
- scope.Complete();
+
using UmbracoContextReference umbracoContextReference =
_umbracoContextFactory.EnsureUmbracoContext();
progress++;
if (progress % 100 == 0)
{
- _logger.LogInformation(" - finíshed {progress} of {total} properties", progress,
- updateBatch.Count);
+ _logger.LogInformation(" - finíshed {progress} of {total} properties", progress, updateBatch.Count);
}
PropertyDataDto propertyDataDto = update.Poco;
@@ -265,11 +256,37 @@ private bool Handle(IPropertyType[] propertyTypes, IDictionary l
stringValue = UpdateDatabaseValue(stringValue);
propertyDataDto.TextValue = stringValue;
- }, token);
- }
+ }
- await task;
- }).GetAwaiter().GetResult();
+ if (DatabaseType == DatabaseType.SQLite)
+ {
+ // SQLite locks up if we run the migration in parallel, so... let's not.
+ foreach (UpdateBatch update in updateBatch)
+ {
+ HandleUpdateBatch(update);
+ }
+ }
+ else
+ {
+ Parallel.ForEachAsync(updateBatch, async (update, token) =>
+ {
+ //Foreach here, but we need to suppress the flow before each task, but not the actuall await of the task
+ Task task;
+ using (ExecutionContext.SuppressFlow())
+ {
+ task = Task.Run(
+ () =>
+ {
+ using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
+ scope.Complete();
+ HandleUpdateBatch(update);
+ },
+ token);
+ }
+
+ await task;
+ }).GetAwaiter().GetResult();
+ }
updateBatch.RemoveAll(updatesToSkip.Contains);
diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client
index 2d60a9361a2f..29583d3d34f5 160000
--- a/src/Umbraco.Web.UI.Client
+++ b/src/Umbraco.Web.UI.Client
@@ -1 +1 @@
-Subproject commit 2d60a9361a2f22e9552382f2af0ade3de732d45d
+Subproject commit 29583d3d34f57e98052450128435fcb06a0c1984
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockEditorElementVariationTestBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockEditorElementVariationTestBase.cs
index 74e7ea77a9af..83bd01e0747d 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockEditorElementVariationTestBase.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockEditorElementVariationTestBase.cs
@@ -37,6 +37,8 @@ public abstract class BlockEditorElementVariationTestBase : UmbracoIntegrationTe
protected PropertyEditorCollection PropertyEditorCollection => GetRequiredService();
+ protected IContentEditingService ContentEditingService => GetRequiredService();
+
private IUmbracoContextAccessor UmbracoContextAccessor => GetRequiredService();
private IUmbracoContextFactory UmbracoContextFactory => GetRequiredService();
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs
new file mode 100644
index 000000000000..2cdadc5e4602
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs
@@ -0,0 +1,501 @@
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Models.Blocks;
+using Umbraco.Cms.Core.Models.ContentEditing;
+using Umbraco.Cms.Core.Models.Membership;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Tests.Common.Builders;
+using Umbraco.Cms.Tests.Common.Builders.Extensions;
+
+namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors;
+
+public partial class BlockListElementLevelVariationTests
+{
+ [TestCase(true)]
+ [TestCase(false)]
+ public async Task Can_Handle_Limited_User_Access_To_Languages_With_AllowEditInvariantFromNonDefault(bool updateWithLimitedUserAccess)
+ {
+ await LanguageService.CreateAsync(
+ new Language("de-DE", "German"), Constants.Security.SuperUserKey);
+ var userKey = updateWithLimitedUserAccess
+ ? (await CreateLimitedUser()).Key
+ : Constants.Security.SuperUserKey;
+
+ var elementType = CreateElementType(ContentVariation.Culture);
+ var blockListDataType = await CreateBlockListDataType(elementType);
+ var contentType = CreateContentType(ContentVariation.Culture, blockListDataType);
+ var content = CreateContent(contentType, elementType, [], false);
+ content.SetCultureName("Home (de)", "de-DE");
+ ContentService.Save(content);
+
+ var blockListValue = BlockListPropertyValue(
+ elementType,
+ [
+ (
+ Guid.NewGuid(),
+ Guid.NewGuid(),
+ new BlockProperty(
+ new List {
+ new() { Alias = "invariantText", Value = "#1: The first invariant content value" },
+ new() { Alias = "variantText", Value = "#1: The first content value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "#1: The first content value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "#1: The first content value in German", Culture = "de-DE" }
+ },
+ new List {
+ new() { Alias = "invariantText", Value = "#1: The first invariant settings value" },
+ new() { Alias = "variantText", Value = "#1: The first settings value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "#1: The first settings value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "#1: The first settings value in German", Culture = "de-DE" }
+ },
+ null,
+ null
+ )
+ ),
+ (
+ Guid.NewGuid(),
+ Guid.NewGuid(),
+ new BlockProperty(
+ new List {
+ new() { Alias = "invariantText", Value = "#2: The first invariant content value" },
+ new() { Alias = "variantText", Value = "#2: The first content value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "#2: The first content value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "#2: The first content value in German", Culture = "de-DE" }
+ },
+ new List {
+ new() { Alias = "invariantText", Value = "#2: The first invariant settings value" },
+ new() { Alias = "variantText", Value = "#2: The first settings value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "#2: The first settings value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "#2: The first settings value in German", Culture = "de-DE" }
+ },
+ null,
+ null
+ )
+ )
+ ]
+ );
+
+ content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
+ ContentService.Save(content);
+
+ blockListValue.ContentData[0].Values[0].Value = "#1: The second invariant content value";
+ blockListValue.ContentData[0].Values[1].Value = "#1: The second content value in English";
+ blockListValue.ContentData[0].Values[2].Value = "#1: The second content value in Danish";
+ blockListValue.ContentData[0].Values[3].Value = "#1: The second content value in German";
+ blockListValue.SettingsData[0].Values[0].Value = "#1: The second invariant settings value";
+ blockListValue.SettingsData[0].Values[1].Value = "#1: The second settings value in English";
+ blockListValue.SettingsData[0].Values[2].Value = "#1: The second settings value in Danish";
+ blockListValue.SettingsData[0].Values[3].Value = "#1: The second settings value in German";
+
+ blockListValue.ContentData[1].Values[0].Value = "#2: The second invariant content value";
+ blockListValue.ContentData[1].Values[1].Value = "#2: The second content value in English";
+ blockListValue.ContentData[1].Values[2].Value = "#2: The second content value in Danish";
+ blockListValue.ContentData[1].Values[3].Value = "#2: The second content value in German";
+ blockListValue.SettingsData[1].Values[0].Value = "#2: The second invariant settings value";
+ blockListValue.SettingsData[1].Values[1].Value = "#2: The second settings value in English";
+ blockListValue.SettingsData[1].Values[2].Value = "#2: The second settings value in Danish";
+ blockListValue.SettingsData[1].Values[3].Value = "#2: The second settings value in German";
+
+ var updateModel = new ContentUpdateModel
+ {
+ InvariantProperties = new[]
+ {
+ new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) }
+ },
+ Variants = new[]
+ {
+ new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] },
+ new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] },
+ new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] }
+ }
+ };
+
+ var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey);
+ Assert.IsTrue(result.Success);
+
+ content = ContentService.GetById(content.Key);
+ var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString();
+ Assert.NotNull(savedBlocksValue);
+ blockListValue = JsonSerializer.Deserialize(savedBlocksValue);
+
+ // the Danish and invariant values should be updated regardless of the executing user
+ Assert.Multiple(() =>
+ {
+ Assert.AreEqual("#1: The second invariant content value", blockListValue.ContentData[0].Values[0].Value);
+ Assert.AreEqual("#1: The second content value in Danish", blockListValue.ContentData[0].Values[2].Value);
+ Assert.AreEqual("#1: The second invariant settings value", blockListValue.SettingsData[0].Values[0].Value);
+ Assert.AreEqual("#1: The second settings value in Danish", blockListValue.SettingsData[0].Values[2].Value);
+
+ Assert.AreEqual("#2: The second invariant content value", blockListValue.ContentData[1].Values[0].Value);
+ Assert.AreEqual("#2: The second content value in Danish", blockListValue.ContentData[1].Values[2].Value);
+ Assert.AreEqual("#2: The second invariant settings value", blockListValue.SettingsData[1].Values[0].Value);
+ Assert.AreEqual("#2: The second settings value in Danish", blockListValue.SettingsData[1].Values[2].Value);
+ });
+
+ // limited user access means English and German should not have been updated - changes should be rolled back to the initial block values
+ if (updateWithLimitedUserAccess)
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.AreEqual("#1: The first content value in English", blockListValue.ContentData[0].Values[1].Value);
+ Assert.AreEqual("#1: The first settings value in English", blockListValue.SettingsData[0].Values[1].Value);
+ Assert.AreEqual("#1: The first content value in German", blockListValue.ContentData[0].Values[3].Value);
+ Assert.AreEqual("#1: The first settings value in German", blockListValue.SettingsData[0].Values[3].Value);
+
+ Assert.AreEqual("#2: The first content value in English", blockListValue.ContentData[1].Values[1].Value);
+ Assert.AreEqual("#2: The first settings value in English", blockListValue.SettingsData[1].Values[1].Value);
+ Assert.AreEqual("#2: The first content value in German", blockListValue.ContentData[1].Values[3].Value);
+ Assert.AreEqual("#2: The first settings value in German", blockListValue.SettingsData[1].Values[3].Value);
+ });
+ }
+ else
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.AreEqual("#1: The second content value in English", blockListValue.ContentData[0].Values[1].Value);
+ Assert.AreEqual("#1: The second settings value in English", blockListValue.SettingsData[0].Values[1].Value);
+ Assert.AreEqual("#1: The second content value in German", blockListValue.ContentData[0].Values[3].Value);
+ Assert.AreEqual("#1: The second settings value in German", blockListValue.SettingsData[0].Values[3].Value);
+
+ Assert.AreEqual("#2: The second content value in English", blockListValue.ContentData[1].Values[1].Value);
+ Assert.AreEqual("#2: The second settings value in English", blockListValue.SettingsData[1].Values[1].Value);
+ Assert.AreEqual("#2: The second content value in German", blockListValue.ContentData[1].Values[3].Value);
+ Assert.AreEqual("#2: The second settings value in German", blockListValue.SettingsData[1].Values[3].Value);
+ });
+ }
+ }
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public async Task Can_Handle_Limited_User_Access_To_Languages_Without_AllowEditInvariantFromNonDefault(bool updateWithLimitedUserAccess)
+ {
+ await LanguageService.CreateAsync(
+ new Language("de-DE", "German"), Constants.Security.SuperUserKey);
+ var userKey = updateWithLimitedUserAccess
+ ? (await CreateLimitedUser()).Key
+ : Constants.Security.SuperUserKey;
+
+ var elementType = CreateElementType(ContentVariation.Culture);
+ var blockListDataType = await CreateBlockListDataType(elementType);
+ var contentType = CreateContentType(ContentVariation.Culture, blockListDataType);
+ var content = CreateContent(contentType, elementType, [], false);
+ content.SetCultureName("Home (de)", "de-DE");
+ ContentService.Save(content);
+
+ var blockListValue = BlockListPropertyValue(
+ elementType,
+ [
+ (
+ Guid.NewGuid(),
+ Guid.NewGuid(),
+ new BlockProperty(
+ new List {
+ new() { Alias = "invariantText", Value = "#1: The first invariant content value" },
+ new() { Alias = "variantText", Value = "#1: The first content value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "#1: The first content value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "#1: The first content value in German", Culture = "de-DE" }
+ },
+ new List {
+ new() { Alias = "invariantText", Value = "#1: The first invariant settings value" },
+ new() { Alias = "variantText", Value = "#1: The first settings value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "#1: The first settings value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "#1: The first settings value in German", Culture = "de-DE" }
+ },
+ null,
+ null
+ )
+ ),
+ (
+ Guid.NewGuid(),
+ Guid.NewGuid(),
+ new BlockProperty(
+ new List {
+ new() { Alias = "invariantText", Value = "#2: The first invariant content value" },
+ new() { Alias = "variantText", Value = "#2: The first content value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "#2: The first content value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "#2: The first content value in German", Culture = "de-DE" }
+ },
+ new List {
+ new() { Alias = "invariantText", Value = "#2: The first invariant settings value" },
+ new() { Alias = "variantText", Value = "#2: The first settings value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "#2: The first settings value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "#2: The first settings value in German", Culture = "de-DE" }
+ },
+ null,
+ null
+ )
+ )
+ ]
+ );
+
+ content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
+ ContentService.Save(content);
+
+ blockListValue.ContentData[0].Values[0].Value = "#1: The second invariant content value";
+ blockListValue.ContentData[0].Values[1].Value = "#1: The second content value in English";
+ blockListValue.ContentData[0].Values[2].Value = "#1: The second content value in Danish";
+ blockListValue.ContentData[0].Values[3].Value = "#1: The second content value in German";
+ blockListValue.SettingsData[0].Values[0].Value = "#1: The second invariant settings value";
+ blockListValue.SettingsData[0].Values[1].Value = "#1: The second settings value in English";
+ blockListValue.SettingsData[0].Values[2].Value = "#1: The second settings value in Danish";
+ blockListValue.SettingsData[0].Values[3].Value = "#1: The second settings value in German";
+
+ blockListValue.ContentData[1].Values[0].Value = "#2: The second invariant content value";
+ blockListValue.ContentData[1].Values[1].Value = "#2: The second content value in English";
+ blockListValue.ContentData[1].Values[2].Value = "#2: The second content value in Danish";
+ blockListValue.ContentData[1].Values[3].Value = "#2: The second content value in German";
+ blockListValue.SettingsData[1].Values[0].Value = "#2: The second invariant settings value";
+ blockListValue.SettingsData[1].Values[1].Value = "#2: The second settings value in English";
+ blockListValue.SettingsData[1].Values[2].Value = "#2: The second settings value in Danish";
+ blockListValue.SettingsData[1].Values[3].Value = "#2: The second settings value in German";
+
+ var updateModel = new ContentUpdateModel
+ {
+ InvariantProperties = new[]
+ {
+ new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) }
+ },
+ Variants = new[]
+ {
+ new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] },
+ new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] },
+ new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] }
+ }
+ };
+
+ var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey);
+ Assert.IsTrue(result.Success);
+
+ content = ContentService.GetById(content.Key);
+ var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString();
+ Assert.NotNull(savedBlocksValue);
+ blockListValue = JsonSerializer.Deserialize(savedBlocksValue);
+
+ // the Danish values should be updated regardless of the executing user
+ Assert.Multiple(() =>
+ {
+ Assert.AreEqual("#1: The second content value in Danish", blockListValue.ContentData[0].Values[2].Value);
+ Assert.AreEqual("#1: The second settings value in Danish", blockListValue.SettingsData[0].Values[2].Value);
+
+ Assert.AreEqual("#2: The second content value in Danish", blockListValue.ContentData[1].Values[2].Value);
+ Assert.AreEqual("#2: The second settings value in Danish", blockListValue.SettingsData[1].Values[2].Value);
+ });
+
+ // limited user access means invariant, English and German should not have been updated - changes should be rolled back to the initial block values
+ if (updateWithLimitedUserAccess)
+ {
+ Assert.Multiple(() =>
+ {
+
+ Assert.AreEqual("#1: The first invariant content value", blockListValue.ContentData[0].Values[0].Value);
+ Assert.AreEqual("#1: The first invariant settings value", blockListValue.SettingsData[0].Values[0].Value);
+ Assert.AreEqual("#1: The first content value in English", blockListValue.ContentData[0].Values[1].Value);
+ Assert.AreEqual("#1: The first settings value in English", blockListValue.SettingsData[0].Values[1].Value);
+ Assert.AreEqual("#1: The first content value in German", blockListValue.ContentData[0].Values[3].Value);
+ Assert.AreEqual("#1: The first settings value in German", blockListValue.SettingsData[0].Values[3].Value);
+
+ Assert.AreEqual("#2: The first invariant content value", blockListValue.ContentData[1].Values[0].Value);
+ Assert.AreEqual("#2: The first invariant settings value", blockListValue.SettingsData[1].Values[0].Value);
+ Assert.AreEqual("#2: The first content value in English", blockListValue.ContentData[1].Values[1].Value);
+ Assert.AreEqual("#2: The first settings value in English", blockListValue.SettingsData[1].Values[1].Value);
+ Assert.AreEqual("#2: The first content value in German", blockListValue.ContentData[1].Values[3].Value);
+ Assert.AreEqual("#2: The first settings value in German", blockListValue.SettingsData[1].Values[3].Value);
+ });
+ }
+ else
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.AreEqual("#1: The second invariant content value", blockListValue.ContentData[0].Values[0].Value);
+ Assert.AreEqual("#1: The second invariant settings value", blockListValue.SettingsData[0].Values[0].Value);
+ Assert.AreEqual("#1: The second content value in English", blockListValue.ContentData[0].Values[1].Value);
+ Assert.AreEqual("#1: The second settings value in English", blockListValue.SettingsData[0].Values[1].Value);
+ Assert.AreEqual("#1: The second content value in German", blockListValue.ContentData[0].Values[3].Value);
+ Assert.AreEqual("#1: The second settings value in German", blockListValue.SettingsData[0].Values[3].Value);
+
+ Assert.AreEqual("#2: The second invariant content value", blockListValue.ContentData[1].Values[0].Value);
+ Assert.AreEqual("#2: The second invariant settings value", blockListValue.SettingsData[1].Values[0].Value);
+ Assert.AreEqual("#2: The second content value in English", blockListValue.ContentData[1].Values[1].Value);
+ Assert.AreEqual("#2: The second settings value in English", blockListValue.SettingsData[1].Values[1].Value);
+ Assert.AreEqual("#2: The second content value in German", blockListValue.ContentData[1].Values[3].Value);
+ Assert.AreEqual("#2: The second settings value in German", blockListValue.SettingsData[1].Values[3].Value);
+ });
+ }
+ }
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public async Task Can_Handle_Limited_User_Access_To_Languages_In_Nested_Blocks_Without_Access_With_AllowEditInvariantFromNonDefault(bool updateWithLimitedUserAccess)
+ {
+ await LanguageService.CreateAsync(
+ new Language("de-DE", "German"), Constants.Security.SuperUserKey);
+ var userKey = updateWithLimitedUserAccess
+ ? (await CreateLimitedUser()).Key
+ : Constants.Security.SuperUserKey;
+ var nestedElementType = CreateElementType(ContentVariation.Culture);
+ var nestedBlockListDataType = await CreateBlockListDataType(nestedElementType);
+
+ var rootElementType = new ContentTypeBuilder()
+ .WithAlias("myRootElementType")
+ .WithName("My Root Element Type")
+ .WithIsElement(true)
+ .WithContentVariation(ContentVariation.Culture)
+ .AddPropertyType()
+ .WithAlias("nestedBlocks")
+ .WithName("Nested blocks")
+ .WithDataTypeId(nestedBlockListDataType.Id)
+ .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.BlockList)
+ .WithValueStorageType(ValueStorageType.Ntext)
+ .WithVariations(ContentVariation.Nothing)
+ .Done()
+ .Build();
+ ContentTypeService.Save(rootElementType);
+ var rootBlockListDataType = await CreateBlockListDataType(rootElementType);
+ var contentType = CreateContentType(ContentVariation.Culture, rootBlockListDataType);
+
+ var nestedElementContentKey = Guid.NewGuid();
+ var nestedElementSettingsKey = Guid.NewGuid();
+ var content = CreateContent(
+ contentType,
+ rootElementType,
+ new List
+ {
+ new()
+ {
+ Alias = "nestedBlocks",
+ Value = BlockListPropertyValue(
+ nestedElementType,
+ nestedElementContentKey,
+ nestedElementSettingsKey,
+ new BlockProperty(
+ new List
+ {
+ new() { Alias = "invariantText", Value = "The first nested invariant content value" },
+ new() { Alias = "variantText", Value = "The first nested content value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "The first nested content value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "The first nested content value in German", Culture = "de-DE" },
+ },
+ new List
+ {
+ new() { Alias = "invariantText", Value = "The first nested invariant settings value" },
+ new() { Alias = "variantText", Value = "The first nested settings value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "The first nested settings value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "The first nested settings value in German", Culture = "de-DE" },
+ },
+ null,
+ null))
+ }
+ },
+ [],
+ false);
+ content.SetCultureName("Home (de)", "de-DE");
+ ContentService.Save(content);
+
+ var blockListValue = JsonSerializer.Deserialize((string)content.Properties["blocks"]!.GetValue()!);
+ blockListValue.ContentData[0].Values[0].Value = BlockListPropertyValue(
+ nestedElementType,
+ nestedElementContentKey,
+ nestedElementSettingsKey,
+ new BlockProperty(
+ new List
+ {
+ new() { Alias = "invariantText", Value = "The second nested invariant content value" },
+ new() { Alias = "variantText", Value = "The second nested content value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "The second nested content value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "The second nested content value in German", Culture = "de-DE" },
+ },
+ new List
+ {
+ new() { Alias = "invariantText", Value = "The second nested invariant settings value" },
+ new() { Alias = "variantText", Value = "The second nested settings value in English", Culture = "en-US" },
+ new() { Alias = "variantText", Value = "The second nested settings value in Danish", Culture = "da-DK" },
+ new() { Alias = "variantText", Value = "The second nested settings value in German", Culture = "de-DE" },
+ },
+ null,
+ null));
+
+ var updateModel = new ContentUpdateModel
+ {
+ InvariantProperties = new[]
+ {
+ new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) }
+ },
+ Variants = new[]
+ {
+ new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] },
+ new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] },
+ new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] }
+ }
+ };
+
+ var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey);
+ Assert.IsTrue(result.Success);
+
+ content = ContentService.GetById(content.Key);
+ var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString();
+ Assert.NotNull(savedBlocksValue);
+ blockListValue = JsonSerializer.Deserialize(savedBlocksValue);
+
+ var nestedBlocksPropertyValue = blockListValue.ContentData
+ .FirstOrDefault()?.Values
+ .FirstOrDefault(v => v.Alias == "nestedBlocks")?.Value?.ToString();
+ Assert.IsNotNull(nestedBlocksPropertyValue);
+ var nestedBlockListValue = JsonSerializer.Deserialize(nestedBlocksPropertyValue);
+
+
+ // the Danish and invariant values should be updated regardless of the executing user
+ Assert.Multiple(() =>
+ {
+ Assert.AreEqual("The second nested invariant content value", nestedBlockListValue.ContentData[0].Values[0].Value);
+ Assert.AreEqual("The second nested content value in Danish", nestedBlockListValue.ContentData[0].Values[2].Value);
+
+ Assert.AreEqual("The second nested invariant settings value", nestedBlockListValue.SettingsData[0].Values[0].Value);
+ Assert.AreEqual("The second nested settings value in Danish", nestedBlockListValue.SettingsData[0].Values[2].Value);
+ });
+
+ // limited user access means English and German should not have been updated - changes should be rolled back to the initial block values
+ if (updateWithLimitedUserAccess)
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.AreEqual("The first nested content value in English", nestedBlockListValue.ContentData[0].Values[1].Value);
+ Assert.AreEqual("The first nested content value in German", nestedBlockListValue.ContentData[0].Values[3].Value);
+
+ Assert.AreEqual("The first nested settings value in English", nestedBlockListValue.SettingsData[0].Values[1].Value);
+ Assert.AreEqual("The first nested settings value in German", nestedBlockListValue.SettingsData[0].Values[3].Value);
+ });
+ }
+ else
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.AreEqual("The second nested content value in English", nestedBlockListValue.ContentData[0].Values[1].Value);
+ Assert.AreEqual("The second nested content value in German", nestedBlockListValue.ContentData[0].Values[3].Value);
+
+ Assert.AreEqual("The second nested settings value in English", nestedBlockListValue.SettingsData[0].Values[1].Value);
+ Assert.AreEqual("The second nested settings value in German", nestedBlockListValue.SettingsData[0].Values[3].Value);
+ });
+ }
+ }
+
+ private async Task CreateLimitedUser()
+ {
+ var userGroupService = GetRequiredService();
+ var userService = GetRequiredService();
+
+ var danish = await LanguageService.GetAsync("da-DK");
+ Assert.IsNotNull(danish);
+
+ var user = UserBuilder.CreateUser();
+ userService.Save(user);
+
+ var group = UserGroupBuilder.CreateUserGroup();
+ group.ClearAllowedLanguages();
+ group.AddAllowedLanguage(danish.Id);
+
+ var userGroupResult = await userGroupService.CreateAsync(group, Constants.Security.SuperUserKey, [user.Key]);
+ Assert.IsTrue(userGroupResult.Success);
+
+ return user;
+ }
+}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.cs
index 53af4af967b7..14fb9c5c72ef 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.cs
@@ -16,6 +16,12 @@ public partial class BlockListElementLevelVariationTests : BlockEditorElementVar
public void OneTimeSetUp()
{
TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Publish_Invariant_Properties_Without_Default_Culture_With_AllowEditInvariantFromNonDefault));
+ TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_With_AllowEditInvariantFromNonDefault));
+ TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_In_Nested_Blocks_Without_Access_With_AllowEditInvariantFromNonDefault));
+ TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_With_AllowEditInvariantFromNonDefault) + "(True)");
+ TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_With_AllowEditInvariantFromNonDefault) + "(False)");
+ TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_In_Nested_Blocks_Without_Access_With_AllowEditInvariantFromNonDefault) + "(True)");
+ TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_In_Nested_Blocks_Without_Access_With_AllowEditInvariantFromNonDefault) + "(False)");
}
private IJsonSerializer JsonSerializer => GetRequiredService();
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj
index d17424352236..6c4eafafb690 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj
@@ -167,6 +167,9 @@
BlockListElementLevelVariationTests.cs
+
+ BlockListElementLevelVariationTests.cs
+
BlockListElementLevelVariationTests.cs
diff --git a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs
index 4cc94e8e03f5..ae54bd51dad4 100644
--- a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs
+++ b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs
@@ -67,6 +67,7 @@ public class UmbracoCmsDefinition
public required LegacyPasswordMigrationSettings LegacyPasswordMigration { get; set; }
+ [Obsolete("Scheduled for removal in v16, dashboard manipulation is now done trough frontend extensions.")]
public required ContentDashboardSettings ContentDashboard { get; set; }
public required HelpPageSettings HelpPage { get; set; }