Skip to content

Commit

Permalink
User Localization settings (OrchardCMS#13181)
Browse files Browse the repository at this point in the history
  • Loading branch information
Skrypt authored and urbanit committed Mar 18, 2024
1 parent 2eea967 commit 4fe3f96
Show file tree
Hide file tree
Showing 13 changed files with 227 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -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<User, UserLocalizationSettings>
{
private readonly ILocalizationService _localizationService;
protected readonly IStringLocalizer S;

public UserLocalizationDisplayDriver(
ILocalizationService localizationService,
IStringLocalizer<UserLocalizationDisplayDriver> localizer)
{
_localizationService = localizationService;
S = localizer;
}

public override Task<IDisplayResult> EditAsync(UserLocalizationSettings section, BuildEditorContext context)
{
return Task.FromResult<IDisplayResult>(Initialize<UserLocalizationViewModel>("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<IDisplayResult> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace OrchardCore.Users.Localization.Models;

/// <summary>
/// Provides a model for the IEntity property.
/// </summary>
public class UserLocalizationSettings
{
public string Culture { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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<UserLocalizationSettings>())
{
var localizationSetting = currentUser.As<UserLocalizationSettings>();

if (localizationSetting.Culture != "none")
{
claims.AddClaim(new Claim(CultureClaimType, localizationSetting.Culture == UserLocalizationConstants.Invariant ? "" : localizationSetting.Culture));
}
}

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -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<ProviderCultureResult> 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));
}
}
23 changes: 23 additions & 0 deletions src/OrchardCore.Modules/OrchardCore.Users/Localization/Startup.cs
Original file line number Diff line number Diff line change
@@ -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<IDisplayDriver<User>, UserLocalizationDisplayDriver>();
services.AddScoped<IUserClaimsProvider, UserLocalizationClaimsProvider>();

services.Configure<RequestLocalizationOptions>(options =>
options.AddInitialRequestCultureProvider(new UserLocalizationRequestCultureProvider()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace OrchardCore.Users.Localization
{
public static class UserLocalizationConstants
{
public const string Invariant = "invariant";
}
}
Original file line number Diff line number Diff line change
@@ -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<SelectListItem> CultureList { get; set; } = [];
}
9 changes: 9 additions & 0 deletions src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@using OrchardCore.Users.Localization.ViewModels
@model UserLocalizationViewModel

<div class="mb-3" asp-validation-class-for="SelectedCulture">
<label asp-for="SelectedCulture">@T["Default User Culture"]</label>
<select asp-for="SelectedCulture" asp-items="Model.CultureList" class="form-select">
</select>
<span asp-validation-for="SelectedCulture"></span>
<span class="hint">@T["Determines the default culture used by this user."]</span>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ private static void AddAuthentication(OrchardCoreBuilder builder)
.Configure(app =>
{
app.UseAuthentication();
});
}, order: -150);
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions src/docs/reference/modules/Users/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/docs/releases/1.9.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

0 comments on commit 4fe3f96

Please sign in to comment.