diff --git a/e-suite.API/eSuite.API.UnitTests/Controllers/AccountControllerUnitTests/AccountControllerTestBase.cs b/e-suite.API/eSuite.API.UnitTests/Controllers/AccountControllerUnitTests/AccountControllerTestBase.cs index 5c40e72..638aa0e 100644 --- a/e-suite.API/eSuite.API.UnitTests/Controllers/AccountControllerUnitTests/AccountControllerTestBase.cs +++ b/e-suite.API/eSuite.API.UnitTests/Controllers/AccountControllerUnitTests/AccountControllerTestBase.cs @@ -4,6 +4,7 @@ using e_suite.Service.Sentinel; using e_suite.UnitTestCore; using eSuite.API.Controllers; using eSuite.API.SingleSignOn; +using eSuite.API.Translation; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -17,6 +18,8 @@ public abstract class AccountControllerTestBase : TestBase protected Mock _sentinelMock = null!; protected Mock _singleSignOnMock = null!; protected Mock _cookieManagerMock = null!; + protected Mock _translatorFactoryMock = null!; + protected Mock _translatorMock = null!; public override async Task Setup() { @@ -27,7 +30,11 @@ public abstract class AccountControllerTestBase : TestBase _singleSignOnMock = new Mock(); _cookieManagerMock = new Mock(); - _accountController = new AccountController(_userManagerMock.Object,_sentinelMock.Object, _singleSignOnMock.Object, _cookieManagerMock.Object); + _translatorMock = new Mock(); + _translatorFactoryMock = new Mock(); + _translatorFactoryMock.Setup(x => x.Use(It.IsAny())).ReturnsAsync(_translatorMock.Object); + + _accountController = new AccountController(_userManagerMock.Object,_sentinelMock.Object, _singleSignOnMock.Object, _cookieManagerMock.Object, _translatorFactoryMock.Object); } protected void AddAuthorisedUserToController(long id, string email, string displayName) diff --git a/e-suite.API/eSuite.API.UnitTests/Controllers/AccountControllerUnitTests/ProfileGetUnitTests.cs b/e-suite.API/eSuite.API.UnitTests/Controllers/AccountControllerUnitTests/ProfileGetUnitTests.cs index 30dc949..b444338 100644 --- a/e-suite.API/eSuite.API.UnitTests/Controllers/AccountControllerUnitTests/ProfileGetUnitTests.cs +++ b/e-suite.API/eSuite.API.UnitTests/Controllers/AccountControllerUnitTests/ProfileGetUnitTests.cs @@ -1,4 +1,5 @@ using e_suite.API.Common.models; +using eSuite.API.Controllers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -45,9 +46,13 @@ public class ProfileGetUnitTests : AccountControllerTestBase Assert.That(response, Is.TypeOf()); var viewResult = response as PartialViewResult; Assert.That(viewResult?.ViewName, Is.EqualTo("Profile")); - Assert.That(viewResult?.Model, Is.TypeOf()); - var actualProfile = viewResult?.Model as Models.UserProfile; + Assert.That(viewResult?.Model, Is.TypeOf()); + var profileViewModel = viewResult!.Model as ProfileViewModel; + + Assert.That(profileViewModel.Profile, Is.TypeOf()); + + var actualProfile = profileViewModel.Profile; Assert.That(actualProfile, Is.Not.Null); Assert.That(actualProfile?.Email, Is.EqualTo(userProfile.Email)); Assert.That(actualProfile?.FirstName, Is.EqualTo(userProfile.FirstName)); diff --git a/e-suite.API/eSuite.API/Controllers/AccountController.cs b/e-suite.API/eSuite.API/Controllers/AccountController.cs index 11c0586..f9ba3c0 100644 --- a/e-suite.API/eSuite.API/Controllers/AccountController.cs +++ b/e-suite.API/eSuite.API/Controllers/AccountController.cs @@ -8,6 +8,7 @@ using e_suite.Service.Sentinel; using eSuite.API.Extensions; using eSuite.API.security; using eSuite.API.SingleSignOn; +using eSuite.API.Translation; using eSuite.API.Utilities; using eSuite.Core.Security; using Microsoft.AspNetCore.Authorization; @@ -15,6 +16,12 @@ using Microsoft.AspNetCore.Mvc; namespace eSuite.API.Controllers; +public class ProfileViewModel +{ + public Models.UserProfile Profile { get; set; } + public ITranslatorFactory TranslatorFactory { get; set; } +} + /// /// This MVC controller is used to support all user interactions when logging in and out of the system, including profile updates. /// @@ -26,7 +33,7 @@ public class AccountController : ESuiteController private readonly ISentinel _sentinel; private readonly ISingleSignOn _singleSignOn; private readonly ICookieManager _cookieManager; - + private readonly ITranslatorFactory _translatorFactory; /// /// Default constructor used to inject dependencies /// @@ -34,12 +41,13 @@ public class AccountController : ESuiteController /// /// /// - public AccountController(IUserManager userManager, ISentinel sentinel, ISingleSignOn singleSignOn, ICookieManager cookieManager) + public AccountController(IUserManager userManager, ISentinel sentinel, ISingleSignOn singleSignOn, ICookieManager cookieManager, ITranslatorFactory translatorFactory) { _userManager = userManager; _sentinel = sentinel; _singleSignOn = singleSignOn; _cookieManager = cookieManager; + _translatorFactory = translatorFactory; } private const string AccountProfileUrl = "~/account/profile"; @@ -222,7 +230,7 @@ public class AccountController : ESuiteController return Ok(); } - + /// /// Page used for making alterations to a users profile. /// @@ -234,9 +242,17 @@ public class AccountController : ESuiteController public async Task ProfileGet(CancellationToken cancellationToken) { var profile = await _userManager.GetProfile(User.Email(), cancellationToken); - var userProfile = profile.ToUserProfile(); + Models.UserProfile userProfile = profile.ToUserProfile(); - return PartialView("Profile", userProfile); + _translatorFactory.Locale = profile.PreferredLocale; + var profileViewModel = new ProfileViewModel + { + Profile = userProfile, + TranslatorFactory = _translatorFactory + }; + + + return PartialView("Profile", profileViewModel); } /// diff --git a/e-suite.API/eSuite.API/DependencyInjection/CoreRegistrationModule.cs b/e-suite.API/eSuite.API/DependencyInjection/CoreRegistrationModule.cs index ff74259..ec723a9 100644 --- a/e-suite.API/eSuite.API/DependencyInjection/CoreRegistrationModule.cs +++ b/e-suite.API/eSuite.API/DependencyInjection/CoreRegistrationModule.cs @@ -1,7 +1,7 @@ using Autofac; using e_suite.DependencyInjection; using eSuite.API.SingleSignOn; -using eSuite.Core.Clock; +using eSuite.API.Translation; namespace eSuite.API.DependencyInjection; @@ -23,6 +23,16 @@ internal class CoreRegistrationModule : ESuiteModule builder.RegisterType().As(); builder.RegisterType().As(); + builder.RegisterType() + .As() + .SingleInstance(); // safe to cache globally + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); // per-request, because locale is per-request + + + + //e_suite.Service.Mail.IocRegistration.RegisterTypes(builder); //e_suite.Service.Sentinel.IocRegistration.RegisterTypes(builder); diff --git a/e-suite.API/eSuite.API/Program.cs b/e-suite.API/eSuite.API/Program.cs index c5268e5..3f8e73f 100644 --- a/e-suite.API/eSuite.API/Program.cs +++ b/e-suite.API/eSuite.API/Program.cs @@ -6,8 +6,10 @@ using eSuite.API.DependencyInjection; using eSuite.API.HealthChecks; using eSuite.API.Middleware; using eSuite.API.Swagger; +using eSuite.API.Translation; using HealthChecks.UI.Client; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.SwaggerUI; using System.Text.Json.Serialization; @@ -49,6 +51,21 @@ builder.Services.AddHealthChecks() .AddCheck("Mail Server", () => SmtpHealthCheck.Healthy(builder.Configuration, new SocketFactory())); builder.Services.AddAntiforgery(options => options.HeaderName = "XSRF-TOKEN"); +//builder.Services.Configure(builder.Configuration); +//builder.Services.AddHttpClient() +// .ConfigureHttpClient((sp, client) => +// { +// var settings = sp.GetRequiredService>().Value; + +// // Ensure trailing slash +// var baseUrl = settings.BaseUrl.EndsWith("/") +// ? settings.BaseUrl +// : settings.BaseUrl + "/"; + +// client.BaseAddress = new Uri(baseUrl); +// }); + + var app = builder.Build(); using (var scope = app.Services.CreateScope()) @@ -85,3 +102,9 @@ app.UseMiddleware(); app.MapControllers().RequireAuthorization(); //This ensures that ALL API calls need a Bearer token, unless marked [AllowAnonymous] DO NOT REMOVE! app.Run(); + + +public class FrontendSettings +{ + public string BaseUrl { get; set; } +} diff --git a/e-suite.API/eSuite.API/Translation/TranslatorFactory.cs b/e-suite.API/eSuite.API/Translation/TranslatorFactory.cs new file mode 100644 index 0000000..20d2fd2 --- /dev/null +++ b/e-suite.API/eSuite.API/Translation/TranslatorFactory.cs @@ -0,0 +1,217 @@ +using eSuite.API.SingleSignOn; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using System.Text.Json; +using System.Text.RegularExpressions; +using e_suite.API.Common.extensions; + +namespace eSuite.API.Translation; + +[AttributeUsage(AttributeTargets.Field)] +public class NamespaceValueAttribute : Attribute +{ + public string Value { get; } + + public NamespaceValueAttribute(string value) + { + Value = value; + } +} + +public enum Namespaces +{ + [NamespaceValue("common")] + Common, + + [NamespaceValue("mailTypes")] + MailTypes, + + [NamespaceValue("htmlIsland")] + HtmlIsland +} + +public static class NamespaceExtensions +{ + public static string ToNamespaceString(this Namespaces ns) + { + var type = ns.GetType(); + var member = type.GetMember(ns.ToString()).FirstOrDefault(); + + var attr = member? + .GetCustomAttributes(typeof(NamespaceValueAttribute), false) + .Cast() + .FirstOrDefault(); + + return attr?.Value ?? ns.ToString(); + } +} + +public static class I18nInterpolation +{ + private static readonly Regex TokenRegex = new Regex(@"{{\s*(\w+)\s*}}", + RegexOptions.Compiled); + + public static string Interpolate(string template, IDictionary values) + { + if (template == null) + return string.Empty; + + return TokenRegex.Replace(template, match => + { + var key = match.Groups[1].Value; + + if (values != null && values.TryGetValue(key, out var val)) + return val?.ToString() ?? string.Empty; + + return match.Value; + }); + } +} + +public interface ITranslator +{ + string T(string key, object values = null); +} + +public class Translator : ITranslator +{ + private readonly IDictionary _translations; + + public Translator(IDictionary translations) + { + _translations = translations; + } + + public string T(string key, object values = null) + { + if (!_translations.TryGetValue(key, out var template)) + return key; + + var dict = values? + .GetType() + .GetProperties() + .ToDictionary(p => p.Name, p => p.GetValue(values)); + + return I18nInterpolation.Interpolate(template, dict); + } +} + +public static class LocaleFallback +{ + public static IEnumerable ResolveFallbacks(string locale, string defaultLocale = "en") + { + if (!string.IsNullOrWhiteSpace(locale)) + { + var parts = locale.Split('-'); + + // Default first + if (!string.Equals(locale, defaultLocale, StringComparison.OrdinalIgnoreCase)) + yield return defaultLocale; + + // Language-only + if (parts.Length > 1) + yield return parts[0]; + + // Full locale last (most specific) + yield return locale; + } + else + { + yield return defaultLocale; + } + } + +} + +public interface IJsonLocalizationService +{ + Task> LoadNamespaceAsync(string locale, string ns); +} + +public class JsonLocalizationService : IJsonLocalizationService +{ + private readonly IHttpClientFacade _http; + private readonly IMemoryCache _cache; + private readonly string _baseUrl; + + public JsonLocalizationService( + IHttpClientFacade http, + IMemoryCache cache, + IConfiguration configuration + ) + { + _http = http; + _cache = cache; + + _baseUrl = configuration.GetConfigValue("BASE_URL", "baseUrl", "http://localhost:3000"); + } + + public Task> LoadNamespaceAsync(string locale, string ns) + { + var cacheKey = $"i18n:{locale}:{ns}"; + + return _cache.GetOrCreateAsync(cacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); + return await LoadNamespaceInternalAsync(locale, ns); + }); + } + + private async Task> LoadNamespaceInternalAsync(string locale, string ns) + { + var result = new Dictionary(); + + foreach (var loc in LocaleFallback.ResolveFallbacks(locale)) + { + var url = new Uri($"{_baseUrl}/locales/{loc}/{ns}.json"); + + try + { + var json = await _http.GetStringAsync(url, CancellationToken.None); + var dict = JsonSerializer.Deserialize>(json); + + foreach (var kvp in dict) + result[kvp.Key] = kvp.Value; + } + catch + { + // ignore missing files + } + } + + return result; + } +} + +public interface ITranslatorFactory +{ + string Locale { get; set; } + Task Use(params Namespaces[] namespaces); +} + +public class TranslatorFactory : ITranslatorFactory +{ + private readonly IJsonLocalizationService _localizer; + public string Locale { get; set; } = "en-GB"; + + public TranslatorFactory(IJsonLocalizationService localizer) + { + _localizer = localizer; + } + + public async Task Use(params Namespaces[] namespaces) + { + var merged = new Dictionary(); + + foreach (var ns in namespaces) + { + var nsString = ns.ToNamespaceString(); + var dict = await _localizer.LoadNamespaceAsync(Locale, nsString); + + foreach (var kvp in dict) + merged[kvp.Key] = kvp.Value; + } + + return new Translator(merged); + } +} \ No newline at end of file diff --git a/e-suite.API/eSuite.API/Views/Account/Profile.cshtml b/e-suite.API/eSuite.API/Views/Account/Profile.cshtml index 59ea507..c78ddf4 100644 --- a/e-suite.API/eSuite.API/Views/Account/Profile.cshtml +++ b/e-suite.API/eSuite.API/Views/Account/Profile.cshtml @@ -1,46 +1,45 @@ -@model eSuite.API.Models.UserProfile +@using eSuite.API.Translation +@model eSuite.API.Controllers.ProfileViewModel @{ - ViewBag.Title = "e-suite"; - //Layout = "~/Views/Shared/_layout.cshtml"; + var t = await Model.TranslatorFactory.Use(Namespaces.Common); Layout = null; }
-

