From d052d05e85d8a6e73c2f31bd4774a6a92655ed2f Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:15:45 -0300 Subject: [PATCH 01/55] feat(identity): add nullable User.Locale and emit locale JWT claim --- .../DTOs/UserDto.cs | 3 + .../Services/IUserProfileService.cs | 2 +- .../Services/IUserService.cs | 2 +- .../v1/Users/UpdateUser/UpdateUserCommand.cs | 1 + .../Modules.Identity/Domain/FshUser.cs | 3 + .../StartImpersonationCommandHandler.cs | 4 +- .../UpdateUser/UpdateUserCommandHandler.cs | 1 + .../Services/IdentityService.cs | 18 +++- .../Services/UserProfileService.cs | 10 +- .../Modules.Identity/Services/UserService.cs | 4 +- .../StartImpersonationCommandHandlerTests.cs | 73 ++++++++++++++ .../Handlers/UpdateUserCommandHandlerTests.cs | 8 +- .../Services/CreateBasicClaimsTests.cs | 38 ++++++++ .../Services/UserLocaleTests.cs | 95 +++++++++++++++++++ .../Support/TestAsyncQueryable.cs | 68 +++++++++++++ 15 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs create mode 100644 src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs create mode 100644 src/Tests/Identity.Tests/Services/UserLocaleTests.cs create mode 100644 src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs index 0ccd71384e..343cb17dbb 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs @@ -22,4 +22,7 @@ public class UserDto /// Whether the user has enrolled in TOTP-based two-factor authentication. public bool TwoFactorEnabled { get; set; } + + /// BCP 47 UI language tag (e.g. "pt-BR"); null resolves to the default culture. + public string? Locale { get; set; } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs index f305b4a782..5cc4bbed96 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs @@ -26,7 +26,7 @@ public interface IUserProfileService /// /// Updates a user's profile. /// - Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default); + Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default); /// /// Sets the profile image URL directly (no upload). Used by the presigned-upload flow: diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs index 91ab3467fa..edc323581a 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs @@ -15,7 +15,7 @@ public interface IUserService Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken); Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default); Task RegisterAsync(string firstName, string lastName, string email, string userName, string password, string confirmPassword, string phoneNumber, string origin, CancellationToken cancellationToken); - Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default); + Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default); Task DeleteAsync(string userId, CancellationToken cancellationToken = default); Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken); Task AdminConfirmEmailAsync(string userId, CancellationToken cancellationToken = default); diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs index 09292a46bc..90c7740862 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs @@ -12,4 +12,5 @@ public class UpdateUserCommand : ICommand public string? Email { get; set; } public FileUploadRequest? Image { get; set; } public bool DeleteCurrentImage { get; set; } + public string? Locale { get; set; } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs b/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs index 270d87fd96..536abe934c 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs @@ -15,6 +15,9 @@ public class FshUser : IdentityUser, IHasDomainEvents public string? RefreshToken { get; set; } public DateTime RefreshTokenExpiryTime { get; set; } + /// BCP 47 UI language tag (e.g. "pt-BR"); null resolves to the default culture. + public string? Locale { get; set; } + public string? ObjectId { get; set; } /// Timestamp when the user last changed their password diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs index c5a4288fc0..c8c15bb27b 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs @@ -104,7 +104,9 @@ public async ValueTask Handle( // ImpersonationGrant row and the issued JWT share the same jti. var jti = Guid.NewGuid().ToString("N"); var impersonationClaims = claims - .Where(c => c.Type != JwtRegisteredClaimNames.Jti) + // Drop the target's locale: language is a presentation concern, so the operator reads in + // THEIR own language (falls through to Accept-Language), not the impersonated user's. + .Where(c => c.Type != JwtRegisteredClaimNames.Jti && c.Type != "locale") .Concat( [ new Claim(JwtRegisteredClaimNames.Jti, jti), diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs index 9b6608e03a..64772a10a5 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -24,6 +24,7 @@ await _userService.UpdateAsync( command.PhoneNumber ?? string.Empty, command.Image!, command.DeleteCurrentImage, + command.Locale, cancellationToken).ConfigureAwait(false); return Unit.Value; diff --git a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs index a403a2363e..852d0c098a 100644 --- a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs @@ -301,11 +301,11 @@ private async Task> BuildUserClaimsAsync(FshUser user, string tenant return claims; } - private static List CreateBasicClaims(FshUser user, string tenantId) + internal static List CreateBasicClaims(FshUser user, string tenantId) { var fullName = $"{user.FirstName} {user.LastName}".Trim(); - return - [ + var claims = new List + { new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // RFC 7519 short-form sub/name/email emitted alongside legacy ClaimTypes.* so JWT consumers read them per spec. // `name` is published explicitly because the default outbound map turns ClaimTypes.Name into `unique_name`, not `name`. @@ -320,7 +320,17 @@ private static List CreateBasicClaims(FshUser user, string tenantId) new(ClaimTypes.Surname, user.LastName ?? string.Empty), new(ClaimConstants.Tenant, tenantId), new(ClaimConstants.ImageUrl, user.ImageUrl?.ToString() ?? string.Empty) - ]; + }; + + // OIDC-standard `locale` claim, emitted ONLY when the user explicitly chose a language. + // A null/blank Locale emits no claim so the culture-resolution chain falls to Accept-Language + // rather than forcing the deployment default onto users who never picked one. + if (!string.IsNullOrWhiteSpace(user.Locale)) + { + claims.Add(new Claim("locale", user.Locale)); + } + + return claims; } private async Task AddRoleClaimsAsync(List claims, FshUser user, CancellationToken ct) diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index c96c90384b..af021e896e 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -48,6 +48,7 @@ public async Task GetAsync(string userId, CancellationToken cancellatio EmailConfirmed = user.EmailConfirmed, PhoneNumber = user.PhoneNumber, TwoFactorEnabled = user.TwoFactorEnabled, + Locale = user.Locale, }; } @@ -75,7 +76,7 @@ public async Task> GetListAsync(CancellationToken cancellationToke return result; } - public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default) + public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default) { var user = await userManager.FindByIdAsync(userId); @@ -101,6 +102,13 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, user.FirstName = firstName; user.LastName = lastName; + // Null locale means "not provided by this update" — preserve the existing value so a + // text-only profile edit never clears a language the user already chose. + if (locale is not null) + { + user.Locale = locale; + } + string? currentPhoneNumber = await userManager.GetPhoneNumberAsync(user); if (phoneNumber != currentPhoneNumber) { diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.cs index e11963512f..827eab17bc 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.cs @@ -55,8 +55,8 @@ public Task> GetListAsync(CancellationToken cancellationToken) public Task GetCountAsync(CancellationToken cancellationToken) => profileService.GetCountAsync(cancellationToken); - public Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default) - => profileService.UpdateAsync(userId, firstName, lastName, phoneNumber, image, deleteCurrentImage, cancellationToken); + public Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default) + => profileService.UpdateAsync(userId, firstName, lastName, phoneNumber, image, deleteCurrentImage, locale, cancellationToken); public Task ExistsWithEmailAsync(string email, string? exceptId = null, CancellationToken cancellationToken = default) => profileService.ExistsWithEmailAsync(email, exceptId, cancellationToken); diff --git a/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs new file mode 100644 index 0000000000..46450cd661 --- /dev/null +++ b/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using FSH.Framework.Core.Context; +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Impersonation; +using FSH.Modules.Identity.Contracts.v1.Impersonation.StartImpersonation; +using FSH.Modules.Identity.Features.v1.Impersonation.StartImpersonation; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.IdentityModel.Tokens.Jwt; + +namespace Identity.Tests.Handlers; + +/// +/// The impersonation token must NOT carry the target user's `locale` claim — language is a +/// presentation concern, so the operator keeps reading in their own language. +/// +public sealed class StartImpersonationCommandHandlerTests +{ + private const string TenantId = "codefi"; + private const string TargetUserId = "target-user"; + + private readonly IIdentityService _identityService = Substitute.For(); + private readonly ITokenService _tokenService = Substitute.For(); + private readonly ISecurityAudit _securityAudit = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IRequestContext _requestContext = Substitute.For(); + private readonly IImpersonationGrantService _grantService = Substitute.For(); + + private StartImpersonationCommandHandler CreateSut() => + new(_identityService, _tokenService, _securityAudit, _currentUser, _requestContext, + _grantService, TimeProvider.System, Substitute.For>()); + + [Fact] + public async Task Handle_strips_the_targets_locale_claim_from_the_impersonation_token() + { + // Arrange — an authenticated operator in the same tenant as the target. + _currentUser.IsAuthenticated().Returns(true); + _currentUser.GetUserId().Returns(Guid.NewGuid()); + _currentUser.GetTenant().Returns(TenantId); + _currentUser.Name.Returns("operator"); + _currentUser.GetUserClaims().Returns(new List()); + + // The target user has a persisted locale, so BuildClaimsForUserAsync returns a `locale` claim. + var targetClaims = new List + { + new(JwtRegisteredClaimNames.Jti, "orig-jti"), + new(ClaimTypes.Name, "Target User"), + new("locale", "pt-BR"), + }; + _identityService + .BuildClaimsForUserAsync(TargetUserId, TenantId, Arg.Any()) + .Returns(((string, IEnumerable)?)(TargetUserId, targetClaims)); + + IEnumerable? issuedClaims = null; + _tokenService + .IssueAccessOnlyAsync( + Arg.Any(), + Arg.Do>(c => issuedClaims = c), + Arg.Any(), + Arg.Any()) + .Returns(("access-token", DateTime.UtcNow.AddMinutes(15))); + + var sut = CreateSut(); + + // Act + await sut.Handle(new StartImpersonationCommand(TargetUserId, TenantId, "reason", 15), CancellationToken.None); + + // Assert + issuedClaims.ShouldNotBeNull(); + issuedClaims.Any(c => c.Type == "locale").ShouldBeFalse(); + } +} diff --git a/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs index f89478916a..2a9e25d006 100644 --- a/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs +++ b/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs @@ -39,7 +39,8 @@ await _userService.Received(1).UpdateAsync( command.LastName ?? string.Empty, command.PhoneNumber ?? string.Empty, command.Image!, - command.DeleteCurrentImage); + command.DeleteCurrentImage, + command.Locale); } [Fact] @@ -66,7 +67,8 @@ await _userService.Received(1).UpdateAsync( string.Empty, string.Empty, null!, - true); + true, + command.Locale); } [Fact] @@ -83,7 +85,7 @@ public async Task Handle_Should_ThrowException_When_UserServiceThrows() // Arrange var command = _fixture.Create(); var expectedExceptionMessage = "Update failed"; - _userService.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _userService.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(x => throw new InvalidOperationException(expectedExceptionMessage)); // Act & Assert diff --git a/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs b/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs new file mode 100644 index 0000000000..ab8a29c746 --- /dev/null +++ b/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs @@ -0,0 +1,38 @@ +using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Services; + +namespace Identity.Tests.Services; + +/// +/// The JWT carries the OIDC-standard `locale` claim only when the user explicitly chose a language; +/// an unset locale emits no claim so culture resolution can fall through to Accept-Language. +/// +public sealed class CreateBasicClaimsTests +{ + private static FshUser User(string? locale) => + new() { Id = "u1", Email = "u@codefi.com.br", UserName = "u", FirstName = "First", LastName = "Last", Locale = locale }; + + [Fact] + public void Emits_locale_claim_when_user_locale_is_set() + { + var claims = IdentityService.CreateBasicClaims(User("pt-BR"), "codefi"); + + claims.Single(c => c.Type == "locale").Value.ShouldBe("pt-BR"); + } + + [Fact] + public void Omits_locale_claim_when_user_locale_is_null() + { + var claims = IdentityService.CreateBasicClaims(User(null), "codefi"); + + claims.Any(c => c.Type == "locale").ShouldBeFalse(); + } + + [Fact] + public void Omits_locale_claim_when_user_locale_is_whitespace() + { + var claims = IdentityService.CreateBasicClaims(User(" "), "codefi"); + + claims.Any(c => c.Type == "locale").ShouldBeFalse(); + } +} diff --git a/src/Tests/Identity.Tests/Services/UserLocaleTests.cs b/src/Tests/Identity.Tests/Services/UserLocaleTests.cs new file mode 100644 index 0000000000..f8a6d1726a --- /dev/null +++ b/src/Tests/Identity.Tests/Services/UserLocaleTests.cs @@ -0,0 +1,95 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Storage.Services; +using FSH.Framework.Web.Origin; +using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Services; +using Identity.Tests.Support; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Identity.Tests.Services; + +/// +/// Covers the User.Locale foundation: UpdateAsync persists the locale onto the entity and +/// GetAsync projects it back onto the DTO. +/// +public sealed class UserLocaleTests +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IStorageService _storageService; + private readonly IMultiTenantContextAccessor _tenantAccessor; + + public UserLocaleTests() + { + _userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + _signInManager = Substitute.For>( + _userManager, + Substitute.For(), + Substitute.For>(), + Options.Create(new IdentityOptions()), + Substitute.For>>(), + Substitute.For(), + Substitute.For>()); + _signInManager.RefreshSignInAsync(Arg.Any()).Returns(Task.CompletedTask); + _storageService = Substitute.For(); + _tenantAccessor = Substitute.For>(); + } + + private UserProfileService CreateSut() => + new(_userManager, _signInManager, _storageService, _tenantAccessor, + Options.Create(new OriginOptions()), Substitute.For()); + + [Fact] + public async Task UpdateAsync_persists_the_supplied_locale_onto_the_user() + { + // Arrange + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u" }; + _userManager.FindByIdAsync("u1").Returns(user); + _userManager.UpdateAsync(user).Returns(IdentityResult.Success); + var sut = CreateSut(); + + // Act + await sut.UpdateAsync("u1", "First", "Last", string.Empty, null!, false, "pt-BR", CancellationToken.None); + + // Assert + user.Locale.ShouldBe("pt-BR"); + } + + [Fact] + public async Task UpdateAsync_with_null_locale_preserves_the_existing_value() + { + // Arrange — a text-only edit forwards a null locale; the user already chose en-US. + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u", Locale = "en-US" }; + _userManager.FindByIdAsync("u1").Returns(user); + _userManager.UpdateAsync(user).Returns(IdentityResult.Success); + var sut = CreateSut(); + + // Act + await sut.UpdateAsync("u1", "First", "Last", string.Empty, null!, false, null, CancellationToken.None); + + // Assert + user.Locale.ShouldBe("en-US"); + } + + [Fact] + public async Task GetAsync_projects_the_persisted_locale_onto_the_dto() + { + // Arrange + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u", Locale = "pt-BR" }; + _userManager.Users.Returns(new[] { user }.AsAsyncQueryable()); + var sut = CreateSut(); + + // Act + var dto = await sut.GetAsync("u1", CancellationToken.None); + + // Assert + dto.Locale.ShouldBe("pt-BR"); + } +} diff --git a/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs b/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs new file mode 100644 index 0000000000..39a27f199b --- /dev/null +++ b/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs @@ -0,0 +1,68 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; + +namespace Identity.Tests.Support; + +/// +/// Minimal in-memory async queryable so services that call EF's FirstOrDefaultAsync +/// (e.g. UserManager.Users.Where(...).FirstOrDefaultAsync) can be unit tested against a +/// mocked UserManager.Users without a database. Standard EF unit-testing scaffold. +/// +internal static class TestAsyncQueryable +{ + public static IQueryable AsAsyncQueryable(this IEnumerable source) => + new TestAsyncEnumerable(source); +} + +internal sealed class TestAsyncQueryProvider : IAsyncQueryProvider +{ + private readonly IQueryProvider _inner; + + internal TestAsyncQueryProvider(IQueryProvider inner) => _inner = inner; + + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + + public object? Execute(Expression expression) => _inner.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Execute(expression); + + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) + { + // TResult is Task; run the query synchronously through the base provider and wrap the result. + var resultType = typeof(TResult).GetGenericArguments()[0]; + var executionResult = _inner.Execute(expression); + var fromResult = typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(resultType); + return (TResult)fromResult.Invoke(null, new[] { executionResult })!; + } +} + +internal sealed class TestAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable +{ + public TestAsyncEnumerable(IEnumerable enumerable) : base(enumerable) { } + + public TestAsyncEnumerable(Expression expression) : base(expression) { } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); + + IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); +} + +internal sealed class TestAsyncEnumerator : IAsyncEnumerator +{ + private readonly IEnumerator _inner; + + public TestAsyncEnumerator(IEnumerator inner) => _inner = inner; + + public T Current => _inner.Current; + + public ValueTask MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext()); + + public ValueTask DisposeAsync() + { + _inner.Dispose(); + return ValueTask.CompletedTask; + } +} From e5318359fe12220e06746bf4c34246787d42aadc Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:25:24 -0300 Subject: [PATCH 02/55] feat(identity): validate User.Locale against supported cultures Add SupportedCultures constant (Default/Tags/RequestMatch) in BuildingBlocks/Core/Localization and reject unsupported locale tags in UpdateUserCommandValidator; null/empty locale still passes. --- .../Core/Localization/SupportedCultures.cs | 14 ++++++++++++++ .../UpdateUser/UpdateUserCommandValidator.cs | 6 ++++++ .../UpdateUserCommandValidatorTests.cs | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 src/BuildingBlocks/Core/Localization/SupportedCultures.cs diff --git a/src/BuildingBlocks/Core/Localization/SupportedCultures.cs b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs new file mode 100644 index 0000000000..290ef3c6c6 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs @@ -0,0 +1,14 @@ +namespace FSH.Framework.Core.Localization; + +/// Canonical set of cultures the platform supports for user-facing localization. +public static class SupportedCultures +{ + /// Guaranteed ultimate fallback culture (neutral catalog). + public const string Default = "en-US"; + + /// Specific tags a user may persist and the switcher offers. + public static readonly string[] Tags = ["en-US", "pt-BR"]; + + /// Tags for Accept-Language matching, including neutrals for parent-culture fallback. + public static readonly string[] RequestMatch = ["en-US", "pt-BR", "pt", "en"]; +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs index 6fc722d9d9..e85af5a6f6 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Storage; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; @@ -38,5 +39,10 @@ public UpdateUserCommandValidator() RuleFor(x => x) .Must(x => !(x.DeleteCurrentImage && x.Image is not null)) .WithMessage("You cannot upload a new image and delete the current one simultaneously."); + + RuleFor(x => x.Locale) + .Must(l => SupportedCultures.Tags.Contains(l!)) + .When(x => !string.IsNullOrWhiteSpace(x.Locale)) + .WithMessage("Unsupported locale."); } } \ No newline at end of file diff --git a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs index ee98206a87..f79c1eb581 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; using FSH.Modules.Identity.Features.v1.Users.UpdateUser; using Shouldly; @@ -90,4 +91,22 @@ public void Validate_Should_Fail_When_DeleteImageAndUploadImage_Simultaneously() result.IsValid.ShouldBeFalse(); result.Errors.ShouldContain(e => e.ErrorMessage == "You cannot upload a new image and delete the current one simultaneously."); } + + [Theory] + [InlineData("pt-BR", true)] + [InlineData("en-US", true)] + [InlineData(null, true)] + [InlineData("xx-YY", false)] + [InlineData("notaculture", false)] + public void Locale_Must_Be_Supported_Or_Null(string? locale, bool expectedValid) + { + // Arrange + var command = new UpdateUserCommand { Id = "user-123", Locale = locale }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.Any(e => e.PropertyName == nameof(UpdateUserCommand.Locale)).ShouldBe(!expectedValid); + } } From 63a9f118b79ace861341c60a9bc718c00514c125 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:30:14 -0300 Subject: [PATCH 03/55] feat(identity): migration adding nullable Locale column to Users --- .../20260720062947_AddUserLocale.Designer.cs | 845 ++++++++++++++++++ .../Identity/20260720062947_AddUserLocale.cs | 30 + .../IdentityDbContextModelSnapshot.cs | 3 + 3 files changed, 878 insertions(+) create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs new file mode 100644 index 0000000000..fe1fce1ca1 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs @@ -0,0 +1,845 @@ +// +using System; +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20260720062947_AddUserLocale")] + partial class AddUserLocale + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Eventing.Inbox.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id", "HandlerName"); + + b.ToTable("InboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Framework.Eventing.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDead") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.ToTable("OutboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName", "TenantId") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Locale") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ObjectId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName", "TenantId") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("CreatedOnUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsSystemGroup") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("ModifiedBy"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("ModifiedAt"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Name"); + + b.ToTable("Groups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("GroupId", "RoleId"); + + b.HasIndex("GroupId"); + + b.HasIndex("RoleId"); + + b.ToTable("GroupRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.ImpersonationGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClientId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImpersonatedTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImpersonatedUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImpersonatedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokeReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedByUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.HasIndex("ActorUserId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ActorUserId_StartedAtUtc"); + + b.HasIndex("ImpersonatedTenantId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ImpersonatedTenantId_StartedAtUtc"); + + b.ToTable("ImpersonationGrants", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("AddedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BrowserVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OsVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("GroupRoles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("UserGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Navigation("GroupRoles"); + + b.Navigation("UserGroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs new file mode 100644 index 0000000000..ed6e399d89 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Identity +{ + /// + public partial class AddUserLocale : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Locale", + schema: "identity", + table: "Users", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Locale", + schema: "identity", + table: "Users"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs index 03f737d0ee..1f8ec5efe4 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs @@ -200,6 +200,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastPasswordChangeDate") .HasColumnType("timestamp with time zone"); + b.Property("Locale") + .HasColumnType("text"); + b.Property("LockoutEnabled") .HasColumnType("boolean"); From 7975c05ac707ddac6f5d0d0057473f40fc5d47f2 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:56:45 -0300 Subject: [PATCH 04/55] feat(core): shared resx catalog and supported-cultures constant for localization --- src/BuildingBlocks/Core/Core.csproj | 2 + .../Core/Localization/SharedResources.cs | 4 + .../Core/Localization/SharedResources.pt.resx | 79 +++++++++++++++++++ .../Core/Localization/SharedResources.resx | 79 +++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.cs create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.pt.resx create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.resx diff --git a/src/BuildingBlocks/Core/Core.csproj b/src/BuildingBlocks/Core/Core.csproj index 3c2e01bfdb..0af6dc03eb 100644 --- a/src/BuildingBlocks/Core/Core.csproj +++ b/src/BuildingBlocks/Core/Core.csproj @@ -3,6 +3,8 @@ FSH.Framework.Core FSH.Framework.Core + + $(NoWarn);S2094 diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.cs b/src/BuildingBlocks/Core/Localization/SharedResources.cs new file mode 100644 index 0000000000..8dd0ce5452 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Framework.Core.Localization; + +/// Marker type binding IStringLocalizer<SharedResources> to the shared resx catalog. +public sealed class SharedResources; diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx b/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx new file mode 100644 index 0000000000..430e7f0fab --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ocorreram um ou mais erros de validação. + + + Não autorizado + + + Não encontrado + + + Requisição inválida + + + Ocorreu um erro inesperado + + + Ocorreu um erro inesperado. Tente novamente mais tarde. + + diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.resx b/src/BuildingBlocks/Core/Localization/SharedResources.resx new file mode 100644 index 0000000000..a23ed82d23 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + One or more validation errors occurred. + + + Unauthorized + + + Not Found + + + Bad Request + + + An unexpected error occurred + + + An unexpected error occurred. Please try again later. + + From e8948df0601a9ffee14c73ac1ff4fb255d204175 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:56:54 -0300 Subject: [PATCH 05/55] feat(web): request localization with user-locale-first culture provider --- .agents/rules/architecture.md | 5 +- src/BuildingBlocks/Web/Extensions.cs | 6 ++ .../Localization/LocalizationExtensions.cs | 52 +++++++++++ .../UserLocaleRequestCultureProvider.cs | 31 +++++++ .../SharedResourcesLocalizationTests.cs | 47 ++++++++++ .../UserLocaleRequestCultureProviderTests.cs | 90 +++++++++++++++++++ 6 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 src/BuildingBlocks/Web/Localization/LocalizationExtensions.cs create mode 100644 src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs create mode 100644 src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md index cf3fa6b6ef..3f63770aac 100644 --- a/.agents/rules/architecture.md +++ b/.agents/rules/architecture.md @@ -81,8 +81,9 @@ In `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`): 2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected) 3. HttpsRedirection → SecurityHeaders → static files → Routing 4. **`UseAuthentication`** -5. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth -6. RateLimiting → Quotas → `UseAuthorization` → `MapModules` +5. **`UseHeroLocalization`** — request localization, sits **between `UseAuthentication` and `UseAuthorization`** so the user-`locale`-claim culture provider can read `HttpContext.User` +6. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth +7. RateLimiting → Quotas → `UseAuthorization` → `MapModules` `app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`** — so tenant resolution is header-driven, not claim-driven. See `modules/multitenancy.md`. diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 50c6568fda..5b21d659dd 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -9,6 +9,7 @@ using FSH.Framework.Web.Exceptions; using FSH.Framework.Web.FeatureFlags; using FSH.Framework.Web.Idempotency; +using FSH.Framework.Web.Localization; using FSH.Framework.Web.Sse; using FSH.Framework.Web.Health; using FSH.Framework.Web.Mediator.Behaviors; @@ -129,6 +130,7 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild builder.Services.AddHeroQuotas(builder.Configuration); } + builder.Services.AddHeroLocalization(builder.Configuration); builder.Services.AddExceptionHandler(); builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); builder.Services.AddProblemDetails(); @@ -185,6 +187,10 @@ public static WebApplication UseHeroPlatform(this WebApplication app, Action + /// Registers request localization: resx-backed IStringLocalizer and a culture-provider + /// chain of Query → user locale claim → Accept-Language → configured default → en-US. + /// The per-deployment default is read from LocalizationOptions:DefaultCulture and validated + /// against the whitelist, so garbage config falls back to the guaranteed neutral culture. + /// + public static IServiceCollection AddHeroLocalization(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var configured = configuration["LocalizationOptions:DefaultCulture"]; + var defaultCulture = SupportedCultures.Tags.Contains(configured!) ? configured! : SupportedCultures.Default; + + // ResourcesPath = "" because SharedResources and its resx live in the same folder/namespace + // (co-located). A non-empty path would double the prefix and IStringLocalizer would silently + // fall back to the raw key. The resx-resolution test guards this value. + services.AddLocalization(o => o.ResourcesPath = ""); + + services.Configure(o => + { + o.SetDefaultCulture(defaultCulture); + o.AddSupportedCultures(SupportedCultures.RequestMatch); + o.AddSupportedUICultures(SupportedCultures.RequestMatch); + o.ApplyCurrentCultureToResponseHeaders = true; + + // Default order is [Query(0), Cookie(1), AcceptLanguage(2)]. Drop the cookie provider and + // insert the user-claim provider right after query, so the final chain is + // Query → UserLocaleClaim → AcceptLanguage → configured default → en-US neutral resx. + o.RequestCultureProviders.RemoveAt(1); + o.RequestCultureProviders.Insert(1, new UserLocaleRequestCultureProvider()); + }); + + return services; + } + + public static IApplicationBuilder UseHeroLocalization(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseRequestLocalization(); + } +} diff --git a/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs b/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs new file mode 100644 index 0000000000..8c0e3a7e45 --- /dev/null +++ b/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs @@ -0,0 +1,31 @@ +using System.Security.Claims; +using FSH.Framework.Core.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; + +namespace FSH.Framework.Web.Localization; + +/// +/// Resolves the request culture from the authenticated user's locale claim +/// (mirrors the persisted User.Locale). Sits after the query-string provider and before +/// the Accept-Language provider: a supported claim wins over the browser header, an unsupported +/// or absent claim falls through to the next provider. +/// +public sealed class UserLocaleRequestCultureProvider : RequestCultureProvider +{ + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + var claim = httpContext.User.FindFirstValue("locale"); + if (IsSupported(claim)) + { + return Task.FromResult(new ProviderCultureResult(claim!)); + } + + return NullProviderCultureResult; + } + + private static bool IsSupported(string? tag) => + !string.IsNullOrWhiteSpace(tag) && SupportedCultures.Tags.Contains(tag); +} diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs new file mode 100644 index 0000000000..bab543e573 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using FSH.Framework.Core.Localization; +using FSH.Framework.Web.Localization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Framework.Tests.Localization; + +// Proves the whole resx wiring: SharedResources marker + co-located resx + ResourcesPath="" in +// AddHeroLocalization resolve to the embedded catalog under the current UI culture. If the manifest +// name or ResourcesPath is wrong, ResourceNotFound flips true and IStringLocalizer leaks the raw key. +public sealed class SharedResourcesLocalizationTests +{ + private static IStringLocalizer BuildLocalizer() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHeroLocalization(configuration); + return services.BuildServiceProvider().GetRequiredService>(); + } + + [Theory] + [InlineData("pt-BR", "Não encontrado")] // specific pt-BR falls back to the neutral .pt catalog + [InlineData("pt", "Não encontrado")] // neutral pt resolves directly + [InlineData("pt-PT", "Não encontrado")] // other pt variant also falls back to .pt + [InlineData("en-US", "Not Found")] // en-US falls back to the neutral (default) catalog + public void Localizer_resolves_error_key_per_culture(string culture, string expected) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + var localized = localizer["Error.NotFound"]; + + localized.ResourceNotFound.ShouldBeFalse( + $"resx for '{culture}' did not resolve 'Error.NotFound' — check ResourcesPath/resx manifest name."); + localized.Value.ShouldBe(expected); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs b/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs new file mode 100644 index 0000000000..87e76e3cf2 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Security.Claims; +using FSH.Framework.Web.Localization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Framework.Tests.Localization; + +public sealed class UserLocaleRequestCultureProviderTests +{ + private static IOptions BuildOptions() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var services = new ServiceCollection(); + services.AddHeroLocalization(configuration); + return services.BuildServiceProvider().GetRequiredService>(); + } + + private static DefaultHttpContext BuildContext(string? claim, string? header, string? query) + { + var context = new DefaultHttpContext(); + if (claim is not null) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("locale", claim)], "test")); + } + + if (header is not null) + { + context.Request.Headers.AcceptLanguage = header; + } + + if (query is not null) + { + context.Request.QueryString = new QueryString($"?culture={query}"); + } + + return context; + } + + // Full culture-provider chain: Query -> user locale claim -> Accept-Language -> configured default -> en-US. + [Theory] + [InlineData("pt-BR", "en-US", null, "pt-BR")] // supported claim wins over the header + [InlineData(null, "pt-BR", null, "pt-BR")] // header used when there is no claim + [InlineData(null, null, null, "en-US")] // nothing set -> default fallback + [InlineData("xx-YY", "pt-BR", null, "pt-BR")] // unsupported claim ignored -> falls to header + [InlineData(null, "pt-BR", "en-US", "en-US")] // explicit query override wins over everything + public async Task Resolves_expected_culture_through_chain(string? claim, string? header, string? query, string expected) + { + var options = BuildOptions(); + var context = BuildContext(claim, header, query); + + string? resolved = null; + var middleware = new RequestLocalizationMiddleware( + _ => { resolved = CultureInfo.CurrentUICulture.Name; return Task.CompletedTask; }, + options, + NullLoggerFactory.Instance); + + var previous = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); + try + { + await middleware.Invoke(context); + } + finally + { + CultureInfo.CurrentCulture = previous.Item1; + CultureInfo.CurrentUICulture = previous.Item2; + } + + resolved.ShouldBe(expected); + } + + // The custom provider in isolation: emit the claim only when supported, otherwise fall through (null). + [Theory] + [InlineData("pt-BR", "pt-BR")] + [InlineData("en-US", "en-US")] + [InlineData("xx-YY", null)] + [InlineData(null, null)] + public async Task Provider_returns_claim_only_when_supported(string? claim, string? expected) + { + var context = BuildContext(claim, header: null, query: null); + var result = await new UserLocaleRequestCultureProvider().DetermineProviderCultureResult(context); + (result?.Cultures[0].Value).ShouldBe(expected); + } +} From 2b37619646ed895ffb22d0bd90722357a63875b5 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:10:44 -0300 Subject: [PATCH 06/55] feat(web): localize GlobalExceptionHandler ProblemDetails titles Inject IStringLocalizer and swap hardcoded ProblemDetails titles (Validation/Unauthorized/NotFound/BadRequest/Unexpected + the 500 Detail) for resx keys. Exception-supplied Detail messages stay raw. Docker-free handler-level tests exercise the localized 404 and 500 branches under pt-BR and en-US. --- .../Web/Exceptions/GlobalExceptionHandler.cs | 18 +++--- .../SharedResourcesLocalizerFactory.cs | 19 ++++++ ...GlobalExceptionHandlerLocalizationTests.cs | 61 +++++++++++++++++++ .../Web/GlobalExceptionHandlerTests.cs | 5 +- 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs create mode 100644 src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs index 8e8987dae7..c625dcef4f 100644 --- a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -1,15 +1,19 @@ using System.Diagnostics; using System; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Core.Localization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Serilog.Context; namespace FSH.Framework.Web.Exceptions; -public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +public class GlobalExceptionHandler( + ILogger logger, + IStringLocalizer localizer) : IExceptionHandler { public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { @@ -28,7 +32,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e statusCode = StatusCodes.Status400BadRequest; problemDetails.Status = statusCode; - problemDetails.Title = "Validation error"; + problemDetails.Title = localizer["Error.Validation"]; problemDetails.Detail = "One or more validation errors occurred."; problemDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; @@ -57,14 +61,14 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e { statusCode = StatusCodes.Status401Unauthorized; problemDetails.Status = statusCode; - problemDetails.Title = "Unauthorized"; + problemDetails.Title = localizer["Error.Unauthorized"]; problemDetails.Detail = exception.Message; } else if (exception is KeyNotFoundException) { statusCode = StatusCodes.Status404NotFound; problemDetails.Status = statusCode; - problemDetails.Title = "Not Found"; + problemDetails.Title = localizer["Error.NotFound"]; problemDetails.Detail = exception.Message; } else if (exception is BadHttpRequestException badRequest) @@ -73,15 +77,15 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e // Client error carrying the correct status (usually 400) — honour it instead of falling through to a generic 500. statusCode = badRequest.StatusCode; problemDetails.Status = statusCode; - problemDetails.Title = "Bad Request"; + problemDetails.Title = localizer["Error.BadRequest"]; problemDetails.Detail = badRequest.Message; } else { statusCode = StatusCodes.Status500InternalServerError; problemDetails.Status = statusCode; - problemDetails.Title = "An unexpected error occurred"; - problemDetails.Detail = "An unexpected error occurred. Please try again later."; + problemDetails.Title = localizer["Error.Unexpected"]; + problemDetails.Detail = localizer["Error.Unexpected.Detail"]; } httpContext.Response.StatusCode = statusCode; diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..c42d340dfe --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Framework.Tests.Localization; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx). Shared by the handler and validator tests +// so they exercise the actual catalog resolution rather than a stub. +internal static class SharedResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs new file mode 100644 index 0000000000..bac9252a8e --- /dev/null +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using FSH.Framework.Web.Exceptions; +using Framework.Tests.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Framework.Tests.Web; + +// Handler-level (Docker-free) proof that GlobalExceptionHandler localizes ProblemDetails titles +// from the shared resx under the ambient UI culture. A raw KeyNotFoundException hits the 404 branch +// whose Title is a catalog string (FSH NotFoundException is a CustomException and instead renders its +// own type name, so it is intentionally not used here). +public sealed class GlobalExceptionHandlerLocalizationTests +{ + private static async Task HandleAndReadTitleAsync(Exception exception, string culture) + { + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + + var context = new DefaultHttpContext(); + context.Request.Path = "/api/v1/test"; + using var body = new MemoryStream(); + context.Response.Body = body; + + var handler = new GlobalExceptionHandler( + NullLogger.Instance, + SharedResourcesLocalizerFactory.Create()); + await handler.TryHandleAsync(context, exception, CancellationToken.None); + + var json = Encoding.UTF8.GetString(body.ToArray()); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("title").GetString(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task NotFound_title_is_localized(string culture, string expected) + { + var title = await HandleAndReadTitleAsync(new KeyNotFoundException("missing"), culture); + title.ShouldBe(expected); + } + + [Theory] + [InlineData("pt-BR", "Ocorreu um erro inesperado")] + [InlineData("en-US", "An unexpected error occurred")] + public async Task Unexpected_title_is_localized(string culture, string expected) + { + var title = await HandleAndReadTitleAsync(new InvalidOperationException("boom"), culture); + title.ShouldBe(expected); + } +} diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs index 5355719f66..5ab20384f7 100644 --- a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Web.Exceptions; +using Framework.Tests.Localization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using System.Net; @@ -14,7 +15,9 @@ private static async Task HandleAsync(Exception exception) context.Request.Path = "/api/v1/identity/forgot-password"; context.Response.Body = new MemoryStream(); - var handler = new GlobalExceptionHandler(NullLogger.Instance); + var handler = new GlobalExceptionHandler( + NullLogger.Instance, + SharedResourcesLocalizerFactory.Create()); await handler.TryHandleAsync(context, exception, CancellationToken.None); return context; } From ef02091df6923944e73d6258306c22e154d576ba Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:17:48 -0300 Subject: [PATCH 07/55] feat(identity): localize UpdateUser validator messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inject IStringLocalizer into UpdateUserCommandValidator and resolve the three custom WithMessage literals lazily (Func overload, so the lookup runs per validation under the request culture, not at construction). Add the keys to both resx catalogs. Built-in FluentValidation messages localize automatically via CurrentUICulture (FV ships a pt catalog) — no LanguageManager wiring needed. Adds a resx key-parity test (neutral vs .pt) and updates the Task 2 validator test to supply a localizer. --- .../Core/Localization/SharedResources.pt.resx | 9 +++ .../Core/Localization/SharedResources.resx | 9 +++ .../UpdateUser/UpdateUserCommandValidator.cs | 9 +-- .../SharedResourcesKeyParityTests.cs | 40 +++++++++++ .../SharedResourcesLocalizerFactory.cs | 18 +++++ .../UpdateUserCommandValidatorTests.cs | 71 ++++++++++++++----- 6 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs create mode 100644 src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx b/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx index 430e7f0fab..a83c05527c 100644 --- a/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx +++ b/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx @@ -76,4 +76,13 @@ Ocorreu um erro inesperado. Tente novamente mais tarde. + + O ID do usuário é obrigatório. + + + Você não pode enviar uma nova imagem e excluir a atual ao mesmo tempo. + + + Idioma não suportado. + diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.resx b/src/BuildingBlocks/Core/Localization/SharedResources.resx index a23ed82d23..7f70fcf5af 100644 --- a/src/BuildingBlocks/Core/Localization/SharedResources.resx +++ b/src/BuildingBlocks/Core/Localization/SharedResources.resx @@ -76,4 +76,13 @@ An unexpected error occurred. Please try again later. + + User ID is required. + + + You cannot upload a new image and delete the current one simultaneously. + + + Unsupported locale. + diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs index e85af5a6f6..8cc79525c4 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -2,16 +2,17 @@ using FSH.Framework.Core.Localization; using FSH.Framework.Storage; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.UpdateUser; public sealed class UpdateUserCommandValidator : AbstractValidator { - public UpdateUserCommandValidator() + public UpdateUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) .NotEmpty() - .WithMessage("User ID is required."); + .WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.FirstName) .MaximumLength(50) @@ -38,11 +39,11 @@ public UpdateUserCommandValidator() // Prevent deleting and uploading image at the same time RuleFor(x => x) .Must(x => !(x.DeleteCurrentImage && x.Image is not null)) - .WithMessage("You cannot upload a new image and delete the current one simultaneously."); + .WithMessage(_ => localizer["Validation.ImageUploadDeleteConflict"]); RuleFor(x => x.Locale) .Must(l => SupportedCultures.Tags.Contains(l!)) .When(x => !string.IsNullOrWhiteSpace(x.Locale)) - .WithMessage("Unsupported locale."); + .WithMessage(_ => localizer["Validation.UnsupportedLocale"]); } } \ No newline at end of file diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs new file mode 100644 index 0000000000..541205c6b7 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Linq; + +namespace Framework.Tests.Localization; + +// Guards against an English key missing from the .pt catalog (a silent English fallback shipped as +// "translated"). Enumerates each culture's own embedded resx (includeParentCultures: false) and +// asserts identical key sets. +public sealed class SharedResourcesKeyParityTests +{ + private static List KeysFor(string culture) + { + var localizer = SharedResourcesLocalizerFactory.Create(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_pt_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // SharedResources.resx (English / fallback) + var pt = KeysFor("pt"); // SharedResources.pt.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } +} diff --git a/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs b/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..fb4b79f15a --- /dev/null +++ b/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,18 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Identity.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog so validator +// tests exercise the actual catalog under the ambient UI culture (default culture -> neutral English). +internal static class SharedResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs index f79c1eb581..1f95633039 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs @@ -1,6 +1,8 @@ +using System.Globalization; using System.Linq; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; using FSH.Modules.Identity.Features.v1.Users.UpdateUser; +using Identity.Tests.Support; using Shouldly; using Xunit; @@ -8,7 +10,21 @@ namespace Identity.Tests.Validators; public sealed class UpdateUserCommandValidatorTests { - private readonly UpdateUserCommandValidator _sut = new(); + private readonly UpdateUserCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); + + private static TResult WithCulture(string culture, Func action) + { + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + return action(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } [Fact] public void Validate_Should_Pass_When_ValidMinimalCommand() @@ -41,10 +57,10 @@ public void Validate_Should_Fail_When_IdIsEmpty() public void Validate_Should_Fail_When_FirstNameExceedsMaxLength() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - FirstName = new string('a', 51) + var command = new UpdateUserCommand + { + Id = "user-123", + FirstName = new string('a', 51) }; // Act @@ -59,10 +75,10 @@ public void Validate_Should_Fail_When_FirstNameExceedsMaxLength() public void Validate_Should_Fail_When_EmailIsInvalid() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - Email = "not-an-email" + var command = new UpdateUserCommand + { + Id = "user-123", + Email = "not-an-email" }; // Act @@ -77,15 +93,15 @@ public void Validate_Should_Fail_When_EmailIsInvalid() public void Validate_Should_Fail_When_DeleteImageAndUploadImage_Simultaneously() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - DeleteCurrentImage = true, - Image = new FSH.Framework.Shared.Storage.FileUploadRequest { FileName = "test.png", Data = [0] } + var command = new UpdateUserCommand + { + Id = "user-123", + DeleteCurrentImage = true, + Image = new FSH.Framework.Shared.Storage.FileUploadRequest { FileName = "test.png", Data = [0] } }; - // Act - var result = _sut.Validate(command); + // Act — pin en-US so the localized message resolves to the neutral (English) catalog. + var result = WithCulture("en-US", () => _sut.Validate(command)); // Assert result.IsValid.ShouldBeFalse(); @@ -109,4 +125,27 @@ public void Locale_Must_Be_Supported_Or_Null(string? locale, bool expectedValid) // Assert result.Errors.Any(e => e.PropertyName == nameof(UpdateUserCommand.Locale)).ShouldBe(!expectedValid); } + + [Fact] + public void UserId_required_message_is_localized_under_ptBR() + { + // Act + var result = WithCulture("pt-BR", () => _sut.Validate(new UpdateUserCommand { Id = "" })); + + // Assert — custom WithMessage resolves from the .pt catalog. + result.Errors.Single(e => e.PropertyName == "Id").ErrorMessage + .ShouldBe("O ID do usuário é obrigatório."); + } + + [Fact] + public void Builtin_validation_message_is_localized_under_ptBR() + { + // Act — FluentValidation resolves built-in messages via CurrentUICulture (ships a pt catalog). + var result = WithCulture("pt-BR", () => + _sut.Validate(new UpdateUserCommand { Id = "user-123", Email = "not-an-email" })); + + // Assert — the English built-in text must NOT leak through under pt-BR. + var message = result.Errors.Single(e => e.PropertyName == "Email").ErrorMessage; + message.ShouldNotContain("is not a valid email address"); + } } From 66629d15f6a80ba7aaaa58af16d257e9bd929aba Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:34:13 -0300 Subject: [PATCH 08/55] feat(identity): localize remaining validator messages --- .../Core/Localization/SharedResources.pt.resx | 99 +++++++++++++++++++ .../Core/Localization/SharedResources.resx | 99 +++++++++++++++++++ .../AddUsersToGroupCommandValidator.cs | 10 +- .../CreateGroupCommandValidator.cs | 10 +- .../DeleteGroupCommandValidator.cs | 6 +- .../RemoveUserFromGroupCommandValidator.cs | 8 +- .../UpdateGroupCommandValidator.cs | 12 ++- .../DeleteRole/DeleteRoleCommandValidator.cs | 6 +- .../Roles/GetRoles/GetRolesQueryValidator.cs | 8 +- .../UpsertRole/UpsertRoleCommandValidator.cs | 6 +- .../AdminRevokeAllSessionsCommandValidator.cs | 8 +- .../AdminRevokeSessionCommandValidator.cs | 10 +- .../GetTenantSessionsValidator.cs | 8 +- .../RevokeSessionCommandValidator.cs | 6 +- .../AdminConfirmEmailCommandValidator.cs | 6 +- .../AssignUserRolesCommandValidator.cs | 8 +- .../ChangePassword/ChangePasswordValidator.cs | 15 +-- .../ConfirmEmailCommandValidator.cs | 10 +- .../DeleteUser/DeleteUserCommandValidator.cs | 6 +- .../RegisterUserCommandValidator.cs | 32 +++--- ...ResendConfirmationEmailCommandValidator.cs | 6 +- .../ToggleUserStatusCommandValidator.cs | 6 +- .../CreateGroupCommandValidatorTests.cs | 3 +- .../DeleteUserCommandValidatorTests.cs | 3 +- .../UpdateGroupCommandValidatorTests.cs | 3 +- .../UpsertRoleCommandValidatorTests.cs | 3 +- 26 files changed, 320 insertions(+), 77 deletions(-) diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx b/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx index a83c05527c..f3ff80ee50 100644 --- a/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx +++ b/src/BuildingBlocks/Core/Localization/SharedResources.pt.resx @@ -85,4 +85,103 @@ Idioma não suportado. + + O ID do grupo é obrigatório. + + + É necessário pelo menos um ID de usuário. + + + Os IDs de usuário não podem ser vazios ou conter apenas espaços. + + + O nome do grupo é obrigatório. + + + O nome do grupo não pode exceder 256 caracteres. + + + A descrição não pode exceder 1024 caracteres. + + + O ID da função é obrigatório. + + + O nome da função é obrigatório. + + + O número da página deve ser maior ou igual a 1. + + + O tamanho da página deve ser maior ou igual a 1. + + + O ID da sessão é obrigatório. + + + O motivo não pode exceder 500 caracteres. + + + A lista de funções do usuário é obrigatória. + + + O código de confirmação é obrigatório. + + + O tenant é obrigatório. + + + A senha atual é obrigatória. + + + A nova senha é obrigatória. + + + A nova senha deve ser diferente da senha atual. + + + Esta senha foi usada recentemente. Escolha uma senha diferente. + + + As senhas não coincidem. + + + O nome é obrigatório. + + + O nome não pode exceder 100 caracteres. + + + O sobrenome é obrigatório. + + + O sobrenome não pode exceder 100 caracteres. + + + O e-mail é obrigatório. + + + É necessário um endereço de e-mail válido. + + + O nome de usuário é obrigatório. + + + O nome de usuário deve ter pelo menos 3 caracteres. + + + O nome de usuário não pode exceder 50 caracteres. + + + A senha é obrigatória. + + + A senha deve ter pelo menos 6 caracteres. + + + A confirmação de senha é obrigatória. + + + O número de telefone não pode exceder 20 caracteres. + diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.resx b/src/BuildingBlocks/Core/Localization/SharedResources.resx index 7f70fcf5af..826f4b029d 100644 --- a/src/BuildingBlocks/Core/Localization/SharedResources.resx +++ b/src/BuildingBlocks/Core/Localization/SharedResources.resx @@ -85,4 +85,103 @@ Unsupported locale. + + Group ID is required. + + + At least one user ID is required. + + + User IDs cannot be empty or whitespace. + + + Group name is required. + + + Group name must not exceed 256 characters. + + + Description must not exceed 1024 characters. + + + Role ID is required. + + + Role name is required. + + + Page number must be greater than or equal to 1. + + + Page size must be greater than or equal to 1. + + + Session ID is required. + + + Reason must not exceed 500 characters. + + + User roles list is required. + + + Confirmation code is required. + + + Tenant is required. + + + Current password is required. + + + New password is required. + + + New password must be different from the current password. + + + This password has been used recently. Please choose a different password. + + + Passwords do not match. + + + First name is required. + + + First name must not exceed 100 characters. + + + Last name is required. + + + Last name must not exceed 100 characters. + + + Email is required. + + + A valid email address is required. + + + Username is required. + + + Username must be at least 3 characters. + + + Username must not exceed 50 characters. + + + Password is required. + + + Password must be at least 6 characters. + + + Password confirmation is required. + + + Phone number must not exceed 20 characters. + diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs index e90a9a564d..6c15fbcb9f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs @@ -1,18 +1,20 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; public sealed class AddUsersToGroupCommandValidator : AbstractValidator { - public AddUsersToGroupCommandValidator() + public AddUsersToGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.UserIds) - .NotEmpty().WithMessage("At least one user ID is required.") + .NotEmpty().WithMessage(_ => localizer["Validation.AtLeastOneUserIdRequired"]) .Must(ids => ids.All(id => !string.IsNullOrWhiteSpace(id))) - .WithMessage("User IDs cannot be empty or whitespace."); + .WithMessage(_ => localizer["Validation.UserIdsNotEmptyOrWhitespace"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs index 42a8dd668e..f112b51976 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; public sealed class CreateGroupCommandValidator : AbstractValidator { - public CreateGroupCommandValidator() + public CreateGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupNameRequired"]) + .MaximumLength(256).WithMessage(_ => localizer["Validation.GroupNameMaxLength"]); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(_ => localizer["Validation.DescriptionMaxLength"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs index 4805b8769a..4d2834c5a7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; public sealed class DeleteGroupCommandValidator : AbstractValidator { - public DeleteGroupCommandValidator() + public DeleteGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs index da5ce2bd3d..418b3210ff 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; public sealed class RemoveUserFromGroupCommandValidator : AbstractValidator { - public RemoveUserFromGroupCommandValidator() + public RemoveUserFromGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs index 4c111e0c05..3442077ddc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs @@ -1,20 +1,22 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; public sealed class UpdateGroupCommandValidator : AbstractValidator { - public UpdateGroupCommandValidator() + public UpdateGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupNameRequired"]) + .MaximumLength(256).WithMessage(_ => localizer["Validation.GroupNameMaxLength"]); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(_ => localizer["Validation.DescriptionMaxLength"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs index bf213c86a5..a015186dbc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.DeleteRole; public sealed class DeleteRoleCommandValidator : AbstractValidator { - public DeleteRoleCommandValidator() + public DeleteRoleCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Role ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.RoleIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs index 183ad47f34..5a51837f1a 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.GetRoles; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.GetRoles; public sealed class GetRolesQueryValidator : AbstractValidator { - public GetRolesQueryValidator() + public GetRolesQueryValidator(IStringLocalizer localizer) { RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(x => x.PageSize) - .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageSizeMinimum"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs index e206420a88..19ce9f01f9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs @@ -1,12 +1,14 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; public sealed class UpsertRoleCommandValidator : AbstractValidator { - public UpsertRoleCommandValidator() + public UpsertRoleCommandValidator(IStringLocalizer localizer) { - RuleFor(x => x.Name).NotEmpty().WithMessage("Role name is required."); + RuleFor(x => x.Name).NotEmpty().WithMessage(_ => localizer["Validation.RoleNameRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs index f9b8449cb6..3e642608fb 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; public sealed class AdminRevokeAllSessionsCommandValidator : AbstractValidator { - public AdminRevokeAllSessionsCommandValidator() + public AdminRevokeAllSessionsCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(_ => localizer["Validation.ReasonMaxLength"]) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs index 8e59cb4ec9..f4edf82bbf 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs @@ -1,20 +1,22 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; public sealed class AdminRevokeSessionCommandValidator : AbstractValidator { - public AdminRevokeSessionCommandValidator() + public AdminRevokeSessionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.SessionIdRequired"]); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(_ => localizer["Validation.ReasonMaxLength"]) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs index 06b3786ba8..95bbf174d7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.GetTenantSessions; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.GetTenantSessions; public sealed class GetTenantSessionsValidator : AbstractValidator { - public GetTenantSessionsValidator() + public GetTenantSessionsValidator(IStringLocalizer localizer) { RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(x => x.PageSize) - .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageSizeMinimum"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs index c0ceb12b33..d5e79ba14d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; public sealed class RevokeSessionCommandValidator : AbstractValidator { - public RevokeSessionCommandValidator() + public RevokeSessionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.SessionIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs index 71523b2f76..2c606b98b3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.AdminConfirmEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.AdminConfirmEmail; public sealed class AdminConfirmEmailCommandValidator : AbstractValidator { - public AdminConfirmEmailCommandValidator() + public AdminConfirmEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs index 703d43d398..500c4242b4 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.AssignUserRoles; public sealed class AssignUserRolesCommandValidator : AbstractValidator { - public AssignUserRolesCommandValidator() + public AssignUserRolesCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.UserRoles) - .NotNull().WithMessage("User roles list is required."); + .NotNull().WithMessage(_ => localizer["Validation.UserRolesRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs index 0581ae1878..6563029bb8 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs @@ -1,7 +1,9 @@ using FluentValidation; using FSH.Framework.Core.Context; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ChangePassword; @@ -12,26 +14,27 @@ public sealed class ChangePasswordValidator : AbstractValidator localizer) { _passwordHistoryService = passwordHistoryService; _currentUser = currentUser; RuleFor(p => p.Password) .NotEmpty() - .WithMessage("Current password is required."); + .WithMessage(_ => localizer["Validation.CurrentPasswordRequired"]); RuleFor(p => p.NewPassword) .NotEmpty() - .WithMessage("New password is required.") + .WithMessage(_ => localizer["Validation.NewPasswordRequired"]) .NotEqual(p => p.Password) - .WithMessage("New password must be different from the current password.") + .WithMessage(_ => localizer["Validation.NewPasswordMustDiffer"]) .MustAsync(NotBeInPasswordHistoryAsync) - .WithMessage("This password has been used recently. Please choose a different password."); + .WithMessage(_ => localizer["Validation.PasswordRecentlyUsed"]); RuleFor(p => p.ConfirmNewPassword) .Equal(p => p.NewPassword) - .WithMessage("Passwords do not match."); + .WithMessage(_ => localizer["Validation.PasswordsDoNotMatch"]); } private async Task NotBeInPasswordHistoryAsync(string newPassword, CancellationToken cancellationToken) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs index 54805a491b..d8b599e9fe 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs @@ -1,19 +1,21 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; public sealed class ConfirmEmailCommandValidator : AbstractValidator { - public ConfirmEmailCommandValidator() + public ConfirmEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.Code) - .NotEmpty().WithMessage("Confirmation code is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.ConfirmationCodeRequired"]); RuleFor(x => x.Tenant) - .NotEmpty().WithMessage("Tenant is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.TenantRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs index f5410d84ac..f73908b372 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.DeleteUser; public sealed class DeleteUserCommandValidator : AbstractValidator { - public DeleteUserCommandValidator() + public DeleteUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs index 54d774b2da..f52db83b99 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs @@ -1,39 +1,41 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.RegisterUser; public sealed class RegisterUserCommandValidator : AbstractValidator { - public RegisterUserCommandValidator() + public RegisterUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.FirstName) - .NotEmpty().WithMessage("First name is required.") - .MaximumLength(100).WithMessage("First name must not exceed 100 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.FirstNameRequired"]) + .MaximumLength(100).WithMessage(_ => localizer["Validation.FirstNameMaxLength"]); RuleFor(x => x.LastName) - .NotEmpty().WithMessage("Last name is required.") - .MaximumLength(100).WithMessage("Last name must not exceed 100 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.LastNameRequired"]) + .MaximumLength(100).WithMessage(_ => localizer["Validation.LastNameMaxLength"]); RuleFor(x => x.Email) - .NotEmpty().WithMessage("Email is required.") - .EmailAddress().WithMessage("A valid email address is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.EmailRequired"]) + .EmailAddress().WithMessage(_ => localizer["Validation.EmailInvalid"]); RuleFor(x => x.UserName) - .NotEmpty().WithMessage("Username is required.") - .MinimumLength(3).WithMessage("Username must be at least 3 characters.") - .MaximumLength(50).WithMessage("Username must not exceed 50 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.UsernameRequired"]) + .MinimumLength(3).WithMessage(_ => localizer["Validation.UsernameMinLength"]) + .MaximumLength(50).WithMessage(_ => localizer["Validation.UsernameMaxLength"]); RuleFor(x => x.Password) - .NotEmpty().WithMessage("Password is required.") - .MinimumLength(6).WithMessage("Password must be at least 6 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.PasswordRequired"]) + .MinimumLength(6).WithMessage(_ => localizer["Validation.PasswordMinLength"]); RuleFor(x => x.ConfirmPassword) - .NotEmpty().WithMessage("Password confirmation is required.") - .Equal(x => x.Password).WithMessage("Passwords do not match."); + .NotEmpty().WithMessage(_ => localizer["Validation.PasswordConfirmationRequired"]) + .Equal(x => x.Password).WithMessage(_ => localizer["Validation.PasswordsDoNotMatch"]); RuleFor(x => x.PhoneNumber) - .MaximumLength(20).WithMessage("Phone number must not exceed 20 characters.") + .MaximumLength(20).WithMessage(_ => localizer["Validation.PhoneNumberMaxLength"]) .When(x => x.PhoneNumber is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs index 34d29f7b1b..28cd866e23 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ResendConfirmationEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ResendConfirmationEmail; public sealed class ResendConfirmationEmailCommandValidator : AbstractValidator { - public ResendConfirmationEmailCommandValidator() + public ResendConfirmationEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs index 4eece88de2..2df3f36a54 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; public sealed class ToggleUserStatusCommandValidator : AbstractValidator { - public ToggleUserStatusCommandValidator() + public ToggleUserStatusCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs index 72e8a0e36f..de8a53d385 100644 --- a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; using FSH.Modules.Identity.Features.v1.Groups.CreateGroup; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class CreateGroupCommandValidatorTests { - private readonly CreateGroupCommandValidator _sut = new(); + private readonly CreateGroupCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Name Validation diff --git a/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs index 399d4de894..d85b145aa6 100644 --- a/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; using FSH.Modules.Identity.Features.v1.Users.DeleteUser; +using Identity.Tests.Support; using Shouldly; using Xunit; @@ -7,7 +8,7 @@ namespace Identity.Tests.Validators; public sealed class DeleteUserCommandValidatorTests { - private readonly DeleteUserCommandValidator _sut = new(); + private readonly DeleteUserCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); [Fact] public void Validate_Should_Pass_When_IdIsProvided() diff --git a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs index c0c1cdb2b9..305a1c76d7 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; using FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class UpdateGroupCommandValidatorTests { - private readonly UpdateGroupCommandValidator _sut = new(); + private readonly UpdateGroupCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Id Validation diff --git a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs index 3fe0559c2c..500b8f7a54 100644 --- a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; using FSH.Modules.Identity.Features.v1.Roles.UpsertRole; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class UpsertRoleCommandValidatorTests { - private readonly UpsertRoleCommandValidator _sut = new(); + private readonly UpsertRoleCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Name Validation From d3de5964d08d105512a4b93ecca71447491612a7 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:46:23 -0300 Subject: [PATCH 09/55] feat(admin): react-i18next bootstrap with en-US/pt-BR common catalog --- clients/admin/package-lock.json | 105 +++++++++++++++++++- clients/admin/package.json | 3 + clients/admin/public/config.json | 3 +- clients/admin/src/env.ts | 4 + clients/admin/src/i18n.ts | 43 ++++++++ clients/admin/src/locales/en-US/common.json | 5 + clients/admin/src/locales/pt-BR/common.json | 5 + clients/admin/src/main.tsx | 7 +- 8 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 clients/admin/src/i18n.ts create mode 100644 clients/admin/src/locales/en-US/common.json create mode 100644 clients/admin/src/locales/pt-BR/common.json diff --git a/clients/admin/package-lock.json b/clients/admin/package-lock.json index da4eb2be37..221b76c7f4 100644 --- a/clients/admin/package-lock.json +++ b/clients/admin/package-lock.json @@ -18,11 +18,14 @@ "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.475.0", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-i18next": "^17.0.10", "react-router-dom": "^7.1.5", "sonner": "^2.0.7", "tailwind-merge": "^3.0.1", @@ -284,6 +287,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -4523,6 +4535,52 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5911,6 +5969,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", + "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -6722,7 +6807,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6884,6 +6969,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", @@ -6959,6 +7053,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/clients/admin/package.json b/clients/admin/package.json index 80b8a4570e..99b46aaa54 100644 --- a/clients/admin/package.json +++ b/clients/admin/package.json @@ -22,11 +22,14 @@ "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.475.0", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-i18next": "^17.0.10", "react-router-dom": "^7.1.5", "sonner": "^2.0.7", "tailwind-merge": "^3.0.1", diff --git a/clients/admin/public/config.json b/clients/admin/public/config.json index 015dc0c1a2..c760e9d765 100644 --- a/clients/admin/public/config.json +++ b/clients/admin/public/config.json @@ -3,5 +3,6 @@ "defaultTenant": "root", "dashboardUrl": "http://localhost:5174", "inactivityIdleMs": 600000, - "inactivityWarningMs": 60000 + "inactivityWarningMs": 60000, + "defaultLanguage": "en-US" } diff --git a/clients/admin/src/env.ts b/clients/admin/src/env.ts index b911ffefb4..b5d0e0583a 100644 --- a/clients/admin/src/env.ts +++ b/clients/admin/src/env.ts @@ -10,6 +10,8 @@ type RuntimeConfig = { inactivityIdleMs: number; /** Warning-countdown length (ms) before auto sign-out. */ inactivityWarningMs: number; + /** Per-deployment default UI language (BCP 47); the i18n fallback when nothing is persisted/detected. */ + defaultLanguage: string; }; // Admin defaults: 10 minutes idle, then a 60-second warning. @@ -38,6 +40,7 @@ export async function loadRuntimeConfig(): Promise { dashboardUrl: (cfg.dashboardUrl ?? "http://localhost:5174").replace(/\/$/, ""), inactivityIdleMs: positiveOr(cfg.inactivityIdleMs, DEFAULT_INACTIVITY_IDLE_MS), inactivityWarningMs: positiveOr(cfg.inactivityWarningMs, DEFAULT_INACTIVITY_WARNING_MS), + defaultLanguage: cfg.defaultLanguage ?? "en-US", }; } @@ -56,4 +59,5 @@ export const env = { get dashboardUrl(): string { return get().dashboardUrl; }, get inactivityIdleMs(): number { return get().inactivityIdleMs; }, get inactivityWarningMs(): number { return get().inactivityWarningMs; }, + get defaultLanguage(): string { return get().defaultLanguage; }, }; diff --git a/clients/admin/src/i18n.ts b/clients/admin/src/i18n.ts new file mode 100644 index 0000000000..2afa37386f --- /dev/null +++ b/clients/admin/src/i18n.ts @@ -0,0 +1,43 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; +import enCommon from "@/locales/en-US/common.json"; +import ptCommon from "@/locales/pt-BR/common.json"; + +// Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries). +export const SUPPORTED = ["en-US", "pt-BR"] as const; + +// i18next's nonExplicitSupportedLngs does NOT rewrite pt-PT->pt-BR. Normalize explicitly via +// convertDetectedLanguage: map any variant onto a canonical tag by its language part. +const CANON: Record = { pt: "pt-BR", en: "en-US" }; +const toCanonical = (lng: string) => + (SUPPORTED as readonly string[]).includes(lng) ? lng : (CANON[lng.split("-")[0]] ?? lng); + +// Called from main.tsx AFTER loadRuntimeConfig(), so fallbackLng reads the per-deployment +// default: the browser/persisted locale wins, the deployment default is only the fallback. +export function initI18n(deploymentDefault: string) { + return i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + "en-US": { common: enCommon }, + "pt-BR": { common: ptCommon }, + }, + fallbackLng: (SUPPORTED as readonly string[]).includes(deploymentDefault) + ? deploymentDefault + : "en-US", + supportedLngs: [...SUPPORTED], + defaultNS: "common", + interpolation: { escapeValue: false }, + detection: { + // NO cookie — localStorage only (the library default; key i18nextLng). + order: ["querystring", "localStorage", "navigator"], + caches: ["localStorage"], + lookupQuerystring: "culture", + convertDetectedLanguage: toCanonical, // pt/pt-PT->pt-BR, en/en-GB->en-US + }, + }); +} + +export default i18n; diff --git a/clients/admin/src/locales/en-US/common.json b/clients/admin/src/locales/en-US/common.json new file mode 100644 index 0000000000..d7a107b97b --- /dev/null +++ b/clients/admin/src/locales/en-US/common.json @@ -0,0 +1,5 @@ +{ + "language": "Language", + "language.enUS": "English (US)", + "language.ptBR": "Português (BR)" +} diff --git a/clients/admin/src/locales/pt-BR/common.json b/clients/admin/src/locales/pt-BR/common.json new file mode 100644 index 0000000000..f56f7f5d41 --- /dev/null +++ b/clients/admin/src/locales/pt-BR/common.json @@ -0,0 +1,5 @@ +{ + "language": "Idioma", + "language.enUS": "English (US)", + "language.ptBR": "Português (BR)" +} diff --git a/clients/admin/src/main.tsx b/clients/admin/src/main.tsx index 5af6553b6f..6394f0f50a 100644 --- a/clients/admin/src/main.tsx +++ b/clients/admin/src/main.tsx @@ -1,7 +1,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "@/App"; -import { loadRuntimeConfig } from "@/env"; +import { env, loadRuntimeConfig } from "@/env"; +import { initI18n } from "@/i18n"; import "@/styles/globals.css"; // Runtime config must be in-memory BEFORE any module that reads env.* @@ -9,6 +10,10 @@ import "@/styles/globals.css"; // output and is the simplest shape that guarantees ordering. await loadRuntimeConfig(); +// i18n boots AFTER config so fallbackLng can read the per-deployment default; +// the persisted/detected locale still wins over it. +await initI18n(env.defaultLanguage); + const rootElement = document.getElementById("root"); if (!rootElement) { throw new Error("Root element '#root' not found"); From 01b8f5f9b59e8f715d75cfd87ce8f26e9a992951 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:46:32 -0300 Subject: [PATCH 10/55] feat(admin): send Accept-Language and hydrate UI from persisted User.locale --- clients/admin/src/api/users.ts | 2 + .../admin/src/components/layout/topbar.tsx | 12 +++- clients/admin/src/lib/api-client.ts | 8 +++ clients/admin/tests/i18n/i18n.spec.ts | 66 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 clients/admin/tests/i18n/i18n.spec.ts diff --git a/clients/admin/src/api/users.ts b/clients/admin/src/api/users.ts index e082822225..4497ed4662 100644 --- a/clients/admin/src/api/users.ts +++ b/clients/admin/src/api/users.ts @@ -12,6 +12,8 @@ export type UserDto = { phoneNumber?: string | null; imageUrl?: string | null; twoFactorEnabled?: boolean; + /** Persisted BCP 47 UI language tag (e.g. "pt-BR"); null when the user never chose. */ + locale?: string | null; }; export type UserRoleDto = { diff --git a/clients/admin/src/components/layout/topbar.tsx b/clients/admin/src/components/layout/topbar.tsx index 575d980d84..21b7b6f89d 100644 --- a/clients/admin/src/components/layout/topbar.tsx +++ b/clients/admin/src/components/layout/topbar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { @@ -32,6 +32,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar } from "@/components/ui/avatar"; import { useAuth } from "@/auth/use-auth"; +import i18n from "@/i18n"; import { getMyProfile } from "@/api/users"; import { useTheme } from "@/components/theme/theme-provider"; import { cn } from "@/lib/cn"; @@ -161,6 +162,15 @@ export function Topbar() { const avatarUrl = profile.data?.imageUrl ?? null; const displayName = user?.name ?? user?.email ?? "Unknown"; + // Hydrate the UI language from the server-persisted locale once the profile + // loads, so a locale chosen on another device carries over on this one. + const persistedLocale = profile.data?.locale; + useEffect(() => { + if (persistedLocale && persistedLocale !== i18n.language) { + void i18n.changeLanguage(persistedLocale); + } + }, [persistedLocale]); + const onConfirmSignOut = () => { setConfirmOpen(false); logout(); diff --git a/clients/admin/src/lib/api-client.ts b/clients/admin/src/lib/api-client.ts index 7f666cf9f6..91c811b806 100644 --- a/clients/admin/src/lib/api-client.ts +++ b/clients/admin/src/lib/api-client.ts @@ -1,4 +1,5 @@ import { env } from "@/env"; +import i18n from "@/i18n"; import { tokenStore } from "@/auth/token-store"; export type ApiError = { @@ -139,6 +140,13 @@ export async function apiFetch( mergedHeaders.set("tenant", tenant); } + // Tell the backend which culture to localize responses in. The active UI + // locale drives it; the backend resolution chain still falls through to its + // own default for anything unsupported. + if (!mergedHeaders.has("Accept-Language")) { + mergedHeaders.set("Accept-Language", i18n.language || "en-US"); + } + const url = path.startsWith("http") ? path : `${env.apiBase}${path}`; let response = await fetch(url, { ...rest, diff --git a/clients/admin/tests/i18n/i18n.spec.ts b/clients/admin/tests/i18n/i18n.spec.ts new file mode 100644 index 0000000000..6bc857a8ee --- /dev/null +++ b/clients/admin/tests/i18n/i18n.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; +import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks"; +import { mockJsonResponse } from "../helpers/api-mocks"; + +// These specs cover Task 8 (i18n bootstrap) + Task 9 (Accept-Language header). +// The dashboard route ("/") renders the AppShell + Topbar; the Topbar's profile +// menu button is a stable "the app mounted" signal. main.tsx awaits initI18n() +// before mounting React, so a boot failure there would leave nothing to find. + +test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); + await installAdminShellMocks(page); + + // Dashboard load endpoints (page content); topbar renders regardless, but + // mocking these keeps the route quiet and deterministic. + await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 0 })); + await mockJsonResponse(page, "**/api/v1/billing/plans**", []); + await mockJsonResponse(page, "**/api/v1/billing/invoices**", paged([], { totalCount: 0 })); +}); + +test.describe("i18n", () => { + test("app boots with i18n initialized", async ({ page }) => { + await page.goto("/"); + + // The Topbar (which consumes the i18n instance for the profile-locale sync) + // rendered → initI18n() resolved and React mounted. + await expect( + page.getByRole("button", { name: /open profile menu/i }), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("apiFetch sends Accept-Language matching the active locale", async ({ page }) => { + let seenLang: string | null = null; + + // Registered AFTER installAdminShellMocks so this handler wins (LIFO) and + // can inspect the request header. Return a profile whose locale matches the + // default active locale so the sync effect does not switch languages. + await page.route("**/api/v1/identity/profile", async (route) => { + if (route.request().method() !== "GET") { + await route.fallback(); + return; + } + seenLang = route.request().headers()["accept-language"] ?? null; + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "u-test-1", + isActive: true, + emailConfirmed: true, + locale: "en-US", + }), + }); + }); + + const profileReq = page.waitForRequest( + (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "GET", + { timeout: 10_000 }, + ); + await page.goto("/"); + await profileReq; + + expect(seenLang).toBe("en-US"); + }); +}); From 27a60844a337101574818bf7ddc9b20d768f05e1 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:57:35 -0300 Subject: [PATCH 11/55] feat(dashboard): react-i18next i18n setup, Accept-Language and locale sync --- clients/dashboard/package-lock.json | 105 +++++++++++++++++- clients/dashboard/package.json | 3 + clients/dashboard/public/config.json | 3 +- clients/dashboard/src/api/identity.ts | 1 + .../src/components/layout/topbar.tsx | 11 ++ clients/dashboard/src/env.ts | 4 + clients/dashboard/src/i18n.ts | 43 +++++++ clients/dashboard/src/lib/api-client.ts | 8 ++ .../dashboard/src/locales/en-US/common.json | 5 + .../dashboard/src/locales/pt-BR/common.json | 5 + clients/dashboard/src/main.tsx | 7 +- clients/dashboard/tests/i18n/i18n.spec.ts | 59 ++++++++++ 12 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 clients/dashboard/src/i18n.ts create mode 100644 clients/dashboard/src/locales/en-US/common.json create mode 100644 clients/dashboard/src/locales/pt-BR/common.json create mode 100644 clients/dashboard/tests/i18n/i18n.spec.ts diff --git a/clients/dashboard/package-lock.json b/clients/dashboard/package-lock.json index cfe3247a57..929e96dec1 100644 --- a/clients/dashboard/package-lock.json +++ b/clients/dashboard/package-lock.json @@ -18,10 +18,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.475.0", "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-i18next": "^17.0.10", "react-router-dom": "^7.15.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" @@ -282,6 +285,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -4815,6 +4827,52 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6183,6 +6241,33 @@ "react": "^19.2.6" } }, + "node_modules/react-i18next": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", + "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -7000,7 +7085,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7163,6 +7248,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", @@ -7238,6 +7332,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/clients/dashboard/package.json b/clients/dashboard/package.json index 0ccc2d8485..13fbbdf435 100644 --- a/clients/dashboard/package.json +++ b/clients/dashboard/package.json @@ -22,10 +22,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.475.0", "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-i18next": "^17.0.10", "react-router-dom": "^7.15.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" diff --git a/clients/dashboard/public/config.json b/clients/dashboard/public/config.json index 2245e87ae2..5af9d5c9b3 100644 --- a/clients/dashboard/public/config.json +++ b/clients/dashboard/public/config.json @@ -3,5 +3,6 @@ "defaultTenant": "root", "demoMode": true, "inactivityIdleMs": 1200000, - "inactivityWarningMs": 60000 + "inactivityWarningMs": 60000, + "defaultLanguage": "en-US" } diff --git a/clients/dashboard/src/api/identity.ts b/clients/dashboard/src/api/identity.ts index 3297c8c18a..c9c2348f73 100644 --- a/clients/dashboard/src/api/identity.ts +++ b/clients/dashboard/src/api/identity.ts @@ -16,6 +16,7 @@ export type UserDto = { phoneNumber?: string; imageUrl?: string; twoFactorEnabled?: boolean; + locale?: string | null; }; export type UserRoleDto = { diff --git a/clients/dashboard/src/components/layout/topbar.tsx b/clients/dashboard/src/components/layout/topbar.tsx index 0383d28e30..cf4e2e174f 100644 --- a/clients/dashboard/src/components/layout/topbar.tsx +++ b/clients/dashboard/src/components/layout/topbar.tsx @@ -37,6 +37,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar } from "@/components/ui/avatar"; import { getMyProfile } from "@/api/identity"; +import i18n from "@/i18n"; import { useAuth } from "@/auth/use-auth"; import { useSseStatus } from "@/sse/sse-context"; import { useTheme } from "@/components/theme/theme-provider"; @@ -156,6 +157,16 @@ export function Topbar() { staleTime: 5 * 60 * 1000, }); const avatarUrl = profile?.imageUrl ?? null; + + // Hydrate the UI language from the server-persisted locale once the profile + // loads, so a locale chosen on another device carries over on this one. + const persistedLocale = profile?.locale; + useEffect(() => { + if (persistedLocale && persistedLocale !== i18n.language) { + void i18n.changeLanguage(persistedLocale); + } + }, [persistedLocale]); + const { status: sseStatus, eventCount } = useSseStatus(); const { mode, setMode } = useTheme(); const { setOpen: setPaletteOpen } = useCommandPalette(); diff --git a/clients/dashboard/src/env.ts b/clients/dashboard/src/env.ts index e56dd35480..e021c159db 100644 --- a/clients/dashboard/src/env.ts +++ b/clients/dashboard/src/env.ts @@ -15,6 +15,8 @@ type RuntimeConfig = { inactivityIdleMs: number; /** Warning-countdown length (ms) before auto sign-out. */ inactivityWarningMs: number; + /** Per-deployment default UI language (BCP 47); the i18n fallback when nothing is persisted/detected. */ + defaultLanguage: string; }; // Dashboard defaults: 20 minutes idle, then a 60-second warning. @@ -41,6 +43,7 @@ export async function loadRuntimeConfig(): Promise { demoMode: cfg.demoMode ?? false, inactivityIdleMs: positiveOr(cfg.inactivityIdleMs, DEFAULT_INACTIVITY_IDLE_MS), inactivityWarningMs: positiveOr(cfg.inactivityWarningMs, DEFAULT_INACTIVITY_WARNING_MS), + defaultLanguage: cfg.defaultLanguage ?? "en-US", }; } @@ -59,4 +62,5 @@ export const env = { get demoMode(): boolean { return get().demoMode; }, get inactivityIdleMs(): number { return get().inactivityIdleMs; }, get inactivityWarningMs(): number { return get().inactivityWarningMs; }, + get defaultLanguage(): string { return get().defaultLanguage; }, }; diff --git a/clients/dashboard/src/i18n.ts b/clients/dashboard/src/i18n.ts new file mode 100644 index 0000000000..2afa37386f --- /dev/null +++ b/clients/dashboard/src/i18n.ts @@ -0,0 +1,43 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; +import enCommon from "@/locales/en-US/common.json"; +import ptCommon from "@/locales/pt-BR/common.json"; + +// Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries). +export const SUPPORTED = ["en-US", "pt-BR"] as const; + +// i18next's nonExplicitSupportedLngs does NOT rewrite pt-PT->pt-BR. Normalize explicitly via +// convertDetectedLanguage: map any variant onto a canonical tag by its language part. +const CANON: Record = { pt: "pt-BR", en: "en-US" }; +const toCanonical = (lng: string) => + (SUPPORTED as readonly string[]).includes(lng) ? lng : (CANON[lng.split("-")[0]] ?? lng); + +// Called from main.tsx AFTER loadRuntimeConfig(), so fallbackLng reads the per-deployment +// default: the browser/persisted locale wins, the deployment default is only the fallback. +export function initI18n(deploymentDefault: string) { + return i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + "en-US": { common: enCommon }, + "pt-BR": { common: ptCommon }, + }, + fallbackLng: (SUPPORTED as readonly string[]).includes(deploymentDefault) + ? deploymentDefault + : "en-US", + supportedLngs: [...SUPPORTED], + defaultNS: "common", + interpolation: { escapeValue: false }, + detection: { + // NO cookie — localStorage only (the library default; key i18nextLng). + order: ["querystring", "localStorage", "navigator"], + caches: ["localStorage"], + lookupQuerystring: "culture", + convertDetectedLanguage: toCanonical, // pt/pt-PT->pt-BR, en/en-GB->en-US + }, + }); +} + +export default i18n; diff --git a/clients/dashboard/src/lib/api-client.ts b/clients/dashboard/src/lib/api-client.ts index 417eab0ca8..d5a3ed0138 100644 --- a/clients/dashboard/src/lib/api-client.ts +++ b/clients/dashboard/src/lib/api-client.ts @@ -1,4 +1,5 @@ import { env } from "@/env"; +import i18n from "@/i18n"; import { tokenStore } from "@/auth/token-store"; import { decodeJwt } from "@/auth/jwt"; @@ -186,6 +187,13 @@ export async function apiFetch( mergedHeaders.set("tenant", tenant); } + // Tell the backend which culture to localize responses in. The active UI + // locale drives it; the backend resolution chain still falls through to its + // own default for anything unsupported. + if (!mergedHeaders.has("Accept-Language")) { + mergedHeaders.set("Accept-Language", i18n.language || "en-US"); + } + const url = path.startsWith("http") ? path : `${env.apiBase}${path}`; const initialTimer = withTimeout({ ...rest, headers: mergedHeaders }, timeoutMs, signal); let response: Response; diff --git a/clients/dashboard/src/locales/en-US/common.json b/clients/dashboard/src/locales/en-US/common.json new file mode 100644 index 0000000000..d7a107b97b --- /dev/null +++ b/clients/dashboard/src/locales/en-US/common.json @@ -0,0 +1,5 @@ +{ + "language": "Language", + "language.enUS": "English (US)", + "language.ptBR": "Português (BR)" +} diff --git a/clients/dashboard/src/locales/pt-BR/common.json b/clients/dashboard/src/locales/pt-BR/common.json new file mode 100644 index 0000000000..f56f7f5d41 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/common.json @@ -0,0 +1,5 @@ +{ + "language": "Idioma", + "language.enUS": "English (US)", + "language.ptBR": "Português (BR)" +} diff --git a/clients/dashboard/src/main.tsx b/clients/dashboard/src/main.tsx index a9db7cf51e..0c6975f570 100644 --- a/clients/dashboard/src/main.tsx +++ b/clients/dashboard/src/main.tsx @@ -2,13 +2,18 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "@/App"; import { installImpersonationFromHash } from "@/auth/impersonation-handoff"; -import { loadRuntimeConfig } from "@/env"; +import { env, loadRuntimeConfig } from "@/env"; +import { initI18n } from "@/i18n"; import "@/styles/globals.css"; // Runtime config must resolve before React mounts so env.apiBase reads // inside components see the right value on first paint. await loadRuntimeConfig(); +// i18n boots AFTER config so fallbackLng can read the per-deployment default; +// the persisted/detected locale still wins over it. +await initI18n(env.defaultLanguage); + const rootElement = document.getElementById("root"); if (!rootElement) { throw new Error("Root element '#root' not found"); diff --git a/clients/dashboard/tests/i18n/i18n.spec.ts b/clients/dashboard/tests/i18n/i18n.spec.ts new file mode 100644 index 0000000000..81bd2b23c7 --- /dev/null +++ b/clients/dashboard/tests/i18n/i18n.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; +import { installShellMocks } from "../helpers/shell-mocks"; + +// These specs cover Task 8 (i18n bootstrap) + Task 9 (Accept-Language header). +// Any authenticated route renders the AppShell + Topbar; the Topbar's profile +// menu button is a stable "the app mounted" signal. main.tsx awaits initI18n() +// before mounting React, so a boot failure there would leave nothing to find. + +test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, TEST_USER); + await installShellMocks(page); +}); + +test.describe("i18n", () => { + test("app boots with i18n initialized", async ({ page }) => { + await page.goto("/"); + + // The Topbar (which consumes the i18n instance for the profile-locale sync) + // rendered → initI18n() resolved and React mounted. + await expect( + page.getByRole("button", { name: /open profile menu/i }), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("apiFetch sends Accept-Language matching the active locale", async ({ page }) => { + let seenLang: string | null = null; + + // Registered AFTER installShellMocks so this handler wins (LIFO) and can + // inspect the request header. Return a profile whose locale matches the + // default active locale so the sync effect does not switch languages. + await page.route("**/api/v1/identity/profile", async (route) => { + if (route.request().method() !== "GET") { + await route.fallback(); + return; + } + seenLang = route.request().headers()["accept-language"] ?? null; + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "u-test-1", + isActive: true, + emailConfirmed: true, + locale: "en-US", + }), + }); + }); + + const profileReq = page.waitForRequest( + (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "GET", + { timeout: 10_000 }, + ); + await page.goto("/"); + await profileReq; + + expect(seenLang).toBe("en-US"); + }); +}); From 246707be3e6169dd63025c69626d885fff4b132b Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:06:45 -0300 Subject: [PATCH 12/55] feat(admin): topbar language switcher Add a Language section to the profile dropdown, mirroring the Theme section: one item per SUPPORTED locale with an active-locale check. Selecting a locale calls i18n.changeLanguage and persists it via a new updateMyProfile mutation (PUT /identity/profile). The locale travels through the mutate argument (frontend rule #9). Current name/phone are echoed to avoid the backend wiping FirstName/LastName on a locale-only save. onSuccess triggers a best-effort token refresh so the new locale JWT claim is minted. --- clients/admin/src/api/users.ts | 22 ++++ .../admin/src/components/layout/topbar.tsx | 85 +++++++++++++- clients/admin/tests/i18n/switcher.spec.ts | 106 ++++++++++++++++++ 3 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 clients/admin/tests/i18n/switcher.spec.ts diff --git a/clients/admin/src/api/users.ts b/clients/admin/src/api/users.ts index 4497ed4662..38163be3ef 100644 --- a/clients/admin/src/api/users.ts +++ b/clients/admin/src/api/users.ts @@ -78,6 +78,28 @@ export async function setProfileImage(imageUrl: string | null): Promise { }); } +export type UpdateMyProfileInput = { + firstName?: string | null; + lastName?: string | null; + phoneNumber?: string | null; + /** BCP 47 UI language tag persisted on the user (drives the JWT locale claim). */ + locale?: string | null; +}; + +/** + * Self-update of the authenticated user's profile (PUT /identity/profile, + * server forces the id to the caller). The backend sets FirstName/LastName + * unconditionally from the command, so callers that only mean to change one + * field (e.g. the language switcher) must echo the current values to avoid + * wiping the others. + */ +export async function updateMyProfile(input: UpdateMyProfileInput): Promise { + await apiFetch(`${IDENTITY}/profile`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + export async function changePassword(input: { password: string; newPassword: string; diff --git a/clients/admin/src/components/layout/topbar.tsx b/clients/admin/src/components/layout/topbar.tsx index 21b7b6f89d..ae80315041 100644 --- a/clients/admin/src/components/layout/topbar.tsx +++ b/clients/admin/src/components/layout/topbar.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { Check, ChevronsUpDown, + Languages, LogOut, Moon, Settings as SettingsIcon, @@ -32,8 +34,9 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar } from "@/components/ui/avatar"; import { useAuth } from "@/auth/use-auth"; -import i18n from "@/i18n"; -import { getMyProfile } from "@/api/users"; +import i18n, { SUPPORTED } from "@/i18n"; +import { getMyProfile, updateMyProfile } from "@/api/users"; +import { refreshAccessToken } from "@/lib/api-client"; import { useTheme } from "@/components/theme/theme-provider"; import { cn } from "@/lib/cn"; @@ -119,6 +122,36 @@ function ThemeMenuItem({ ); } +function LanguageMenuItem({ + label, + active, + onSelect, +}: { + label: string; + active: boolean; + onSelect: () => void; +}) { + return ( + { + // Keep the menu open on select so the switch reflects in place (the + // section label localizes immediately) instead of the menu closing. + e.preventDefault(); + onSelect(); + }} + className="!my-0 flex cursor-pointer items-center gap-2.5 rounded-md !px-2.5 !py-1.5" + > + + + {label} + + {active && ( + + )} + + ); +} + function SimpleMenuItem({ icon: Icon, label, @@ -148,6 +181,9 @@ function SimpleMenuItem({ export function Topbar() { const { user, logout } = useAuth(); const { theme, setTheme } = useTheme(); + // useTranslation subscribes this component to `languageChanged`, so the + // menu labels and the active-locale check re-render the instant we switch. + const { t } = useTranslation(); const navigate = useNavigate(); const [confirmOpen, setConfirmOpen] = useState(false); @@ -171,6 +207,32 @@ export function Topbar() { } }, [persistedLocale]); + const updateProfile = useMutation({ + mutationFn: updateMyProfile, + onSuccess: () => { + // Re-mint the JWT so the fresh `locale` claim is issued (resolution-chain + // level 2). The UI already switched client-side; without this, backend- + // generated strings lag behind until the next natural token refresh. + // Best-effort: a refresh failure must not undo the language switch. + void refreshAccessToken().catch(() => undefined); + }, + }); + + // Switch the UI language and persist it. The locale travels through the + // mutation argument (never closed-over state). The current name/phone are + // echoed because the backend sets FirstName/LastName unconditionally from the + // command — a locale-only save would otherwise wipe them. + const onSelectLanguage = (tag: string) => { + void i18n.changeLanguage(tag); + const snap = profile.data; + updateProfile.mutate({ + firstName: snap?.firstName ?? undefined, + lastName: snap?.lastName ?? undefined, + phoneNumber: snap?.phoneNumber ?? undefined, + locale: tag, + }); + }; + const onConfirmSignOut = () => { setConfirmOpen(false); logout(); @@ -281,6 +343,23 @@ export function Topbar() { + {/* Language */} + + {t("language")} + +
+ {SUPPORTED.map((tag) => ( + onSelectLanguage(tag)} + /> + ))} +
+ + + {/* Account quick actions */} Account diff --git a/clients/admin/tests/i18n/switcher.spec.ts b/clients/admin/tests/i18n/switcher.spec.ts new file mode 100644 index 0000000000..11040b630c --- /dev/null +++ b/clients/admin/tests/i18n/switcher.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; +import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks"; +import { mockJsonResponse } from "../helpers/api-mocks"; + +// Task 10 — the topbar language switcher. Switching to Português must: +// (a) localize the UI in place (the "Language" section label becomes "Idioma"), +// (b) PUT the chosen locale to /identity/profile, and +// (c) trigger a token refresh so the new `locale` JWT claim is minted. + +/** Minimal decodable JWT for the refreshed session (auth-context decodes it). */ +function fakeJwt(payload: Record): string { + const b64url = (obj: unknown) => + btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); + return [b64url({ alg: "HS256", typ: "JWT" }), b64url(payload), "sig"].join("."); +} + +test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); + await installAdminShellMocks(page); + + await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 0 })); + await mockJsonResponse(page, "**/api/v1/billing/plans**", []); + await mockJsonResponse(page, "**/api/v1/billing/invoices**", paged([], { totalCount: 0 })); +}); + +test.describe("language switcher", () => { + test("switching to Português localizes the UI, persists the locale and refreshes the token", async ({ + page, + }) => { + let putBody: { locale?: string; firstName?: string; lastName?: string } | null = null; + let refreshCalled = false; + + // GET returns the current (en-US) profile with a name so we can assert it is + // preserved; PUT captures the body; both share this one route (LIFO wins). + await page.route("**/api/v1/identity/profile", async (route) => { + const method = route.request().method(); + if (method === "PUT") { + putBody = route.request().postDataJSON(); + await route.fulfill({ status: 200 }); + return; + } + if (method === "GET") { + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "u-test-1", + firstName: "Root", + lastName: "Admin", + phoneNumber: "", + isActive: true, + emailConfirmed: true, + locale: "en-US", + }), + }); + return; + } + await route.fallback(); + }); + + // The onSuccess token refresh — capture the call and return a fresh session + // carrying the new locale claim so the auth context stays valid. + await page.route("**/api/v1/identity/token/refresh", async (route) => { + refreshCalled = true; + const token = fakeJwt({ + sub: "u-test-1", + email: TEST_USER.email, + name: "Root Admin", + tenant: "root", + locale: "pt-BR", + permissions: [...ADMIN_PERMS], + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }); + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, refreshToken: "fresh-refresh-token" }), + }); + }); + + await page.goto("/"); + + // Open the profile dropdown, then the (default en-US) language section. + await page.getByRole("button", { name: /open profile menu/i }).click(); + await expect(page.getByText("Language", { exact: true })).toBeVisible(); + + const putRequest = page.waitForRequest( + (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "PUT", + ); + await page.getByRole("menuitem", { name: "Português (BR)" }).click(); + await putRequest; + + // (b) the chosen locale was persisted, name preserved (no data loss). + expect(putBody?.locale).toBe("pt-BR"); + expect(putBody?.firstName).toBe("Root"); + expect(putBody?.lastName).toBe("Admin"); + + // (a) the section label localized in place (menu kept open on select). + await expect(page.getByText("Idioma", { exact: true })).toBeVisible(); + + // (c) the token refresh fired to re-mint the locale claim. + await expect.poll(() => refreshCalled).toBe(true); + }); +}); From ea152e13e1055246eb1cfbc0be35517bdea8c0e0 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:14:44 -0300 Subject: [PATCH 13/55] feat(dashboard): topbar language switcher Mirror the admin Task 10 switcher on the tenant dashboard: a Language section in the profile dropdown that switches the UI locale in place, persists it, and re-mints the JWT locale claim. - topbar: LanguageMenuItem + Language section (preventDefault keeps the menu open so the section label re-localizes visibly); onSelectLanguage calls i18n.changeLanguage, persists via updateMyProfile (locale by mutate arg), and refreshes the token best-effort onSuccess. Profile query is not invalidated so a refetch cannot revert the switch. - api/identity: UpdateProfileInput gains locale; the PUT body echoes it alongside the profile-read name/phone so a locale-only save cannot wipe FirstName/LastName (backend sets them unconditionally). - test: switcher spec asserts PUT {locale: pt-BR} with names preserved, in-place localization, and the token refresh firing. --- clients/dashboard/src/api/identity.ts | 7 +- .../src/components/layout/topbar.tsx | 81 +++++++++++++- clients/dashboard/tests/i18n/switcher.spec.ts | 101 ++++++++++++++++++ 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 clients/dashboard/tests/i18n/switcher.spec.ts diff --git a/clients/dashboard/src/api/identity.ts b/clients/dashboard/src/api/identity.ts index c9c2348f73..3c2e0903d0 100644 --- a/clients/dashboard/src/api/identity.ts +++ b/clients/dashboard/src/api/identity.ts @@ -395,6 +395,8 @@ export type UpdateProfileInput = { firstName?: string | null; lastName?: string | null; phoneNumber?: string | null; + /** BCP 47 UI language tag persisted on the user (drives the JWT locale claim). */ + locale?: string | null; }; /** @@ -402,7 +404,9 @@ export type UpdateProfileInput = { * server-side. Image and email changes go through their own dedicated * endpoints — this is for the editable profile fields surfaced in * settings/profile. Reads the current profile first so unset optional - * fields keep their existing values instead of being nulled. + * fields keep their existing values instead of being nulled — the backend + * sets FirstName/LastName unconditionally from the command, so a locale-only + * save (the language switcher) would otherwise wipe the names. */ export async function updateMyProfile(input: UpdateProfileInput): Promise { const profile = await getMyProfile(); @@ -413,6 +417,7 @@ export async function updateMyProfile(input: UpdateProfileInput): Promise firstName: input.firstName ?? profile.firstName ?? null, lastName: input.lastName ?? profile.lastName ?? null, phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null, + locale: input.locale ?? profile.locale ?? null, email: profile.email, deleteCurrentImage: false, }), diff --git a/clients/dashboard/src/components/layout/topbar.tsx b/clients/dashboard/src/components/layout/topbar.tsx index cf4e2e174f..b4eb2b935b 100644 --- a/clients/dashboard/src/components/layout/topbar.tsx +++ b/clients/dashboard/src/components/layout/topbar.tsx @@ -1,10 +1,12 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { Check, ChevronsUpDown, KeyRound, + Languages, LogOut, Monitor, Moon, @@ -36,8 +38,9 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Avatar } from "@/components/ui/avatar"; -import { getMyProfile } from "@/api/identity"; -import i18n from "@/i18n"; +import { getMyProfile, updateMyProfile } from "@/api/identity"; +import { refreshAccessToken } from "@/lib/api-client"; +import i18n, { SUPPORTED } from "@/i18n"; import { useAuth } from "@/auth/use-auth"; import { useSseStatus } from "@/sse/sse-context"; import { useTheme } from "@/components/theme/theme-provider"; @@ -120,6 +123,37 @@ function ThemeMenuItem({ ); } +/** Language-pick row — mirrors ThemeMenuItem but keeps the menu open on + * select so the section label re-localizes in place instead of the menu + * closing before the switch is visible. */ +function LanguageMenuItem({ + label, + active, + onSelect, +}: { + label: string; + active: boolean; + onSelect: () => void; +}) { + return ( + { + e.preventDefault(); + onSelect(); + }} + className="!my-0 flex cursor-pointer items-center gap-2.5 rounded-md !px-2.5 !py-1.5" + > + + + {label} + + {active && ( + + )} + + ); +} + /** Simple icon + label menu item — used by the Account quick links. */ function SimpleMenuItem({ icon: Icon, @@ -149,6 +183,9 @@ function SimpleMenuItem({ export function Topbar() { const { user, logout } = useAuth(); + // useTranslation subscribes this component to `languageChanged`, so the menu + // labels and the active-locale check re-render the instant we switch. + const { t } = useTranslation(); // Shared with the Profile settings page (same query key), so changing the // photo there invalidates this and the topbar avatar updates live. const { data: profile } = useQuery({ @@ -173,6 +210,27 @@ export function Topbar() { const navigate = useNavigate(); const [confirmOpen, setConfirmOpen] = useState(false); + const updateProfile = useMutation({ + mutationFn: updateMyProfile, + onSuccess: () => { + // Re-mint the JWT so the fresh `locale` claim is issued. The UI already + // switched client-side; without this, backend-generated strings lag + // behind until the next natural token refresh. Best-effort: a refresh + // failure must not undo the language switch. + void refreshAccessToken().catch(() => undefined); + }, + }); + + // Switch the UI language and persist it. The locale travels through the + // mutation argument (never closed-over state). updateMyProfile echoes the + // current name/phone from the profile it reads, so a locale-only save does + // not wipe them. We deliberately do NOT invalidate the profile query on + // success — a refetch would revert the language mid-switch. + const onSelectLanguage = (tag: string) => { + void i18n.changeLanguage(tag); + updateProfile.mutate({ locale: tag }); + }; + const onConfirmSignOut = () => { setConfirmOpen(false); logout(); @@ -346,6 +404,23 @@ export function Topbar() { + {/* Language — one item per supported locale, check on the active one */} + + {t("language")} + +
+ {SUPPORTED.map((tag) => ( + onSelectLanguage(tag)} + /> + ))} +
+ + + {/* Account quick actions */} Account diff --git a/clients/dashboard/tests/i18n/switcher.spec.ts b/clients/dashboard/tests/i18n/switcher.spec.ts new file mode 100644 index 0000000000..85289ace29 --- /dev/null +++ b/clients/dashboard/tests/i18n/switcher.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; +import { installShellMocks } from "../helpers/shell-mocks"; + +// Task 10 — the topbar language switcher. Switching to Português must: +// (a) localize the UI in place (the "Language" section label becomes "Idioma"), +// (b) PUT the chosen locale to /identity/profile with the name preserved +// (a locale-only save must not wipe FirstName/LastName), and +// (c) trigger a token refresh so the new `locale` JWT claim is minted. + +/** Minimal decodable JWT for the refreshed session (auth-context decodes it). */ +function fakeJwt(payload: Record): string { + const b64url = (obj: unknown) => + btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); + return [b64url({ alg: "HS256", typ: "JWT" }), b64url(payload), "sig"].join("."); +} + +test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, TEST_USER); + await installShellMocks(page); +}); + +test.describe("language switcher", () => { + test("switching to Português localizes the UI, persists the locale and refreshes the token", async ({ + page, + }) => { + let putBody: { locale?: string; firstName?: string; lastName?: string } | null = null; + let refreshCalled = false; + + // GET returns the current (en-US) profile with a name so we can assert it is + // preserved; PUT captures the body. Registered AFTER installShellMocks so + // this handler wins (LIFO) over the default profile stub. updateMyProfile + // itself issues a GET before the PUT, so both methods route through here. + await page.route("**/api/v1/identity/profile", async (route) => { + const method = route.request().method(); + if (method === "PUT") { + putBody = route.request().postDataJSON(); + await route.fulfill({ status: 200 }); + return; + } + if (method === "GET") { + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "u-test-1", + firstName: "Alice", + lastName: "Nguyen", + phoneNumber: "", + email: "alice@acme.com", + isActive: true, + emailConfirmed: true, + locale: "en-US", + }), + }); + return; + } + await route.fallback(); + }); + + // The onSuccess token refresh — capture the call and return a fresh session + // carrying the new locale claim so the auth context stays valid. + await page.route("**/api/v1/identity/token/refresh", async (route) => { + refreshCalled = true; + const token = fakeJwt({ + sub: "u-test-1", + email: TEST_USER.email, + name: "Alice Nguyen", + tenant: "acme", + locale: "pt-BR", + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }); + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, refreshToken: "fresh-refresh-token" }), + }); + }); + + await page.goto("/"); + + // Open the profile dropdown, then the (default en-US) language section. + await page.getByRole("button", { name: /open profile menu/i }).click(); + await expect(page.getByText("Language", { exact: true })).toBeVisible(); + + await page.getByRole("menuitem", { name: "Português (BR)" }).click(); + + // (b) the chosen locale was persisted, name preserved (no data loss). Poll + // the captured body: the route handler that assigns it runs asynchronously. + await expect.poll(() => putBody?.locale).toBe("pt-BR"); + expect(putBody?.firstName).toBe("Alice"); + expect(putBody?.lastName).toBe("Nguyen"); + + // (a) the section label localized in place (menu kept open on select). + await expect(page.getByText("Idioma", { exact: true })).toBeVisible(); + + // (c) the token refresh fired to re-mint the locale claim. + await expect.poll(() => refreshCalled).toBe(true); + }); +}); From 3df82972f935a06ce3685a3ba4a30682036378c0 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:10:56 -0300 Subject: [PATCH 14/55] feat(admin): locale-aware formatting + localize app shell --- .../admin/src/components/layout/app-shell.tsx | 6 +- .../src/components/layout/mobile-nav.tsx | 11 ++-- .../admin/src/components/layout/nav-items.ts | 33 +++++----- .../admin/src/components/layout/sidebar.tsx | 30 ++++++---- .../admin/src/components/layout/topbar.tsx | 29 ++++----- clients/admin/src/i18n.ts | 29 +++++++-- clients/admin/src/lib/format.ts | 40 +++++++++++++ clients/admin/src/locales/en-US/common.json | 22 ++++++- clients/admin/src/locales/en-US/nav.json | 14 +++++ clients/admin/src/locales/pt-BR/common.json | 22 ++++++- clients/admin/src/locales/pt-BR/nav.json | 14 +++++ clients/admin/tests/i18n/format.spec.ts | 60 +++++++++++++++++++ clients/admin/tests/i18n/parity.spec.ts | 22 +++++++ 13 files changed, 277 insertions(+), 55 deletions(-) create mode 100644 clients/admin/src/lib/format.ts create mode 100644 clients/admin/src/locales/en-US/nav.json create mode 100644 clients/admin/src/locales/pt-BR/nav.json create mode 100644 clients/admin/tests/i18n/format.spec.ts create mode 100644 clients/admin/tests/i18n/parity.spec.ts diff --git a/clients/admin/src/components/layout/app-shell.tsx b/clients/admin/src/components/layout/app-shell.tsx index 62733a3013..4f3a65fee1 100644 --- a/clients/admin/src/components/layout/app-shell.tsx +++ b/clients/admin/src/components/layout/app-shell.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; import { Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { Sidebar } from "@/components/layout/sidebar"; import { Topbar } from "@/components/layout/topbar"; import { @@ -22,6 +23,7 @@ import { InactivityGuard } from "@/components/auth/inactivity-guard"; * add a banner component here that reads from admin's AuthContext. */ export function AppShell() { + const { t } = useTranslation("common"); return ( {/* Skip-to-content link — first focusable element. */} @@ -29,7 +31,7 @@ export function AppShell() { href="#main-content" className="sr-only z-[100] rounded-md bg-[var(--color-foreground)] px-4 py-2 text-sm font-medium text-[var(--color-background)] focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:outline-none focus:ring-2 focus:ring-[var(--color-ring)]" > - Skip to content + {t("shell.skipToContent")}
@@ -50,7 +52,7 @@ export function AppShell() { role="status" className="flex min-h-[40vh] items-center justify-center text-sm text-[var(--color-muted-foreground)]" > - Loading… + {t("shell.loading")}
} > diff --git a/clients/admin/src/components/layout/mobile-nav.tsx b/clients/admin/src/components/layout/mobile-nav.tsx index c055286731..4c50ea2ae7 100644 --- a/clients/admin/src/components/layout/mobile-nav.tsx +++ b/clients/admin/src/components/layout/mobile-nav.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { Menu } from "lucide-react"; import { Dialog as Sheet, @@ -64,6 +65,7 @@ export function MobileNavRoot() { const { open, setOpen } = useMobileNav(); const location = useLocation(); const { user, permissionsHydrated } = useAuth(); + const { t } = useTranslation("common"); const granted = permissionsHydrated ? (user?.permissions ?? []) : []; const visibleSections: NavSection[] = useMemo( @@ -93,9 +95,9 @@ export function MobileNavRoot() { {/* Radix Dialog requires a Title for the accessible tree. */} - Primary navigation + {t("shell.primaryNav")} - Admin sections and account links. + {t("shell.mobileNavDescription")} {/* Brand row — matches Topbar height */} @@ -115,7 +117,7 @@ export function MobileNavRoot() { fullstackhero - Admin + {t("shell.appSubtitle")} @@ -143,11 +145,12 @@ export function MobileNavRoot() { */ export function MobileNavTrigger({ className }: { className?: string }) { const { setOpen } = useMobileNav(); + const { t } = useTranslation("common"); const onClick = useCallback(() => setOpen(true), [setOpen]); return ( diff --git a/clients/admin/src/i18n.ts b/clients/admin/src/i18n.ts index 2afa37386f..c42a046653 100644 --- a/clients/admin/src/i18n.ts +++ b/clients/admin/src/i18n.ts @@ -3,10 +3,33 @@ import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; import enCommon from "@/locales/en-US/common.json"; import ptCommon from "@/locales/pt-BR/common.json"; +import enNav from "@/locales/en-US/nav.json"; +import ptNav from "@/locales/pt-BR/nav.json"; // Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries). export const SUPPORTED = ["en-US", "pt-BR"] as const; +// Translation namespaces keyed by name → per-locale catalog. This map is the +// single source string-migration waves extend: to add a namespace, import its +// two JSON catalogs and add one entry here — `resources` and `ns` below derive +// from it, so no other wiring changes. +const CATALOGS = { + common: { "en-US": enCommon, "pt-BR": ptCommon }, + nav: { "en-US": enNav, "pt-BR": ptNav }, +} as const; + +// react-i18next wants resources shaped { : { : catalog } }. Build it from +// CATALOGS so SUPPORTED (what the switcher offers) and the namespace list stay the +// one source of truth for both the store and the type surface. +const resources = Object.fromEntries( + SUPPORTED.map((lng) => [ + lng, + Object.fromEntries( + Object.entries(CATALOGS).map(([ns, byLng]) => [ns, byLng[lng]]), + ), + ]), +); + // i18next's nonExplicitSupportedLngs does NOT rewrite pt-PT->pt-BR. Normalize explicitly via // convertDetectedLanguage: map any variant onto a canonical tag by its language part. const CANON: Record = { pt: "pt-BR", en: "en-US" }; @@ -20,10 +43,8 @@ export function initI18n(deploymentDefault: string) { .use(LanguageDetector) .use(initReactI18next) .init({ - resources: { - "en-US": { common: enCommon }, - "pt-BR": { common: ptCommon }, - }, + resources, + ns: Object.keys(CATALOGS), fallbackLng: (SUPPORTED as readonly string[]).includes(deploymentDefault) ? deploymentDefault : "en-US", diff --git a/clients/admin/src/lib/format.ts b/clients/admin/src/lib/format.ts new file mode 100644 index 0000000000..4126c82689 --- /dev/null +++ b/clients/admin/src/lib/format.ts @@ -0,0 +1,40 @@ +import i18n from "@/i18n"; + +/** + * Locale-aware formatting helpers built on the platform `Intl` APIs. The active + * UI locale (`i18n.language`) is the default so formatted numbers, currency and + * dates track the language switcher without every call site threading a locale. + * Pass an explicit `locale` to override (e.g. rendering a fixed-locale export). + */ + +// Active UI locale, guarded for the window before i18n has initialized. +const resolveLocale = (locale?: string): string => locale ?? i18n.language ?? "en-US"; + +export function formatNumber(value: number, locale?: string): string { + return new Intl.NumberFormat(resolveLocale(locale)).format(value); +} + +export function formatCurrency(amount: number, currency: string, locale?: string): string { + try { + return new Intl.NumberFormat(resolveLocale(locale), { + style: "currency", + currency, + }).format(amount); + } catch { + // Intl throws RangeError on an unknown/malformed ISO 4217 code — degrade to a + // readable amount + raw code rather than crashing the render. + return `${amount.toFixed(2)} ${currency}`; + } +} + +export function formatDate(iso?: string | null, locale?: string): string { + if (!iso) return "—"; + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? iso + : new Intl.DateTimeFormat(resolveLocale(locale), { + month: "short", + day: "2-digit", + year: "numeric", + }).format(date); +} diff --git a/clients/admin/src/locales/en-US/common.json b/clients/admin/src/locales/en-US/common.json index d7a107b97b..c4d7122c57 100644 --- a/clients/admin/src/locales/en-US/common.json +++ b/clients/admin/src/locales/en-US/common.json @@ -1,5 +1,25 @@ { "language": "Language", "language.enUS": "English (US)", - "language.ptBR": "Português (BR)" + "language.ptBR": "Português (BR)", + "actions.signOut": "Sign out", + "actions.cancel": "Cancel", + "shell.skipToContent": "Skip to content", + "shell.loading": "Loading…", + "shell.openProfileMenu": "Open profile menu", + "shell.openNavMenu": "Open navigation menu", + "shell.primaryNav": "Primary navigation", + "shell.mobileNavDescription": "Admin sections and account links.", + "shell.collapseSidebar": "Collapse sidebar", + "shell.expandSidebar": "Expand sidebar", + "shell.appSubtitle": "Admin", + "shell.unknownUser": "Unknown", + "theme.title": "Theme", + "theme.light": "Light", + "theme.dark": "Dark", + "account.title": "Account", + "account.profile": "Profile", + "account.settings": "Settings", + "signOut.title": "Sign out of fullstackhero?", + "signOut.description": "You'll need to sign in again to access this admin. Any unsaved work in this session will be lost." } diff --git a/clients/admin/src/locales/en-US/nav.json b/clients/admin/src/locales/en-US/nav.json new file mode 100644 index 0000000000..c763a3f399 --- /dev/null +++ b/clients/admin/src/locales/en-US/nav.json @@ -0,0 +1,14 @@ +{ + "overview": "Overview", + "tenants": "Tenants", + "identity": "Identity", + "users": "Users", + "roles": "Roles", + "impersonation": "Impersonation", + "operations": "Operations", + "billing": "Billing", + "webhooks": "Webhooks", + "audits": "Audits", + "health": "Health", + "settings": "Settings" +} diff --git a/clients/admin/src/locales/pt-BR/common.json b/clients/admin/src/locales/pt-BR/common.json index f56f7f5d41..0eeebf2ad3 100644 --- a/clients/admin/src/locales/pt-BR/common.json +++ b/clients/admin/src/locales/pt-BR/common.json @@ -1,5 +1,25 @@ { "language": "Idioma", "language.enUS": "English (US)", - "language.ptBR": "Português (BR)" + "language.ptBR": "Português (BR)", + "actions.signOut": "Sair", + "actions.cancel": "Cancelar", + "shell.skipToContent": "Pular para o conteúdo", + "shell.loading": "Carregando…", + "shell.openProfileMenu": "Abrir menu do perfil", + "shell.openNavMenu": "Abrir menu de navegação", + "shell.primaryNav": "Navegação principal", + "shell.mobileNavDescription": "Seções do admin e links da conta.", + "shell.collapseSidebar": "Recolher a barra lateral", + "shell.expandSidebar": "Expandir a barra lateral", + "shell.appSubtitle": "Admin", + "shell.unknownUser": "Desconhecido", + "theme.title": "Tema", + "theme.light": "Claro", + "theme.dark": "Escuro", + "account.title": "Conta", + "account.profile": "Perfil", + "account.settings": "Configurações", + "signOut.title": "Sair do fullstackhero?", + "signOut.description": "Você precisará entrar novamente para acessar este painel. Qualquer trabalho não salvo nesta sessão será perdido." } diff --git a/clients/admin/src/locales/pt-BR/nav.json b/clients/admin/src/locales/pt-BR/nav.json new file mode 100644 index 0000000000..077107fae2 --- /dev/null +++ b/clients/admin/src/locales/pt-BR/nav.json @@ -0,0 +1,14 @@ +{ + "overview": "Visão geral", + "tenants": "Inquilinos", + "identity": "Identidade", + "users": "Usuários", + "roles": "Funções", + "impersonation": "Personificação", + "operations": "Operações", + "billing": "Faturamento", + "webhooks": "Webhooks", + "audits": "Auditorias", + "health": "Integridade", + "settings": "Configurações" +} diff --git a/clients/admin/tests/i18n/format.spec.ts b/clients/admin/tests/i18n/format.spec.ts new file mode 100644 index 0000000000..e354e1069d --- /dev/null +++ b/clients/admin/tests/i18n/format.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; +import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks"; +import { mockJsonResponse } from "../helpers/api-mocks"; + +// Task 11 — locale-aware Intl formatters (src/lib/format.ts). Exercised through the +// real Vite module graph (dynamic import over the dev server) so the `@/i18n` +// alias and i18n wiring match production. Explicit-locale calls assert +// locale-correct grouping/currency; null/invalid inputs assert the fallbacks. + +test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); + await installAdminShellMocks(page); + await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 0 })); + await mockJsonResponse(page, "**/api/v1/billing/plans**", []); + await mockJsonResponse(page, "**/api/v1/billing/invoices**", paged([], { totalCount: 0 })); +}); + +test.describe("format.ts", () => { + test("formatters render per explicit locale and fall back safely", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("button", { name: /open profile menu/i }), + ).toBeVisible({ timeout: 10_000 }); + + const out = await page.evaluate(async () => { + const m = await import("/src/lib/format.ts"); + return { + curPt: m.formatCurrency(1234.5, "BRL", "pt-BR"), + curEn: m.formatCurrency(1234.5, "BRL", "en-US"), + numPt: m.formatNumber(1234567.89, "pt-BR"), + numEn: m.formatNumber(1234567.89, "en-US"), + datePt: m.formatDate("2026-01-15T12:00:00Z", "pt-BR"), + dateEn: m.formatDate("2026-01-15T12:00:00Z", "en-US"), + dateNull: m.formatDate(null), + dateBad: m.formatDate("not-a-date"), + curBad: m.formatCurrency(10, "NOTACUR", "en-US"), + }; + }); + + // Currency: BRL symbol + locale-specific grouping/decimal separators. + expect(out.curPt).toContain("R$"); + expect(out.curPt).toContain("1.234,50"); + expect(out.curEn).toContain("1,234.50"); + + // Number grouping differs by locale. + expect(out.numPt).toBe("1.234.567,89"); + expect(out.numEn).toBe("1,234,567.89"); + + // Date renders per locale and carries the year; the two locales differ. + expect(out.datePt).toContain("2026"); + expect(out.dateEn).toContain("2026"); + expect(out.datePt).not.toBe(out.dateEn); + + // Documented fallbacks: nullish -> em dash, unparseable -> echo, bad currency -> amount + code. + expect(out.dateNull).toBe("—"); + expect(out.dateBad).toBe("not-a-date"); + expect(out.curBad).toBe("10.00 NOTACUR"); + }); +}); diff --git a/clients/admin/tests/i18n/parity.spec.ts b/clients/admin/tests/i18n/parity.spec.ts new file mode 100644 index 0000000000..c6e112fd81 --- /dev/null +++ b/clients/admin/tests/i18n/parity.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +// A missing/extra key in one locale silently falls back to the key or the other +// locale at runtime. Assert both catalogs expose the identical key set per +// namespace so a half-translated string is caught at test time, not in the UI. +// Read via fs (cwd = the admin app dir) to avoid ESM JSON import-attribute rules. +const readCatalog = (locale: string, ns: string): Record => + JSON.parse(readFileSync(path.resolve("src/locales", locale, `${ns}.json`), "utf8")); + +const namespaces = ["common", "nav"]; + +test.describe("catalog parity", () => { + for (const ns of namespaces) { + test(`${ns}: en-US and pt-BR expose the same keys`, () => { + const en = Object.keys(readCatalog("en-US", ns)).sort(); + const pt = Object.keys(readCatalog("pt-BR", ns)).sort(); + expect(en).toEqual(pt); + }); + } +}); From 91df1deaac43d91ef610c63d52ab510401c9c103 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:12:48 -0300 Subject: [PATCH 15/55] feat(dashboard): locale-aware formatting + localize app shell --- .../src/components/layout/app-shell.tsx | 4 +- .../src/components/layout/mobile-nav.tsx | 9 ++- .../src/components/layout/nav-data.ts | 54 +++++++-------- .../src/components/layout/sidebar.tsx | 27 ++++---- .../src/components/layout/topbar.tsx | 59 +++++++++-------- clients/dashboard/src/i18n.ts | 27 ++++++-- clients/dashboard/src/lib/list-helpers.ts | 66 +++++++++++++------ .../dashboard/src/locales/en-US/common.json | 56 +++++++++++++++- .../dashboard/src/locales/pt-BR/common.json | 56 +++++++++++++++- clients/dashboard/tests/i18n/parity.spec.ts | 23 +++++++ clients/dashboard/tests/i18n/shell.spec.ts | 43 ++++++++++++ 11 files changed, 330 insertions(+), 94 deletions(-) create mode 100644 clients/dashboard/tests/i18n/parity.spec.ts create mode 100644 clients/dashboard/tests/i18n/shell.spec.ts diff --git a/clients/dashboard/src/components/layout/app-shell.tsx b/clients/dashboard/src/components/layout/app-shell.tsx index c2ff92f614..926fee2ef6 100644 --- a/clients/dashboard/src/components/layout/app-shell.tsx +++ b/clients/dashboard/src/components/layout/app-shell.tsx @@ -1,4 +1,5 @@ import { Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { Sidebar } from "@/components/layout/sidebar"; import { Topbar } from "@/components/layout/topbar"; import { ImpersonationBanner } from "@/components/layout/impersonation-banner"; @@ -15,6 +16,7 @@ import { InactivityGuard } from "@/components/auth/inactivity-guard"; import { cn } from "@/lib/cn"; export function AppShell() { + const { t } = useTranslation("common"); return ( @@ -32,7 +34,7 @@ export function AppShell() { "focus:ring-[var(--color-ring)] focus:ring-offset-2 focus:ring-offset-[var(--color-background)]", )} > - Skip to main content + {t("skipToContent")}
diff --git a/clients/dashboard/src/components/layout/mobile-nav.tsx b/clients/dashboard/src/components/layout/mobile-nav.tsx index 5ac6f81630..9d3ee0781f 100644 --- a/clients/dashboard/src/components/layout/mobile-nav.tsx +++ b/clients/dashboard/src/components/layout/mobile-nav.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { Menu } from "lucide-react"; import { Sheet, @@ -61,6 +62,7 @@ export function MobileNavProvider({ children }: { children: ReactNode }) { * or any non-NavLink navigation while the drawer is open). */ export function MobileNavRoot() { + const { t } = useTranslation("common"); const { open, setOpen } = useMobileNav(); const location = useLocation(); @@ -87,9 +89,9 @@ export function MobileNavRoot() { {/* Radix Dialog requires a Title for the accessible name; keep it visually hidden so the drawer chrome is unchanged. */} - Primary navigation + {t("nav.primary")} - Site sections and account links. + {t("nav.primaryDescription")} {/* Brand row — matches Topbar height so the drawer top aligns with the rest of the chrome. */} @@ -129,12 +131,13 @@ export function MobileNavRoot() { * the first child so it sits at the leading edge on small screens). */ export function MobileNavTrigger({ className }: { className?: string }) { + const { t } = useTranslation("common"); const { setOpen } = useMobileNav(); const onClick = useCallback(() => setOpen(true), [setOpen]); return (
)} @@ -114,9 +116,9 @@ export function Sidebar() { diff --git a/clients/dashboard/src/i18n.ts b/clients/dashboard/src/i18n.ts index 2afa37386f..912d865704 100644 --- a/clients/dashboard/src/i18n.ts +++ b/clients/dashboard/src/i18n.ts @@ -7,6 +7,27 @@ import ptCommon from "@/locales/pt-BR/common.json"; // Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries). export const SUPPORTED = ["en-US", "pt-BR"] as const; +type Catalog = Record; + +// Translation catalogs keyed by namespace, then by locale. To add a namespace +// in a later wave: import its two JSON files and add one row here — `resources`, +// the namespace list, and parity tests all derive from this single map, so no +// other wiring changes. +const catalogs: Record> = { + common: { "en-US": enCommon, "pt-BR": ptCommon }, +}; + +export const NAMESPACES = Object.keys(catalogs); + +// Pivot the namespace-first map into i18next's locale-first `resources` shape: +// { "en-US": { common: {…} }, "pt-BR": { common: {…} } }. +const resources = Object.fromEntries( + SUPPORTED.map((lng) => [ + lng, + Object.fromEntries(Object.entries(catalogs).map(([ns, byLng]) => [ns, byLng[lng]])), + ]), +); + // i18next's nonExplicitSupportedLngs does NOT rewrite pt-PT->pt-BR. Normalize explicitly via // convertDetectedLanguage: map any variant onto a canonical tag by its language part. const CANON: Record = { pt: "pt-BR", en: "en-US" }; @@ -20,14 +41,12 @@ export function initI18n(deploymentDefault: string) { .use(LanguageDetector) .use(initReactI18next) .init({ - resources: { - "en-US": { common: enCommon }, - "pt-BR": { common: ptCommon }, - }, + resources, fallbackLng: (SUPPORTED as readonly string[]).includes(deploymentDefault) ? deploymentDefault : "en-US", supportedLngs: [...SUPPORTED], + ns: NAMESPACES, defaultNS: "common", interpolation: { escapeValue: false }, detection: { diff --git a/clients/dashboard/src/lib/list-helpers.ts b/clients/dashboard/src/lib/list-helpers.ts index 186e63b044..9002a1a243 100644 --- a/clients/dashboard/src/lib/list-helpers.ts +++ b/clients/dashboard/src/lib/list-helpers.ts @@ -1,32 +1,60 @@ +import i18n from "@/i18n"; import { ApiRequestError } from "@/lib/api-client"; -const dateLong = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "2-digit", - year: "numeric", -}); +// Formatters read the active UI locale by default; callers may pin an explicit +// locale. Building an Intl formatter per row is wasteful in long ledgers and +// the locale only changes on a language switch, so cache one formatter per +// (locale) — the cache re-fills lazily under the new locale after a switch. +const activeLocale = (locale?: string) => locale ?? i18n.language ?? "en-US"; -export function formatDate(iso: string | null | undefined) { +const dateLongByLocale = new Map(); +function dateLongFor(locale: string) { + let fmt = dateLongByLocale.get(locale); + if (!fmt) { + fmt = new Intl.DateTimeFormat(locale, { + month: "short", + day: "2-digit", + year: "numeric", + }); + dateLongByLocale.set(locale, fmt); + } + return fmt; +} + +// "3:42 PM" — local wall-clock time. Intl renders in the browser's timezone. +const timeShortByLocale = new Map(); +function timeShortFor(locale: string) { + let fmt = timeShortByLocale.get(locale); + if (!fmt) { + fmt = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + }); + timeShortByLocale.set(locale, fmt); + } + return fmt; +} + +export function formatDate(iso: string | null | undefined, locale?: string) { if (!iso) return "—"; - return dateLong.format(new Date(iso)); + return dateLongFor(activeLocale(locale)).format(new Date(iso)); } // "APR 30 2026" — mono-caps tabular form for ledger/registry rows. -export function formatDateMono(iso: string | null | undefined) { +export function formatDateMono(iso: string | null | undefined, locale?: string) { if (!iso) return "—"; - return dateLong.format(new Date(iso)).toUpperCase().replace(",", ""); + return dateLongFor(activeLocale(locale)).format(new Date(iso)).toUpperCase().replace(",", ""); } -// "3:42 PM" — local wall-clock time. Intl renders in the browser's timezone. -const timeShort = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); - // "APR 30 2026 · 3:42 PM" — date + local time for audit/detail panels. -export function formatDateTimeMono(iso: string | null | undefined) { +export function formatDateTimeMono(iso: string | null | undefined, locale?: string) { if (!iso) return "—"; - return `${formatDateMono(iso)} · ${timeShort.format(new Date(iso))}`; + return `${formatDateMono(iso, locale)} · ${timeShortFor(activeLocale(locale)).format(new Date(iso))}`; +} + +// Locale-grouped integer/decimal (e.g. en-US "1,234" · pt-BR "1.234"). +export function formatNumber(value: number, locale?: string) { + return new Intl.NumberFormat(activeLocale(locale)).format(value); } // "3d ago", "2mo ago" — terse relative time for the secondary line. @@ -61,9 +89,9 @@ export function slugify(value: string) { return s; } -export function formatMoney(amount: number, currency: string) { +export function formatMoney(amount: number, currency: string, locale?: string) { try { - return new Intl.NumberFormat(undefined, { + return new Intl.NumberFormat(activeLocale(locale), { style: "currency", currency, }).format(amount); diff --git a/clients/dashboard/src/locales/en-US/common.json b/clients/dashboard/src/locales/en-US/common.json index d7a107b97b..ded1b75f1c 100644 --- a/clients/dashboard/src/locales/en-US/common.json +++ b/clients/dashboard/src/locales/en-US/common.json @@ -1,5 +1,59 @@ { "language": "Language", "language.enUS": "English (US)", - "language.ptBR": "Português (BR)" + "language.ptBR": "Português (BR)", + "search": "Search", + "commandPalette.open": "Open command palette", + "profileMenu.open": "Open profile menu", + "unknownUser": "Unknown", + "skipToContent": "Skip to main content", + "brand.dashboard": "Dashboard", + "presence.connected_one": "Connected · {{formatted}} event", + "presence.connected_other": "Connected · {{formatted}} events", + "presence.offline": "Stream offline", + "presence.connecting": "Connecting…", + "presence.reconnecting": "Reconnecting…", + "presence.idle": "Idle", + "theme.title": "Theme", + "theme.light": "Light", + "theme.dark": "Dark", + "theme.system": "System", + "account.title": "Account", + "account.profile": "Profile", + "account.settings": "Settings", + "account.apiKeys": "API keys", + "account.signOut": "Sign out", + "signOut.title": "Sign out of fullstackhero?", + "signOut.description": "You'll need to sign in again to access this tenant. Any unsaved work in this session will be lost.", + "signOut.cancel": "Cancel", + "signOut.confirm": "Sign out", + "sidebar.collapse": "Collapse sidebar", + "sidebar.expand": "Expand sidebar", + "nav.primary": "Primary navigation", + "nav.primaryDescription": "Site sections and account links.", + "nav.openMenu": "Open navigation menu", + "nav.overview": "Overview", + "nav.chat": "Chat", + "nav.myFiles": "My Files", + "nav.settings": "Settings", + "nav.section.operations": "Operations", + "nav.section.catalog": "Catalog", + "nav.section.helpdesk": "Helpdesk", + "nav.section.identity": "Identity", + "nav.section.system": "System", + "nav.liveActivity": "Live activity", + "nav.subscription": "Subscription", + "nav.wallet": "WhatsApp wallet", + "nav.invoices": "Invoices", + "nav.products": "Products", + "nav.brands": "Brands", + "nav.categories": "Categories", + "nav.tickets": "Tickets", + "nav.users": "Users", + "nav.roles": "Roles", + "nav.groups": "Groups", + "nav.health": "Health", + "nav.audits": "Audit trail", + "nav.sessions": "Sessions", + "nav.trash": "Trash" } diff --git a/clients/dashboard/src/locales/pt-BR/common.json b/clients/dashboard/src/locales/pt-BR/common.json index f56f7f5d41..2efa693838 100644 --- a/clients/dashboard/src/locales/pt-BR/common.json +++ b/clients/dashboard/src/locales/pt-BR/common.json @@ -1,5 +1,59 @@ { "language": "Idioma", "language.enUS": "English (US)", - "language.ptBR": "Português (BR)" + "language.ptBR": "Português (BR)", + "search": "Buscar", + "commandPalette.open": "Abrir paleta de comandos", + "profileMenu.open": "Abrir menu do perfil", + "unknownUser": "Desconhecido", + "skipToContent": "Pular para o conteúdo principal", + "brand.dashboard": "Painel", + "presence.connected_one": "Conectado · {{formatted}} evento", + "presence.connected_other": "Conectado · {{formatted}} eventos", + "presence.offline": "Transmissão offline", + "presence.connecting": "Conectando…", + "presence.reconnecting": "Reconectando…", + "presence.idle": "Ocioso", + "theme.title": "Tema", + "theme.light": "Claro", + "theme.dark": "Escuro", + "theme.system": "Sistema", + "account.title": "Conta", + "account.profile": "Perfil", + "account.settings": "Configurações", + "account.apiKeys": "Chaves de API", + "account.signOut": "Sair", + "signOut.title": "Sair do fullstackhero?", + "signOut.description": "Você precisará entrar novamente para acessar este tenant. Qualquer trabalho não salvo nesta sessão será perdido.", + "signOut.cancel": "Cancelar", + "signOut.confirm": "Sair", + "sidebar.collapse": "Recolher barra lateral", + "sidebar.expand": "Expandir barra lateral", + "nav.primary": "Navegação principal", + "nav.primaryDescription": "Seções do site e links da conta.", + "nav.openMenu": "Abrir menu de navegação", + "nav.overview": "Visão geral", + "nav.chat": "Chat", + "nav.myFiles": "Meus arquivos", + "nav.settings": "Configurações", + "nav.section.operations": "Operações", + "nav.section.catalog": "Catálogo", + "nav.section.helpdesk": "Central de ajuda", + "nav.section.identity": "Identidade", + "nav.section.system": "Sistema", + "nav.liveActivity": "Atividade ao vivo", + "nav.subscription": "Assinatura", + "nav.wallet": "Carteira do WhatsApp", + "nav.invoices": "Faturas", + "nav.products": "Produtos", + "nav.brands": "Marcas", + "nav.categories": "Categorias", + "nav.tickets": "Chamados", + "nav.users": "Usuários", + "nav.roles": "Funções", + "nav.groups": "Grupos", + "nav.health": "Saúde", + "nav.audits": "Trilha de auditoria", + "nav.sessions": "Sessões", + "nav.trash": "Lixeira" } diff --git a/clients/dashboard/tests/i18n/parity.spec.ts b/clients/dashboard/tests/i18n/parity.spec.ts new file mode 100644 index 0000000000..110f4aba0d --- /dev/null +++ b/clients/dashboard/tests/i18n/parity.spec.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; + +// Every namespace must carry identical key sets across locales. A key present +// in one catalog but missing in the other silently falls back to the default +// language at runtime, shipping a mixed-locale UI. This guards each catalog as +// new namespaces land (add one assertion per namespace here per wave). +const load = (locale: string, ns: string): Record => + JSON.parse( + readFileSync( + fileURLToPath(new URL(`../../src/locales/${locale}/${ns}.json`, import.meta.url)), + "utf8", + ), + ); + +test.describe("i18n catalog parity", () => { + test("common: en-US and pt-BR expose the same keys", () => { + const en = load("en-US", "common"); + const pt = load("pt-BR", "common"); + expect(Object.keys(en).sort()).toEqual(Object.keys(pt).sort()); + }); +}); diff --git a/clients/dashboard/tests/i18n/shell.spec.ts b/clients/dashboard/tests/i18n/shell.spec.ts new file mode 100644 index 0000000000..d53c9ecdde --- /dev/null +++ b/clients/dashboard/tests/i18n/shell.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; +import { installShellMocks } from "../helpers/shell-mocks"; + +// Task 12 (Wave A) — the app shell (topbar + sidebar) reads from the `common` +// namespace. Default locale is en-US, so the existing area specs cover the +// English side; here we switch to Português and assert the shell chrome +// localizes in place (menu labels + sidebar nav). + +test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, TEST_USER); + await installShellMocks(page); +}); + +test.describe("app shell localization", () => { + test("english shell chrome renders from the common namespace", async ({ page }) => { + await page.goto("/"); + // Sidebar top-level item — ungated, always visible. + await expect(page.getByRole("link", { name: "Overview", exact: true })).toBeVisible(); + + await page.getByRole("button", { name: /open profile menu/i }).click(); + await expect(page.getByText("Theme", { exact: true })).toBeVisible(); + await expect(page.getByText("Account", { exact: true })).toBeVisible(); + }); + + test("switching to Português localizes the topbar menu and sidebar nav", async ({ + page, + }) => { + await page.goto("/"); + + await page.getByRole("button", { name: /open profile menu/i }).click(); + // The language section keeps the menu open on select, so the surrounding + // labels re-localize in place. + await page.getByRole("menuitem", { name: "Português (BR)" }).click(); + + await expect(page.getByText("Idioma", { exact: true })).toBeVisible(); + await expect(page.getByText("Tema", { exact: true })).toBeVisible(); + await expect(page.getByText("Conta", { exact: true })).toBeVisible(); + + // Sidebar nav re-renders under the new locale too (subscribes to i18n). + await expect(page.getByRole("link", { name: "Visão geral", exact: true })).toBeVisible(); + }); +}); From 1712801eb07301626db7dc252adfb47ff8f39dc7 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:08:16 -0300 Subject: [PATCH 16/55] feat(admin): localize auth, identity and settings screens --- .../components/auth/demo-accounts-dialog.tsx | 20 +-- .../src/components/auth/inactivity-dialog.tsx | 13 +- .../impersonation/impersonate-dialog.tsx | 64 ++++---- .../impersonation/revoke-grant-dialog.tsx | 34 +++-- .../components/roles/create-role-dialog.tsx | 44 +++--- .../sessions/user-sessions-card.tsx | 59 ++++---- .../components/users/create-user-dialog.tsx | 82 +++++----- clients/admin/src/i18n.ts | 18 +++ clients/admin/src/locales/en-US/auth.json | 111 ++++++++++++++ .../src/locales/en-US/impersonation.json | 84 +++++++++++ clients/admin/src/locales/en-US/roles.json | 80 ++++++++++ clients/admin/src/locales/en-US/sessions.json | 42 ++++++ clients/admin/src/locales/en-US/settings.json | 103 +++++++++++++ clients/admin/src/locales/en-US/users.json | 108 ++++++++++++++ clients/admin/src/locales/pt-BR/auth.json | 111 ++++++++++++++ .../src/locales/pt-BR/impersonation.json | 84 +++++++++++ clients/admin/src/locales/pt-BR/roles.json | 80 ++++++++++ clients/admin/src/locales/pt-BR/sessions.json | 42 ++++++ clients/admin/src/locales/pt-BR/settings.json | 103 +++++++++++++ clients/admin/src/locales/pt-BR/users.json | 108 ++++++++++++++ .../admin/src/pages/auth/confirm-email.tsx | 38 ++--- .../admin/src/pages/auth/forgot-password.tsx | 49 +++--- .../admin/src/pages/auth/reset-password.tsx | 76 +++++----- .../admin/src/pages/impersonation/list.tsx | 79 +++++----- clients/admin/src/pages/login.tsx | 40 ++--- clients/admin/src/pages/roles/detail.tsx | 97 ++++++------ clients/admin/src/pages/roles/list.tsx | 55 +++---- .../admin/src/pages/settings/appearance.tsx | 32 ++-- clients/admin/src/pages/settings/layout.tsx | 46 +++--- clients/admin/src/pages/settings/profile.tsx | 40 ++--- clients/admin/src/pages/settings/security.tsx | 141 +++++++++--------- clients/admin/src/pages/settings/sessions.tsx | 72 +++++---- clients/admin/src/pages/users/detail.tsx | 67 +++++---- clients/admin/src/pages/users/list.tsx | 84 ++++++----- clients/admin/tests/i18n/parity.spec.ts | 2 +- 35 files changed, 1729 insertions(+), 579 deletions(-) create mode 100644 clients/admin/src/locales/en-US/auth.json create mode 100644 clients/admin/src/locales/en-US/impersonation.json create mode 100644 clients/admin/src/locales/en-US/roles.json create mode 100644 clients/admin/src/locales/en-US/sessions.json create mode 100644 clients/admin/src/locales/en-US/settings.json create mode 100644 clients/admin/src/locales/en-US/users.json create mode 100644 clients/admin/src/locales/pt-BR/auth.json create mode 100644 clients/admin/src/locales/pt-BR/impersonation.json create mode 100644 clients/admin/src/locales/pt-BR/roles.json create mode 100644 clients/admin/src/locales/pt-BR/sessions.json create mode 100644 clients/admin/src/locales/pt-BR/settings.json create mode 100644 clients/admin/src/locales/pt-BR/users.json diff --git a/clients/admin/src/components/auth/demo-accounts-dialog.tsx b/clients/admin/src/components/auth/demo-accounts-dialog.tsx index e0f8b25c84..fe16a086ee 100644 --- a/clients/admin/src/components/auth/demo-accounts-dialog.tsx +++ b/clients/admin/src/components/auth/demo-accounts-dialog.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { ArrowUpRight, ShieldCheck } from "lucide-react"; import { Dialog, @@ -26,6 +27,7 @@ interface DemoAccountsDialogProps { } export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsDialogProps) { + const { t } = useTranslation("auth"); // Nothing to reset on re-open (single account list, no tenant rail). useEffect(() => {}, [open]); @@ -37,9 +39,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD return ( - Demo accounts + {t("demo.dialogTitle")} - Pick a demo account to sign in to the admin console. + {t("demo.dialogDescription")} {/* Atmospheric gradient wash */} @@ -60,14 +62,14 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD - Dev · demo + {t("demo.badge")}

- Sign in as operator. + {t("demo.title")}

- Tap an account below — we'll fill the credentials and sign you in instantly. + {t("demo.subtitle")}

@@ -75,11 +77,11 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
- Operator accounts + {t("demo.operatorAccounts")}
- tap to sign in + {t("demo.tapToSignIn")}
@@ -100,9 +102,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD

- dev only + {t("demo.devOnly")} · - Not visible in production. + {t("demo.notVisibleInProduction")}

diff --git a/clients/admin/src/components/auth/inactivity-dialog.tsx b/clients/admin/src/components/auth/inactivity-dialog.tsx index fbee548cb4..cde62e8dcf 100644 --- a/clients/admin/src/components/auth/inactivity-dialog.tsx +++ b/clients/admin/src/components/auth/inactivity-dialog.tsx @@ -1,4 +1,5 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { useTranslation } from "react-i18next"; import { ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/cn"; @@ -34,6 +35,7 @@ export function InactivityDialog({ onStay: () => void; onSignOut: () => void; }) { + const { t } = useTranslation("auth"); const fraction = totalSeconds > 0 ? Math.max(0, Math.min(1, secondsLeft / totalSeconds)) : 0; const dashOffset = RING_CIRCUMFERENCE * (1 - fraction); @@ -96,7 +98,7 @@ export function InactivityDialog({ {Math.max(0, secondsLeft)} - seconds + {t("inactivity.seconds")}
@@ -105,23 +107,22 @@ export function InactivityDialog({
- Still there? + {t("inactivity.title")}
- You've been inactive for a while. We'll sign you out shortly to keep your - account secure. + {t("inactivity.description")}
diff --git a/clients/admin/src/components/impersonation/impersonate-dialog.tsx b/clients/admin/src/components/impersonation/impersonate-dialog.tsx index 0cbe613033..24695f77c4 100644 --- a/clients/admin/src/components/impersonation/impersonate-dialog.tsx +++ b/clients/admin/src/components/impersonation/impersonate-dialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useMutation, useQuery, keepPreviousData } from "@tanstack/react-query"; import { ArrowLeft, ArrowRight, Check, Search, ShieldAlert, UserCog } from "lucide-react"; import { toast } from "sonner"; @@ -21,6 +22,8 @@ import { ApiRequestError } from "@/lib/api-client"; import { env } from "@/env"; import { cn } from "@/lib/cn"; +type TFn = (key: string, opts?: Record) => string; + type Props = { open: boolean; onOpenChange: (open: boolean) => void; @@ -54,6 +57,7 @@ export function ImpersonateDialog({ tenantName, prefillUser, }: Props) { + const { t } = useTranslation("impersonation"); const [step, setStep] = useState<"pick" | "configure">(prefillUser ? "configure" : "pick"); const [selected, setSelected] = useState(prefillUser ?? null); @@ -76,14 +80,14 @@ export function ImpersonateDialog({ > - Impersonate user + {t("dialog.title")} - Tenant{" "} + {t("dialog.tenant")}{" "} {tenantName ?? tenantId} ·{" "} {step === "pick" - ? "pick a user to impersonate." - : "session details. Token will be issued and opened in the dashboard."} + ? t("dialog.descPick") + : t("dialog.descConfigure")} @@ -130,6 +134,7 @@ function PickStep({ onPick: (user: UserDto) => void; onCancel: () => void; }) { + const { t } = useTranslation("impersonation"); const [search, setSearch] = useState(""); const [debounced, setDebounced] = useState(""); @@ -164,8 +169,8 @@ function PickStep({ autoFocus value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="Search by name, email, or username…" - aria-label="Search users to impersonate" + placeholder={t("dialog.pick.searchPlaceholder")} + aria-label={t("dialog.pick.searchAria")} className="pl-9" /> @@ -175,20 +180,20 @@ function PickStep({
{query.error instanceof ApiRequestError ? query.error.problem?.detail ?? query.error.message - : "Failed to load users."} + : t("dialog.pick.loadError")}
)} {query.isLoading && (
- Loading + {t("dialog.pick.loading")}
)} {!query.isLoading && users.length === 0 && (
- No users match{debounced ? ` “${debounced}”` : ""}. + {t("dialog.pick.noMatch")}{debounced ? ` “${debounced}”` : ""}.
)} @@ -213,7 +218,7 @@ function PickStep({ {[user.firstName, user.lastName].filter(Boolean).join(" ") || user.userName || user.email || - "Unnamed"} + t("dialog.pick.unnamed")} {user.userName && ( @@ -235,7 +240,7 @@ function PickStep({ @@ -257,6 +262,7 @@ function ConfigureStep({ onBack?: () => void; onDone: () => void; }) { + const { t } = useTranslation("impersonation"); const [reason, setReason] = useState(""); const [minutes, setMinutes] = useState(15); @@ -273,8 +279,8 @@ function ConfigureStep({ }), onSuccess: (response) => { handoffToDashboard(response, tenantId); - toast.success(`Impersonation started · ${minutes} min`, { - description: `Opened the dashboard as ${labelFor(user)}. End impersonation from inside the dashboard tab.`, + toast.success(t("dialog.toast.started", { minutes }), { + description: t("dialog.toast.startedDesc", { name: labelFor(user, t) }), }); onDone(); }, @@ -283,7 +289,7 @@ function ConfigureStep({ err instanceof ApiRequestError ? err.problem?.detail ?? err.problem?.title ?? err.message : err.message; - toast.error("Impersonation failed", { description: detail }); + toast.error(t("dialog.toast.failed"), { description: detail }); }, }); @@ -293,7 +299,7 @@ function ConfigureStep({
- // Duration + {t("dialog.configure.duration")}
{DURATION_OPTIONS.map((opt) => { const active = minutes === opt.minutes; @@ -313,7 +319,7 @@ function ConfigureStep({ {opt.minutes} - minutes + {t("dialog.configure.minutes")} ); })} @@ -325,7 +331,7 @@ function ConfigureStep({ htmlFor="impersonation-reason" className="meta flex items-center gap-1.5 text-[var(--color-muted-foreground)]" > - Reason + {t("dialog.configure.reason")} · @@ -334,7 +340,7 @@ function ConfigureStep({ id="impersonation-reason" value={reason} onChange={(e) => setReason(e.target.value)} - placeholder="e.g. Customer ticket #4821 — verifying ledger discrepancy" + placeholder={t("dialog.configure.reasonPlaceholder")} rows={3} maxLength={500} className={cn( @@ -346,8 +352,7 @@ function ConfigureStep({ />

- Recorded in the security audit trail — be specific enough that a reviewer can - reconstruct the case later. + {t("dialog.configure.reasonHelp")}

- Everything you do is attributed to your account. - {" "}The session token carries actor claims; the audit trail will show - both the user being impersonated and you as the actor. End from the dashboard - tab when done. + {t("dialog.configure.warningStrong")} + {t("dialog.configure.warningRest")}
@@ -374,7 +377,7 @@ function ConfigureStep({ {onBack && ( )} @@ -404,6 +407,7 @@ function SelectedUserCard({ tenantId: string; tenantName?: string; }) { + const { t } = useTranslation("impersonation"); return (
- {labelFor(user)} + {labelFor(user, t)} {user.userName && ( @{user.userName} @@ -456,11 +460,11 @@ function handoffToDashboard(response: ImpersonationResponse, tenantId: string) { window.open(url, "_blank", "noopener,noreferrer"); } -function labelFor(user: UserDto): string { +function labelFor(user: UserDto, t: TFn): string { return ( [user.firstName, user.lastName].filter(Boolean).join(" ").trim() || user.userName || user.email || - "Unnamed user" + t("dialog.unnamedUser") ); } diff --git a/clients/admin/src/components/impersonation/revoke-grant-dialog.tsx b/clients/admin/src/components/impersonation/revoke-grant-dialog.tsx index 9b78466549..ef8339d82b 100644 --- a/clients/admin/src/components/impersonation/revoke-grant-dialog.tsx +++ b/clients/admin/src/components/impersonation/revoke-grant-dialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ShieldOff } from "lucide-react"; import { toast } from "sonner"; @@ -34,6 +35,7 @@ type Props = { * operator knows exactly what they're killing. */ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) { + const { t } = useTranslation("impersonation"); const queryClient = useQueryClient(); const [reason, setReason] = useState(""); const open = grant !== null; @@ -46,8 +48,10 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) { const mutation = useMutation({ mutationFn: () => revokeImpersonationGrant(grant!.id, reason.trim() || undefined), onSuccess: (updated) => { - toast.success("Impersonation revoked", { - description: `Token for ${updated.impersonatedUserName ?? updated.impersonatedUserId} will be rejected on the next request.`, + toast.success(t("revokeDialog.toast.revoked"), { + description: t("revokeDialog.toast.revokedDesc", { + name: updated.impersonatedUserName ?? updated.impersonatedUserId, + }), }); queryClient.invalidateQueries({ queryKey: ["impersonation-grants"] }); onRevoked?.(updated); @@ -58,7 +62,7 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) { err instanceof ApiRequestError ? err.problem?.detail ?? err.problem?.title ?? err.message : err.message; - toast.error("Revoke failed", { description: detail }); + toast.error(t("revokeDialog.toast.revokeFailed"), { description: detail }); }, }); @@ -73,11 +77,10 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) { > - Revoke impersonation grant + {t("revokeDialog.title")}
- The issued token will be rejected on the next authenticated request (within ~1 - second of cache TTL). The session in the dashboard tab is killed without warning. + {t("revokeDialog.description")} @@ -89,13 +92,13 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) { htmlFor="revoke-reason" className="meta text-[var(--color-muted-foreground)]" > - Reason (optional) + {t("revokeDialog.reasonLabel")}