diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Localization/Drivers/UserLocalizationDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Drivers/UserLocalizationDisplayDriver.cs new file mode 100644 index 00000000000..3f849bdf7cc --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Drivers/UserLocalizationDisplayDriver.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Localization; +using OrchardCore.Users.Localization.Models; +using OrchardCore.Users.Localization.ViewModels; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Localization.Drivers; + +public class UserLocalizationDisplayDriver : SectionDisplayDriver +{ + private readonly ILocalizationService _localizationService; + protected readonly IStringLocalizer S; + + public UserLocalizationDisplayDriver( + ILocalizationService localizationService, + IStringLocalizer localizer) + { + _localizationService = localizationService; + S = localizer; + } + + public override Task EditAsync(UserLocalizationSettings section, BuildEditorContext context) + { + return Task.FromResult(Initialize("UserCulture_Edit", async model => + { + var supportedCultures = await _localizationService.GetSupportedCulturesAsync(); + + var cultureList = supportedCultures.Select(culture => + new SelectListItem + { + Text = CultureInfo.GetCultureInfo(culture).DisplayName + " (" + culture + ")", + Value = culture + }).ToList(); + + cultureList.Insert(0, new SelectListItem() { Text = S["Use site's culture"], Value = "none" }); + + // If Invariant Culture is installed as a supported culture we bind it to a different culture code than String.Empty. + var emptyCulture = cultureList.FirstOrDefault(c => c.Value == ""); + if (emptyCulture != null) + { + emptyCulture.Value = UserLocalizationConstants.Invariant; + } + + model.SelectedCulture = section.Culture; + model.CultureList = cultureList; + }).Location("Content:2")); + } + + public override async Task UpdateAsync(User model, UserLocalizationSettings section, IUpdateModel updater, BuildEditorContext context) + { + var viewModel = new UserLocalizationViewModel(); + + if (await context.Updater.TryUpdateModelAsync(viewModel, Prefix)) + { + section.Culture = viewModel.SelectedCulture; + } + + return await EditAsync(section, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Localization/Extensions/ClaimsPrincipleExtensions.cs b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Extensions/ClaimsPrincipleExtensions.cs new file mode 100644 index 00000000000..0282cf17e44 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Extensions/ClaimsPrincipleExtensions.cs @@ -0,0 +1,9 @@ +using OrchardCore.Users.Localization.Providers; + +namespace System.Security.Claims; + +public static class ClaimsPrincipleExtensions +{ + public static string GetCulture(this ClaimsPrincipal principal) + => principal.FindFirstValue(UserLocalizationClaimsProvider.CultureClaimType); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Localization/Models/UserLocalizationSettings.cs b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Models/UserLocalizationSettings.cs new file mode 100644 index 00000000000..6856c3fa386 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Models/UserLocalizationSettings.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.Users.Localization.Models; + +/// +/// Provides a model for the IEntity property. +/// +public class UserLocalizationSettings +{ + public string Culture { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Localization/Providers/UserLocalizationClaimsProvider.cs b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Providers/UserLocalizationClaimsProvider.cs new file mode 100644 index 00000000000..c98bc4f3649 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Providers/UserLocalizationClaimsProvider.cs @@ -0,0 +1,37 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using OrchardCore.Entities; +using OrchardCore.Users.Localization.Models; +using OrchardCore.Users.Models; +using OrchardCore.Users.Services; + +namespace OrchardCore.Users.Localization.Providers; + +public class UserLocalizationClaimsProvider : IUserClaimsProvider +{ + internal const string CultureClaimType = "culture"; + + public Task GenerateAsync(IUser user, ClaimsIdentity claims) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(claims); + + if (user is not User currentUser) + { + return Task.CompletedTask; + } + + if (currentUser.Has()) + { + var localizationSetting = currentUser.As(); + + if (localizationSetting.Culture != "none") + { + claims.AddClaim(new Claim(CultureClaimType, localizationSetting.Culture == UserLocalizationConstants.Invariant ? "" : localizationSetting.Culture)); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Localization/Providers/UserLocalizationRequestCultureProvider.cs b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Providers/UserLocalizationRequestCultureProvider.cs new file mode 100644 index 00000000000..d57fae3f923 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Providers/UserLocalizationRequestCultureProvider.cs @@ -0,0 +1,29 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; + +namespace OrchardCore.Users.Localization.Providers; + +public class UserLocalizationRequestCultureProvider : RequestCultureProvider +{ + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + if (httpContext?.User?.Identity?.IsAuthenticated == false) + { + return NullProviderCultureResult; + } + + var userCulture = httpContext.User.GetCulture(); // String.Empty here means that it did not find the Culture Claim. + + if (String.IsNullOrWhiteSpace(userCulture)) + { + return NullProviderCultureResult; + } + + return Task.FromResult(new ProviderCultureResult(userCulture == UserLocalizationConstants.Invariant ? "" : userCulture)); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Localization/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Startup.cs new file mode 100644 index 00000000000..c04a2a09d0d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Localization/Startup.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using OrchardCore.Users.Localization.Drivers; +using OrchardCore.Users.Localization.Providers; +using OrchardCore.Users.Models; +using OrchardCore.Users.Services; + +namespace OrchardCore.Users.Localization; + +[Feature("OrchardCore.Users.Localization")] +public class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped, UserLocalizationDisplayDriver>(); + services.AddScoped(); + + services.Configure(options => + options.AddInitialRequestCultureProvider(new UserLocalizationRequestCultureProvider())); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Localization/UserLocalizationConstants.cs b/src/OrchardCore.Modules/OrchardCore.Users/Localization/UserLocalizationConstants.cs new file mode 100644 index 00000000000..611c89cabf7 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Localization/UserLocalizationConstants.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Users.Localization +{ + public static class UserLocalizationConstants + { + public const string Invariant = "invariant"; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Localization/ViewModel/UserLocalizationViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/Localization/ViewModel/UserLocalizationViewModel.cs new file mode 100644 index 00000000000..4641d53d1dc --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Localization/ViewModel/UserLocalizationViewModel.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.Users.Localization.ViewModels; + +public class UserLocalizationViewModel +{ + public string SelectedCulture { get; set; } + + [BindNever] + public List CultureList { get; set; } = []; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs index 1c71c0c3dd3..331b37e6455 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs @@ -56,6 +56,15 @@ Category = "Settings" )] +[assembly: Feature( + Id = "OrchardCore.Users.Localization", + Name = "User Localization", + Description = "Provides a way to set the culture per user.", + Dependencies = new[] { "OrchardCore.Users", "OrchardCore.Localization" }, + Category = "Settings", + Priority = "-1" // Added to avoid changing the order in the localization module. +)] + [assembly: Feature( Id = "OrchardCore.Users.CustomUserSettings", Name = "Custom User Settings", diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/UserCulture.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/UserCulture.Edit.cshtml new file mode 100644 index 00000000000..e5c358bd9d2 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/UserCulture.Edit.cshtml @@ -0,0 +1,10 @@ +@using OrchardCore.Users.Localization.ViewModels +@model UserLocalizationViewModel + +
+ + + + @T["Determines the default culture used by this user."] +
diff --git a/src/OrchardCore/OrchardCore/Modules/Extensions/ServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore/Modules/Extensions/ServiceCollectionExtensions.cs index 1bad54deb11..27d408d61ed 100644 --- a/src/OrchardCore/OrchardCore/Modules/Extensions/ServiceCollectionExtensions.cs +++ b/src/OrchardCore/OrchardCore/Modules/Extensions/ServiceCollectionExtensions.cs @@ -517,7 +517,7 @@ private static void AddAuthentication(OrchardCoreBuilder builder) .Configure(app => { app.UseAuthentication(); - }); + }, order: -150); } /// diff --git a/src/docs/reference/modules/Users/README.md b/src/docs/reference/modules/Users/README.md index 14e223d3961..05de03d4dc1 100644 --- a/src/docs/reference/modules/Users/README.md +++ b/src/docs/reference/modules/Users/README.md @@ -15,11 +15,18 @@ The module contains the following features apart from the base feature: - Two-Factor Authentication Services: Provices Two-factor core services. This feature cannot be manually enabled or disable as it is enabled by dependency on demand. - Two-Factor Email Method: Allows users to two-factor authenticate using an email. - Two-Factor Authenticator App Method: Allows users to two-factor authenticate using any Authenticator App. +- User Localization: Allows ability to configure user culture per user from admin UI. ## Two-factor Authentication Starting with version 1.7, OrchardCore is shipped with everything you need to secure your app with two-factor authentication. To use two-factor authentication, simply enable "Two-Factor Email Method" and/or "Two-Factor Authenticator App Method" features. You can configure the process based on your need by navigating to `Security` >> `Settings` >> `User Login`. Click on the "Two-Factor Authentication" tab and update the settings as needed. +## User Localization + +The feature adds the ability to configure the culture per user from the admin UI. + +This feature adds a `RequestCultureProvider` to retrieve the current user culture from its claims. This feature will set a new user claim with a `CultureClaimType` named "culture". It also has a culture option to fall back to other ASP.NET Request Culture Providers by simply setting the user culture to "Use site's culture" which will also be the selected default value. + ## Custom Paths If you want to specify custom paths to access the authentication related urls, you can change them by using this option in the appsettings.json: diff --git a/src/docs/releases/1.9.0.md b/src/docs/releases/1.9.0.md index e7df9b464d8..284931a23ce 100644 --- a/src/docs/releases/1.9.0.md +++ b/src/docs/releases/1.9.0.md @@ -171,3 +171,8 @@ public class PersonController : Controller ``` In this example, (if the admin prefix remains the default "Admin") you can reach the Index action at `~/Admin/Person` (or by the route name `Person`), because its own action-level attribute took precedence. You can reach Create at `~/Admin/Person/Create` (route name `PersonCreate`) and Edit for the person whose identifier string is "john-doe" at `~/Admin/Person/john-doe` (route name `PersonEdit`). + +## Users Module + +Added a new User Localization feature that allows to be able to configure the culture per user from the admin UI. +