Profile

@using (Html.BeginForm(FormMethod.Post, true, null)) { @Html.AntiForgeryToken()
- @Html.LabelFor(m => m.Email) - @Html.EditorFor(m => m.Email, new { htmlAttributes = new + @Html.LabelFor(m => m.Profile.Email,t.T("Email")) + @Html.EditorFor(m => m.Profile.Email, new { htmlAttributes = new { @class = "form-control", @disabled = "disabled" } }) - @Html.ValidationMessageFor(m => m.Email) + @Html.ValidationMessageFor(m => m.Profile.Email)
- @Html.LabelFor(m => m.FirstName) - @Html.EditorFor(m => m.FirstName, new { htmlAttributes = new { @class = "form-control" } }) - @Html.ValidationMessageFor(m => m.FirstName) + @Html.LabelFor(m => m.Profile.FirstName, t.T("FirstName")) + @Html.EditorFor(m => m.Profile.FirstName, new { htmlAttributes = new { @class = "form-control" } }) + @Html.ValidationMessageFor(m => m.Profile.FirstName)
- @Html.LabelFor(m => m.MiddleNames) - @Html.EditorFor(m => m.MiddleNames, new { htmlAttributes = new { @class = "form-control" } }) - @Html.ValidationMessageFor(m => m.MiddleNames) + @Html.LabelFor(m => m.Profile.MiddleNames, t.T("MiddleNames")) + @Html.EditorFor(m => m.Profile.MiddleNames, new { htmlAttributes = new { @class = "form-control" } }) + @Html.ValidationMessageFor(m => m.Profile.MiddleNames)
- @Html.LabelFor(m => m.LastName) - @Html.EditorFor(m => m.LastName, new { htmlAttributes = new { @class = "form-control" } }) - @Html.ValidationMessageFor(m => m.LastName) + @Html.LabelFor(m => m.Profile.LastName, t.T("LastName")) + @Html.EditorFor(m => m.Profile.LastName, new { htmlAttributes = new { @class = "form-control" } }) + @Html.ValidationMessageFor(m => m.Profile.LastName)
- @await Html.PartialAsync("_loginMethodChooser") + @await Html.PartialAsync("_loginMethodChooser", Model.Profile) - + }
diff --git a/eSuite.sln.DotSettings.user b/eSuite.sln.DotSettings.user index 10a5de7..028352f 100644 --- a/eSuite.sln.DotSettings.user +++ b/eSuite.sln.DotSettings.user @@ -9,8 +9,8 @@ </TestAncestor> </SessionState> True - (Doc Ln 120 Col 0) - 7DC1F493-76A5-3740-E774-C8DAA51ED83A/f:PatchUnitTests.cs + (Doc Ln 140 Col 8) + 4A704FA7-4E3A-4CFA-B043-434A0C49AF89/d:Translation/f:TranslatorFactory.cs NumberedBookmarkManager True (Doc Ln 606 Col 8)