From c498afcc1c4136165dd24679d030f3e46760acf9 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:42:52 -0300 Subject: [PATCH 1/2] feat(localization): request localization, localized errors and user locale Framework slice of the i18n work (split of #1344 as requested in review). - `SharedResources` catalog (en + pt-BR) and `SupportedCultures` as the single source of supported tags. - `CustomException` carries `MessageKey`, `MessageArgs` and `ResourceSource`; `Message` stays English so logs remain culture-independent. `ILocalizableMessage` subclasses keep `UnauthorizedAccessException` / `KeyNotFoundException` as base types so audit severity classification is unaffected. - `GlobalExceptionHandler` localizes `title`/`detail` and surfaces the message key as a stable `code` extension on ProblemDetails. - `UseHeroLocalization` request-localization chain, UI-culture-only: `CurrentCulture` stays invariant, only `CurrentUICulture` is negotiated. `UserLocaleRequestCultureProvider` reads the `locale` claim, so the middleware sits between `UseAuthentication` and `UseAuthorization`. - `User.Locale` (`varchar(10)`, nullable, no database default; `en-US` is a code-level fallback) plus the `AddUserLocale` migration, the `locale` claim emission and the write-boundary validator rejecting tags outside `SupportedCultures.Tags`. - `LogContext.PushProperty` scoped in `using` blocks, fixing a pre-existing AsyncLocal leak that contaminated later log entries in the same request. - `SSH.NET` pin (`2026.0.0`), byte-identical to #1333, so `dotnet restore` passes while that PR is open. --- .agents/rules/architecture.md | 5 +- .agents/rules/localization.md | 77 ++ AGENTS.md | 1 + src/BuildingBlocks/Core/Core.csproj | 2 + .../Core/Exceptions/CustomException.cs | 20 +- .../Core/Exceptions/ForbiddenException.cs | 1 + .../Core/Exceptions/ILocalizableMessage.cs | 20 + .../LocalizedKeyNotFoundException.cs | 28 + .../LocalizedUnauthorizedAccessException.cs | 28 + .../Core/Exceptions/UnauthorizedException.cs | 1 + .../Core/Localization/SharedResources.cs | 4 + .../Localization/SharedResources.pt-BR.resx | 244 +++++ .../Core/Localization/SharedResources.resx | 244 +++++ .../Core/Localization/SupportedCultures.cs | 18 + src/BuildingBlocks/Jobs/Extensions.cs | 11 +- .../Storage/QuotaMeteredStorageService.cs | 6 +- .../Web/Exceptions/GlobalExceptionHandler.cs | 123 ++- src/BuildingBlocks/Web/Extensions.cs | 6 + .../Localization/LocalizationExtensions.cs | 75 ++ .../UserLocaleRequestCultureProvider.cs | 31 + src/Directory.Packages.props | 7 + .../20260720062947_AddUserLocale.Designer.cs | 846 ++++++++++++++++++ .../Identity/20260720062947_AddUserLocale.cs | 31 + .../IdentityDbContextModelSnapshot.cs | 4 + .../DTOs/UserDto.cs | 3 + .../Services/IUserProfileService.cs | 2 +- .../Services/IUserService.cs | 2 +- .../v1/Users/UpdateUser/UpdateUserCommand.cs | 1 + .../Data/IdentityConfigurations.cs | 7 + .../Modules.Identity/Domain/FshUser.cs | 3 + .../StartImpersonationCommandHandler.cs | 34 +- .../UpdateUser/UpdateUserCommandHandler.cs | 1 + .../UpdateUser/UpdateUserCommandValidator.cs | 17 +- .../Features/v1/Users/UserImageValidator.cs | 10 +- .../Localization/IdentityResources.cs | 4 + .../Localization/IdentityResources.pt-BR.resx | 265 ++++++ .../Localization/IdentityResources.resx | 265 ++++++ .../Modules.Identity/Modules.Identity.csproj | 2 +- .../Services/IdentityService.cs | 81 +- .../Services/UserProfileService.cs | 46 +- .../Modules.Identity/Services/UserService.cs | 4 +- .../ExceptionSeverityClassifierTests.cs | 40 + .../SharedResourcesKeyParityTests.cs | 40 + .../SharedResourcesLocalizationTests.cs | 51 ++ .../SharedResourcesLocalizerFactory.cs | 27 + .../UserLocaleRequestCultureProviderTests.cs | 138 +++ ...GlobalExceptionHandlerLocalizationTests.cs | 241 +++++ .../Web/GlobalExceptionHandlerTests.cs | 6 +- .../Data/IdentityDbContextModelTests.cs | 56 ++ .../StartImpersonationCommandHandlerTests.cs | 100 +++ .../Handlers/UpdateUserCommandHandlerTests.cs | 8 +- .../Services/CreateBasicClaimsTests.cs | 38 + .../Services/UserLocaleTests.cs | 95 ++ .../SharedResourcesLocalizerFactory.cs | 18 + .../Support/TestAsyncQueryable.cs | 68 ++ .../UpdateUserCommandValidatorTests.cs | 100 ++- 56 files changed, 3520 insertions(+), 86 deletions(-) create mode 100644 .agents/rules/localization.md create mode 100644 src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs create mode 100644 src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs create mode 100644 src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.cs create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.resx create mode 100644 src/BuildingBlocks/Core/Localization/SupportedCultures.cs create mode 100644 src/BuildingBlocks/Web/Localization/LocalizationExtensions.cs create mode 100644 src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs 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 create mode 100644 src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs create mode 100644 src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx create mode 100644 src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs create mode 100644 src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs create mode 100644 src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs create mode 100644 src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs 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/SharedResourcesLocalizerFactory.cs create mode 100644 src/Tests/Identity.Tests/Support/TestAsyncQueryable.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/.agents/rules/localization.md b/.agents/rules/localization.md new file mode 100644 index 0000000000..c723615991 --- /dev/null +++ b/.agents/rules/localization.md @@ -0,0 +1,77 @@ +# Localization (i18n) + +`src/BuildingBlocks/Core/Localization/` + per-module `Localization/` folders. Read before adding any user-facing message (exception, validation, API error). **The client's culture decides which words the client reads; it never decides how the API formats numbers or dates.** + +## Culture negotiation (already wired — don't re-add) + +`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request **UI** culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures.Tags`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically. + +**`CultureInfo.CurrentUICulture` only — `CurrentCulture` stays invariant.** An API that emits JSON must not shift `ToString()`, `Parse()` or interpolation per request; both React apps format at the presentation layer. `RequestLocalizationMiddleware` assigns both cultures unconditionally, so the culture half is pinned rather than left alone: `DefaultRequestCulture` carries `(InvariantCulture, configured default)` and `SupportedCultures` is `null` so the middleware skips culture filtering. Do **not** "fix" this by adding `AddSupportedCultures(...)`; `Formatting_culture_stays_invariant_while_ui_culture_negotiates` fails if you do. + +`SupportedCultures.Tags` is **specific tags only**, no neutrals. A request asking for a bare `pt`, or for an unsupported variant like `pt-PT`, resolves to the configured default rather than being served a language it was not translated into. The React apps canonicalize variants onto supported tags (`CANON` in `clients/*/src/i18n.ts`) before calling the API, so app traffic is unaffected; a hand-rolled client sending bare `pt` gets the default. Adding a language means: add its specific tag to `Tags`, add a `*.{tag}.resx` per catalog, add its JSON catalogs to both apps, and remove it from `CANON` if it was being folded into another tag. + +## Catalogs — hybrid, one marker per catalog + +- **Core (`SharedResources`)** — generic / cross-cutting messages: ProblemDetails titles (`Error.*`), cross-module errors (`Error.TenantContextRequired`, `Error.NoCurrentUser`, …), and shared validation (`Validation.*`). +- **Per module (`Resources`)** — domain-specific messages owned by the module: `src/Modules//Modules./Localization/Resources.cs` (marker `public sealed class Resources;`) + co-located `Resources.resx` (neutral / en-US) + `Resources.pt-BR.resx`. `ResourcesPath = ""` (co-located), so the resx manifest name must equal the marker's full type name. + +Catalogs are named for **specific** cultures (`.pt-BR`, never a neutral `.pt`), matching the front-end catalog folders. The neutral, un-suffixed `.resx` is the en-US / ultimate-fallback catalog. + +Key naming: `Error..` for domain messages (`Catalog.ProductNotFound`), `Error.` / `Validation.` for Core. PascalCase. Placeholders are `{0}`, `{1}` (`string.Format` via the localizer) — **not** the frontend's `{{name}}`. + +**Placeholder arguments must be culture-insensitive.** The localizer's indexer calls `string.Format` under `CurrentCulture`, which is invariant (above). Pass `int`/`long`/`string`/enum — never a `double`, `decimal`, `DateTime` or `TimeSpan.TotalX`, which would render with an invariant separator instead of the reader's. Where a count is conceptually whole, expose it as an `int` at the source rather than converting at the call site (see `GetAuditsQueryHandler.MaxWindowDays` next to `MaxWindow`). Money and dates belong in structured response fields formatted by the client, not interpolated into a message. + +## Exceptions — localize at the boundary, log stays English + +Throw with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site. + +```csharp +// domain message -> module catalog +throw new NotFoundException($"Product {id} not found.") +{ + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [id], + ResourceSource = typeof(CatalogResources), +}; + +// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources) +throw new UnauthorizedException("Tenant context is required.") +{ + MessageKey = "Error.TenantContextRequired", +}; +``` + +`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException("...")` still renders its English literal. + +**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body. + +## Validators — inject the localizer, defer resolution + +```csharp +public sealed class XCommandValidator : AbstractValidator +{ + public XCommandValidator(IStringLocalizer localizer) + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage(_ => localizer["Validation.NameRequired"]); + } +} +``` + +Always the `.WithMessage(_ => localizer["Key"])` lambda (resolution is deferred to `Validate()`, under the request culture) — never `.WithMessage(localizer["Key"])`. **Catalog choice:** inject `IStringLocalizer` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<Resources>` for module-specific validation messages kept in the module's own catalog. DI provides the localizer automatically (`AddValidatorsFromAssembly` + `AddHeroLocalization` + the module's own `AddLocalization`); nested validators (`Include(new PagedQueryValidator(localizer))`) receive it from the parent. + +## Known behaviour (documented, not bugs) + +- **The `locale` claim lags a language switch by one token.** The culture provider reads the JWT `locale` claim, so a switch does not reach the API until the next token issue. The front-end persists the choice to the profile and re-mints, so it converges; in the window between, the shell can be in the new language while an API error still comes back in the old one. Deliberate: the alternative is a per-request DB read on every authenticated call. +- **Impersonation carries the operator's language, not the target's.** `StartImpersonationCommandHandler` strips the target's `locale` claim so the operator keeps reading in their own language, and the cross-app handoff URL carries `locale` because the dashboard is normally on a different origin and cannot read admin's `i18nextLng`. During impersonation the switcher is client-side only — it must not PUT onto the impersonated user's profile. +- **SignalR does not carry the app locale.** The hub client builds its own requests instead of going through `apiFetch`, so `Accept-Language` on the negotiate is the browser's. Applies to every session. `handoff-locale.spec.ts` names the exception explicitly so any *other* channel that stops carrying the locale fails the test. + +## Tests (required with every catalog change) + +- **Parity** — every key present in both the neutral and the `pt-BR` catalog, for Core and every `Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test. +- **Code → resx guard** — every referenced key (`MessageKey`, `localizer["…"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently). +- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub. + +## Emails / background handlers + +Integration-event handlers run without an HTTP request, so there is no negotiated culture. Localizing outbound emails needs the recipient's stored locale propagated to the handler — **not yet implemented** (tracked for a future PR); email bodies stay English for now. diff --git a/AGENTS.md b/AGENTS.md index cbe60e9e1f..6603c5120e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ Single long-lived branch: **`main`** (the default) — there is **no `develop`** | CORS, security headers, rate limiting, idempotency, quotas | `security.md` | | SignalR / SSE backend | `realtime.md` | | Logging, correlation, OpenTelemetry | `logging.md` | +| Localization (i18n), request culture, resource catalogs, localized exceptions | `localization.md` | | Unit test conventions, NetArchTest | `testing.md` | | Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` | | **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` | 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/Exceptions/CustomException.cs b/src/BuildingBlocks/Core/Exceptions/CustomException.cs index 02dfa51699..ddc183c5d7 100644 --- a/src/BuildingBlocks/Core/Exceptions/CustomException.cs +++ b/src/BuildingBlocks/Core/Exceptions/CustomException.cs @@ -7,7 +7,7 @@ namespace FSH.Framework.Core.Exceptions; /// FullStackHero exception used for consistent error handling across the stack. /// Includes HTTP status codes and optional detailed error messages. /// -public class CustomException : Exception +public class CustomException : Exception, ILocalizableMessage { /// /// A list of error messages (e.g., validation errors, business rules). @@ -19,6 +19,24 @@ public class CustomException : Exception /// public HttpStatusCode StatusCode { get; } + /// + /// Optional resource key resolved against to localize the + /// response body under the request culture. When null, the literal + /// is used. The message itself always stays the (English) fallback for logs. + /// + public string? MessageKey { get; init; } + + /// + /// Format arguments applied to the localized message ({0}, {1}, …). + /// + public IReadOnlyList MessageArgs { get; init; } = []; + + /// + /// Marker type identifying the resource catalog for . When null, + /// the shared (Core) catalog is used; module-specific keys point it at the module's own catalog. + /// + public Type? ResourceSource { get; init; } + /// /// Initializes a new instance of the class with default message and internal server error status. /// diff --git a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs index 5033c897b4..2a26e7f0a4 100644 --- a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs +++ b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs @@ -12,6 +12,7 @@ public class ForbiddenException : CustomException public ForbiddenException() : base("Unauthorized access.", Array.Empty(), HttpStatusCode.Forbidden) { + MessageKey = "Error.ForbiddenAccess"; } /// diff --git a/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs b/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs new file mode 100644 index 0000000000..1aa1d7a698 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs @@ -0,0 +1,20 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// Implemented by exceptions whose response Detail can be localized from a resource key. +/// Lets GlobalExceptionHandler translate the body under the request culture while the +/// exception type stays intact — needed for BCL types the audit severity classifier keys off +/// (e.g. , ). +/// The stays the English fallback for logs and unresolved keys. +/// +public interface ILocalizableMessage +{ + /// Resource key resolved against ; null keeps the literal message. + string? MessageKey { get; } + + /// Format arguments applied to the localized message ({0}, {1}, …). + IReadOnlyList MessageArgs { get; } + + /// Marker type identifying the resource catalog; null uses the shared (Core) catalog. + Type? ResourceSource { get; } +} diff --git a/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs b/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs new file mode 100644 index 0000000000..0bd3d44009 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// whose 404 response Detail is localized via . +/// Subclasses the BCL type on purpose so audit exception-type fixtures and severity classification that +/// key off keep working, while the body still translates under the +/// request culture. The base message stays the English log fallback. +/// +public sealed class LocalizedKeyNotFoundException : KeyNotFoundException, ILocalizableMessage +{ + public string? MessageKey { get; init; } + public IReadOnlyList MessageArgs { get; init; } = []; + public Type? ResourceSource { get; init; } + + public LocalizedKeyNotFoundException() + { + } + + public LocalizedKeyNotFoundException(string message) + : base(message) + { + } + + public LocalizedKeyNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs b/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs new file mode 100644 index 0000000000..c17db1dd97 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// whose 401 response Detail is localized via +/// . Subclasses the BCL type on purpose so the audit severity classifier +/// (which maps to Warning) keeps working, while the body +/// still translates under the request culture. The base message stays the English log fallback. +/// +public sealed class LocalizedUnauthorizedAccessException : UnauthorizedAccessException, ILocalizableMessage +{ + public string? MessageKey { get; init; } + public IReadOnlyList MessageArgs { get; init; } = []; + public Type? ResourceSource { get; init; } + + public LocalizedUnauthorizedAccessException() + { + } + + public LocalizedUnauthorizedAccessException(string message) + : base(message) + { + } + + public LocalizedUnauthorizedAccessException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs index b3815a4b7b..3acf01fd1d 100644 --- a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs +++ b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs @@ -12,6 +12,7 @@ public class UnauthorizedException : CustomException public UnauthorizedException() : base("Authentication failed.", Array.Empty(), HttpStatusCode.Unauthorized) { + MessageKey = "Error.AuthenticationFailed"; } /// 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-BR.resx b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx new file mode 100644 index 0000000000..f9d387c648 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + Ocorreram um ou mais erros de validação. + + + Não autorizado + + + Não encontrado + + + Requisição inválida + + + Conflito + + + Acesso negado + + + Nenhum usuário autenticado. + + + Tenant inválido ou ausente. + + + O contexto do tenant é obrigatório. + + + Ocorreu um erro inesperado + + + Ocorreu um erro inesperado. Tente novamente mais tarde. + + + Limite de aplicação + + + Contas de superadministrador devem usar o aplicativo de administração. Entre por lá, não pelo painel da organização. + + + Falha na autenticação. + + + Acesso não autorizado. + + + Cota de armazenamento excedida ({0}/{1} bytes). + + + O valor de Take deve estar entre 1 e {0}. + + + A duração deve estar entre 1 e {0} minutos. + + + Somente estas extensões são permitidas: {0} + + + O arquivo deve ter no máximo {0} MB. + + + Opções de banco de dados não encontradas. + + + O provedor de armazenamento {0} do Hangfire não é suportado. + + + 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. + + + 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 tamanho da página deve estar entre 1 e 100. + + + A expressão de ordenação não pode exceder 200 caracteres. + + + 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. + + + A organização é obrigatória. + + + 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 new file mode 100644 index 0000000000..742a6022ed --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.resx @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + One or more validation errors occurred. + + + Unauthorized + + + Not Found + + + Bad Request + + + Conflict + + + Forbidden + + + No authenticated user. + + + Invalid or missing tenant. + + + Tenant context is required. + + + An unexpected error occurred + + + An unexpected error occurred. Please try again later. + + + App boundary + + + SuperAdmin accounts must use the admin app. Sign in there instead of the tenant dashboard. + + + Authentication failed. + + + Unauthorized access. + + + Storage quota exceeded ({0}/{1} bytes). + + + Take must be between 1 and {0}. + + + Duration must be between 1 and {0} minutes. + + + Only these extensions are allowed: {0} + + + File must be <= {0} MB. + + + Database options not found. + + + Hangfire storage provider {0} is not supported. + + + User ID is required. + + + You cannot upload a new image and delete the current one simultaneously. + + + 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. + + + Page size must be between 1 and 100. + + + Sort expression must not exceed 200 characters. + + + 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/BuildingBlocks/Core/Localization/SupportedCultures.cs b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs new file mode 100644 index 0000000000..6b00f20e3a --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs @@ -0,0 +1,18 @@ +namespace FSH.Framework.Core.Localization; + +/// Canonical set of cultures the platform supports for user-facing localization. +public static class SupportedCultures +{ + /// Guaranteed ultimate fallback culture, served by the neutral (un-suffixed) catalog. + public const string Default = "en-US"; + + /// + /// Specific tags a user may persist, the switcher offers, and Accept-Language is matched against. + /// Deliberately specific-only, with no neutral entries: every catalog is named for a specific + /// culture (*.pt-BR.resx), matching the front-end catalogs. A request asking for a bare + /// pt, or for an unsupported variant like pt-PT, therefore resolves to + /// rather than being silently served Brazilian strings. Adding a language + /// means adding its specific tag here plus a *.{tag}.resx per catalog. + /// + public static readonly string[] Tags = ["en-US", "pt-BR"]; +} diff --git a/src/BuildingBlocks/Jobs/Extensions.cs b/src/BuildingBlocks/Jobs/Extensions.cs index 9ab773b0fc..57a9b4da58 100644 --- a/src/BuildingBlocks/Jobs/Extensions.cs +++ b/src/BuildingBlocks/Jobs/Extensions.cs @@ -32,7 +32,10 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services) { var configuration = provider.GetRequiredService(); var dbOptions = configuration.GetSection(nameof(DatabaseOptions)).Get() - ?? throw new CustomException("Database options not found"); + ?? throw new CustomException("Database options not found") + { + MessageKey = "Jobs.DatabaseOptionsNotFound", + }; switch (dbOptions.Provider.ToUpperInvariant()) { @@ -48,7 +51,11 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services) break; default: - throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported"); + throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported") + { + MessageKey = "Jobs.UnsupportedStorageProvider", + MessageArgs = [dbOptions.Provider], + }; } config.UseActivator(new FshJobActivator(provider.GetRequiredService())); diff --git a/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs b/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs index 2dcef1323f..3938bf8aef 100644 --- a/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs +++ b/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs @@ -69,7 +69,11 @@ public async Task UploadAsync(FileUploadRequest request, FileType fil throw new CustomException( $"Storage quota exceeded ({check.CurrentUsage}/{check.Limit} bytes).", errors: null, - HttpStatusCode.InsufficientStorage); + HttpStatusCode.InsufficientStorage) + { + MessageKey = "Storage.QuotaExceeded", + MessageArgs = [check.CurrentUsage, check.Limit], + }; } try diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs index 8e8987dae7..903f6704ed 100644 --- a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -1,16 +1,78 @@ using System.Diagnostics; using System; +using System.Net; 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, + IStringLocalizerFactory localizerFactory) : IExceptionHandler { + // Returns null for a status with no title of its own. Deliberately NOT a catch-all + // "Error.Unexpected": that key exists, so ResourceNotFound would be false and a 409 would + // report "An unexpected error occurred" alongside Status 409 and a Detail describing a + // perfectly ordinary business-rule conflict — a title contradicting its own status code. + // A null key keeps the pre-localization behaviour (the exception type name) for every + // status not translated here, which is at least status-consistent. + private static string? TitleKeyFor(HttpStatusCode statusCode) => statusCode switch + { + HttpStatusCode.NotFound => "Error.NotFound", + HttpStatusCode.Unauthorized => "Error.Unauthorized", + HttpStatusCode.Forbidden => "Error.Forbidden", + HttpStatusCode.BadRequest => "Error.BadRequest", + HttpStatusCode.Conflict => "Error.Conflict", + _ => null, + }; + + // Resolves the localized Detail for an exception carrying a MessageKey, under the request culture. + // The [key, args] indexer runs string.Format; a stray '{' in the resx would throw FormatException + // from inside the handler, so fall back to the (English) message on a format error or a missing key. + // A null key keeps the literal message. Shared by the CustomException, Unauthorized and NotFound + // branches so a localized BCL subclass (ILocalizableMessage) translates the same way. + private string LocalizeDetail(ILocalizableMessage localizable, string fallbackMessage) + { + if (localizable.MessageKey is null) + { + return fallbackMessage; + } + + var moduleLocalizer = localizerFactory.Create(localizable.ResourceSource ?? typeof(SharedResources)); + try + { + var message = localizable.MessageArgs.Count == 0 + ? moduleLocalizer[localizable.MessageKey] + : moduleLocalizer[localizable.MessageKey, localizable.MessageArgs.ToArray()]; + return message.ResourceNotFound ? fallbackMessage : message.Value; + } + catch (FormatException) + { + return fallbackMessage; + } + } + + // Writes the localized Detail and, when the exception carries a MessageKey, surfaces that key as a + // stable machine-readable "code". Detail is prose under the request culture, so a client that needs + // to branch on a specific error (a terminal state, a dedicated screen, a retry) keys off the code + // instead of matching text that changes with Accept-Language. + private void ApplyLocalizedDetail(ProblemDetails problemDetails, ILocalizableMessage localizable, string fallbackMessage) + { + problemDetails.Detail = LocalizeDetail(localizable, fallbackMessage); + + if (localizable.MessageKey is not null) + { + problemDetails.Extensions["code"] = localizable.MessageKey; + } + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(httpContext); @@ -28,8 +90,8 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e statusCode = StatusCodes.Status400BadRequest; problemDetails.Status = statusCode; - problemDetails.Title = "Validation error"; - problemDetails.Detail = "One or more validation errors occurred."; + problemDetails.Title = localizer["Error.Validation"]; + problemDetails.Detail = localizer["Error.Validation.Detail"]; problemDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; var errors = fluentException.Errors @@ -43,10 +105,13 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e else if (exception is CustomException e) { statusCode = (int)e.StatusCode; - problemDetails.Status = statusCode; - problemDetails.Title = e.GetType().Name; - problemDetails.Detail = e.Message; + + var titleKey = TitleKeyFor(e.StatusCode); + var title = titleKey is null ? null : localizer[titleKey]; + problemDetails.Title = title is null || title.ResourceNotFound ? e.GetType().Name : title.Value; + + ApplyLocalizedDetail(problemDetails, e, e.Message); if (e.ErrorMessages is { Count: > 0 }) { @@ -57,15 +122,29 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e { statusCode = StatusCodes.Status401Unauthorized; problemDetails.Status = statusCode; - problemDetails.Title = "Unauthorized"; - problemDetails.Detail = exception.Message; + problemDetails.Title = localizer["Error.Unauthorized"]; + if (exception is ILocalizableMessage unauthorizedLoc) + { + ApplyLocalizedDetail(problemDetails, unauthorizedLoc, exception.Message); + } + else + { + problemDetails.Detail = exception.Message; + } } else if (exception is KeyNotFoundException) { statusCode = StatusCodes.Status404NotFound; problemDetails.Status = statusCode; - problemDetails.Title = "Not Found"; - problemDetails.Detail = exception.Message; + problemDetails.Title = localizer["Error.NotFound"]; + if (exception is ILocalizableMessage notFoundLoc) + { + ApplyLocalizedDetail(problemDetails, notFoundLoc, exception.Message); + } + else + { + problemDetails.Detail = exception.Message; + } } else if (exception is BadHttpRequestException badRequest) { @@ -73,15 +152,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; @@ -94,12 +173,18 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e ?? httpContext.TraceIdentifier; problemDetails.Extensions["correlationId"] = correlationId; - LogContext.PushProperty("exception_title", problemDetails.Title); - LogContext.PushProperty("exception_detail", problemDetails.Detail); - LogContext.PushProperty("exception_statusCode", problemDetails.Status); - LogContext.PushProperty("exception_stackTrace", exception.StackTrace); - - logger.LogError("Exception at {Path} - {StatusCode} {Title}", httpContext.Request.Path.Value?.Replace(Environment.NewLine, string.Empty), statusCode, problemDetails.Title); + // Log the raw (English) exception message and type, never the localized ProblemDetails body, + // so log entries stay culture-independent regardless of the request's negotiated culture. + // PushProperty returns an IDisposable that pops the property on dispose; scope it to the LogError + // call so it does not leak onto every subsequent log entry of the request (AsyncLocal contamination). + var logPath = httpContext.Request.Path.Value?.Replace(Environment.NewLine, string.Empty); + using (LogContext.PushProperty("exception_type", exception.GetType().Name)) + using (LogContext.PushProperty("exception_detail", exception.Message)) + using (LogContext.PushProperty("exception_statusCode", statusCode)) + using (LogContext.PushProperty("exception_stackTrace", exception.StackTrace)) + { + logger.LogError("Exception at {Path} - {StatusCode} {Type}", logPath, statusCode, exception.GetType().Name); + } await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken).ConfigureAwait(false); return true; 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 UI-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 default culture. + /// Negotiation drives only — + /// stays invariant, so no request can shift numeric, + /// date or string formatting anywhere in the pipeline. + /// + 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 => + { + // UI-culture-only. RequestLocalizationMiddleware.SetCurrentThreadCulture assigns BOTH + // CurrentCulture and CurrentUICulture unconditionally, so there is no "leave formatting + // alone" switch: the culture half has to be pinned instead. Two things make that work. + // 1. DefaultRequestCulture carries the pair, and the middleware resolves the culture half + // as `cultureInfo ??= DefaultRequestCulture.Culture`. Pinning that half to invariant + // makes invariant the only value CurrentCulture can ever take. + // 2. SupportedCultures = null makes the middleware skip culture filtering entirely. + // A one-element [InvariantCulture] list would behave the same but log + // `UnsupportedCultures` on EVERY request: the middleware's parent-culture walk bails + // out at the empty culture name, so invariant is unmatchable by design. + // An API that emits JSON has no business shifting ToString()/Parse() per request; both + // React apps already format at the presentation layer. See #1344 review. + o.DefaultRequestCulture = new RequestCulture(CultureInfo.InvariantCulture, new CultureInfo(defaultCulture)); + o.SupportedCultures = null; + o.AddSupportedUICultures(SupportedCultures.Tags); + o.ApplyCurrentCultureToResponseHeaders = true; + + // Default order is [Query(0), Cookie(1), AcceptLanguage(2)]. Drop the cookie provider by + // type (order-independent, so a framework reshuffle of the defaults can't silently remove + // the wrong provider) and insert the user-claim provider right after query, so the final + // chain is Query → UserLocaleClaim → AcceptLanguage → configured default → en-US neutral resx. + var cookieProvider = o.RequestCultureProviders + .FirstOrDefault(p => p is CookieRequestCultureProvider); + if (cookieProvider is not null) + { + o.RequestCultureProviders.Remove(cookieProvider); + } + + 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/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..7674befa8f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -143,5 +143,12 @@ AccessViolation). Transitive pinning is enabled, so this entry alone bumps it. Remove once the SignalR backplane package depends on a patched version itself. --> + + \ No newline at end of file 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..38997b0597 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs @@ -0,0 +1,846 @@ +// +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") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + 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..566fb18c49 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs @@ -0,0 +1,31 @@ +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: "character varying(10)", + maxLength: 10, + 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 34efbeb275..89d3e50615 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs @@ -128,6 +128,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastPasswordChangeDate") .HasColumnType("timestamp with time zone"); + b.Property("Locale") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + b.Property("LockoutEnabled") .HasColumnType("boolean"); 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/Data/IdentityConfigurations.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs index e13d7842f2..7dc1105f5e 100644 --- a/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs @@ -19,6 +19,13 @@ public void Configure(EntityTypeBuilder builder) builder .Property(u => u.ObjectId) .HasMaxLength(256); + + // A BCP-47 tag is short and bounded; 10 covers language-script-region (zh-Hant-TW). Writes are + // additionally constrained to SupportedCultures.Tags by UpdateUserCommandValidator, so this is + // the storage-level backstop, not the validation. + builder + .Property(u => u.Locale) + .HasMaxLength(10); } } 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..4876de715f 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 @@ -6,6 +6,7 @@ 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.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -58,7 +59,10 @@ public async ValueTask Handle( var actorUserId = _currentUser.GetUserId().ToString(); var actorTenantId = _currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var actorUserName = _currentUser.Name; // Cross-tenant impersonation requires the actor to be in the root tenant. Tenant admins @@ -66,7 +70,11 @@ public async ValueTask Handle( if (!string.Equals(actorTenantId, MultitenancyConstants.Root.Id, StringComparison.Ordinal) && !string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal)) { - throw new ForbiddenException("cross-tenant impersonation is restricted to platform operators"); + throw new ForbiddenException("cross-tenant impersonation is restricted to platform operators") + { + MessageKey = "Identity.CrossTenantImpersonationRestricted", + ResourceSource = typeof(IdentityResources), + }; } // Prevent self-impersonation (pointless, confuses the audit trail). Caller error → explicit 4xx, @@ -74,7 +82,11 @@ public async ValueTask Handle( if (string.Equals(actorUserId, request.TargetUserId, StringComparison.Ordinal) && string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal)) { - throw new CustomException("cannot impersonate yourself", errors: null, System.Net.HttpStatusCode.BadRequest); + throw new CustomException("cannot impersonate yourself", errors: null, System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.CannotImpersonateYourself", + ResourceSource = typeof(IdentityResources), + }; } // Prevent nesting: if the caller is already impersonating, require end-impersonation first. @@ -85,7 +97,11 @@ public async ValueTask Handle( throw new CustomException( "end current impersonation before starting a new one", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.EndImpersonationFirst", + ResourceSource = typeof(IdentityResources), + }; } var targetClaimsResult = await _identityService @@ -93,7 +109,11 @@ public async ValueTask Handle( if (targetClaimsResult is null) { - throw new NotFoundException("target user not found"); + throw new NotFoundException("target user not found") + { + MessageKey = "Identity.TargetUserNotFound", + ResourceSource = typeof(IdentityResources), + }; } var (subject, claims) = targetClaimsResult.Value; @@ -104,7 +124,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/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs index 6fc722d9d9..b8b4ef32ac 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,16 +1,18 @@ -using FluentValidation; +using FluentValidation; +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) @@ -31,12 +33,17 @@ public UpdateUserCommandValidator() When(x => x.Image is not null, () => { RuleFor(x => x.Image!) - .SetValidator(new UserImageValidator(FileType.Image)); + .SetValidator(new UserImageValidator(FileType.Image, localizer)); }); // 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(_ => localizer["Validation.UnsupportedLocale"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs index 283bfae87b..faf930994e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs @@ -1,24 +1,26 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Storage; using FSH.Framework.Storage; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users; public sealed class UserImageValidator : AbstractValidator { - public UserImageValidator() : this(FileType.Image) { } - public UserImageValidator(FileType fileType) + public UserImageValidator(IStringLocalizer localizer) : this(FileType.Image, localizer) { } + public UserImageValidator(FileType fileType, IStringLocalizer localizer) { var rules = FileTypeMetadata.GetRules(fileType); RuleFor(x => x.FileName) .NotEmpty() .Must(file => rules.AllowedExtensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) - .WithMessage($"Only these extensions are allowed: {string.Join(", ", rules.AllowedExtensions)}"); + .WithMessage(_ => localizer["Validation.AllowedExtensions", string.Join(", ", rules.AllowedExtensions)]); RuleFor(x => x.Data) .NotEmpty() .Must(data => data.Count <= rules.MaxSizeInMB * 1024 * 1024) - .WithMessage($"File must be <= {rules.MaxSizeInMB} MB."); + .WithMessage(_ => localizer["Validation.MaxFileSize", rules.MaxSizeInMB]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs new file mode 100644 index 0000000000..880bb0749f --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Identity.Localization; + +/// Marker type binding IStringLocalizer<IdentityResources> to the Identity resx catalog. +public sealed class IdentityResources; diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx new file mode 100644 index 0000000000..c9e3c5d814 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Grupo com ID '{0}' não encontrado. + + + Usuários não encontrados: {0} + + + Já existe um grupo com o nome '{0}'. + + + Funções não encontradas: {0} + + + Grupos do sistema não podem ser excluídos. + + + O usuário '{0}' não é membro do grupo '{1}'. + + + Usuários não podem ser removidos de um grupo padrão. + + + Grupos do sistema não podem ser modificados. + + + Função não encontrada. + + + Funções não encontradas. + + + Funções do sistema não podem ser modificadas. + + + Não é possível renomear uma função para o nome de uma função do sistema. + + + Não é possível criar uma função usando o nome de uma função do sistema. + + + Funções do sistema não podem ser excluídas. + + + As permissões de funções do sistema são gerenciadas pelo framework e não podem ser modificadas. + + + A operação falhou. + + + A sessão atual não é uma sessão de personificação. + + + Ator original não encontrado. + + + Concessão de personificação não encontrada. + + + A personificação entre tenants é restrita a operadores da plataforma. + + + Não é possível personificar a si mesmo. + + + Encerre a personificação atual antes de iniciar uma nova. + + + Usuário alvo não encontrado. + + + Usuário não encontrado. + + + Usuário {0} não encontrado. + + + A senha atual está incorreta. + + + Falha ao gerar a chave do autenticador. + + + O código do autenticador é inválido. + + + Token de atualização inválido. + + + A sessão foi revogada. + + + O assunto do token de acesso não corresponde. + + + two_factor_required: É necessário um código de autenticador para concluir o login. + + + two_factor_invalid: O código do autenticador é inválido ou expirou. + + + A conta está temporariamente bloqueada devido a muitas tentativas de login malsucedidas. Tente novamente mais tarde. + + + O token de atualização é inválido ou expirou. + + + O usuário está desativado. + + + E-mail não confirmado. + + + O tenant {0} está desativado. + + + A validade do tenant {0} expirou. + + + Erro ao redefinir a senha. + + + Falha ao alterar a senha. + + + Falha ao atualizar o perfil. + + + Falha ao atualizar a imagem do perfil. + + + Ocorreu um erro ao confirmar o e-mail. + + + Ocorreu um erro ao confirmar {0}. + + + Ocorreu um erro ao confirmar o e-mail de {0}: {1} + + + O e-mail de {0} já está confirmado. + + + Ocorreu um erro ao confirmar o número de telefone. + + + Ocorreu um erro ao confirmar o número de telefone {0}. + + + A claim de e-mail é obrigatória para autenticação externa. + + + Falha ao criar o usuário a partir do principal externo. + + + As senhas não coincidem. + + + Não foi possível registrar o usuário. + + + Administradores não podem remover a própria função de administrador. + + + O administrador do tenant raiz não pode ser rebaixado. + + + O tenant deve manter pelo menos um administrador. + + + Apenas administradores podem alterar o status do usuário. + + + Usuários não podem desativar a si mesmos. + + + Administradores não podem ser desativados. + + + O tenant deve ter pelo menos um administrador ativo. + + + Falha ao alternar o status. + + + Credenciais inválidas. + + + Não é possível visualizar sessões de outro usuário + + + Não é possível revogar a sessão de outro usuário + + + Não é possível revogar sessões de outro usuário + + + RoleManager<FshRole> não resolvido. Verifique o registro do Identity. + + + Repositório de funções não configurado. Garanta .AddRoles<FshRole>() e os stores do EF. + + + Método reservado para inicialização em escopo. + + diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx new file mode 100644 index 0000000000..5cc85fa437 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Group with ID '{0}' not found. + + + Users not found: {0} + + + Group with name '{0}' already exists. + + + Roles not found: {0} + + + System groups cannot be deleted. + + + User '{0}' is not a member of group '{1}'. + + + Users cannot be removed from a default group. + + + System groups cannot be modified. + + + Role not found. + + + Roles not found. + + + System roles cannot be modified. + + + Cannot rename a role to a system role's name. + + + Cannot create a role using a system role's name. + + + System roles cannot be deleted. + + + System role permissions are managed by the framework and cannot be modified. + + + Operation failed. + + + Current session is not an impersonation session. + + + Original actor not found. + + + Impersonation grant not found. + + + Cross-tenant impersonation is restricted to platform operators. + + + Cannot impersonate yourself. + + + End current impersonation before starting a new one. + + + Target user not found. + + + User not found. + + + User {0} not found. + + + Current password is incorrect. + + + Failed to generate authenticator key. + + + The authenticator code is invalid. + + + Invalid refresh token. + + + Session has been revoked. + + + Access token subject mismatch. + + + two_factor_required: An authenticator code is required to complete sign-in. + + + two_factor_invalid: The authenticator code is invalid or expired. + + + Account is temporarily locked due to too many failed login attempts. Try again later. + + + Refresh token is invalid or expired. + + + User is deactivated. + + + Email not confirmed. + + + Tenant {0} is deactivated. + + + Tenant {0} validity has expired. + + + Error resetting password. + + + Failed to change password. + + + Update profile failed. + + + Update profile image failed. + + + An error occurred while confirming E-Mail. + + + An error occurred while confirming {0}. + + + An error occurred while confirming the email for {0}: {1} + + + The email for {0} is already confirmed. + + + An error occurred while confirming phone number. + + + An error occurred while confirming phone number {0}. + + + Email claim is required for external authentication. + + + Failed to create user from external principal. + + + Passwords do not match. + + + Unable to register the user. + + + Administrators cannot remove their own admin role. + + + The root tenant administrator cannot be demoted. + + + Tenant must retain at least one administrator. + + + Only administrators can change user status. + + + Users cannot deactivate themselves. + + + Administrators cannot be deactivated. + + + Tenant must have at least one active administrator. + + + Toggle status failed. + + + Invalid credentials. + + + Cannot view sessions for another user + + + Cannot revoke session for another user + + + Cannot revoke sessions for another user + + + RoleManager<FshRole> not resolved. Check Identity registration. + + + Role store not configured. Ensure .AddRoles<FshRole>() and EF stores. + + + Method reserved for in-scope initialization + + diff --git a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj index 2ef3b71607..7f3773dcfe 100644 --- a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj +++ b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj @@ -3,7 +3,7 @@ FSH.Modules.Identity FSH.Modules.Identity - $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862;CA2227 + $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862;CA2227;S2094 diff --git a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs index a403a2363e..76e07c0ac0 100644 --- a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -72,7 +73,11 @@ private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCo throw new CustomException( "two_factor_required: An authenticator code is required to complete sign-in.", errors: null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Identity.TwoFactorRequired", + ResourceSource = typeof(IdentityResources), + }; } var valid = await _userManager.VerifyTwoFactorTokenAsync( @@ -83,7 +88,11 @@ private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCo if (!valid) { _logger.LogWarning("Invalid two-factor code for user {UserId}", user.Id); - throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired."); + throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired.") + { + MessageKey = "Identity.TwoFactorInvalid", + ResourceSource = typeof(IdentityResources), + }; } } @@ -116,7 +125,11 @@ public async Task StoreRefreshTokenAsync(string subject, string refreshToken, Da if (updated == 0) { - throw new UnauthorizedException("user not found"); + throw new UnauthorizedException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; } if (_logger.IsEnabled(LogLevel.Debug)) @@ -199,7 +212,11 @@ private async Task FindAndValidateUserByCredentialsAsync(string email, throw new CustomException( "Account is temporarily locked due to too many failed login attempts. Try again later.", errors: null, - HttpStatusCode.Locked); + HttpStatusCode.Locked) + { + MessageKey = "Identity.AccountLocked", + ResourceSource = typeof(IdentityResources), + }; } if (!await _userManager.CheckPasswordAsync(user, password)) @@ -243,7 +260,11 @@ private async Task FindUserByRefreshTokenAsync(string refreshToken, str if (user is null) { _logger.LogWarning("No user found with matching refresh token hash for tenant {TenantId}", tenantId); - throw new UnauthorizedException("refresh token is invalid or expired"); + throw new UnauthorizedException("refresh token is invalid or expired") + { + MessageKey = "Identity.RefreshTokenInvalidOrExpired", + ResourceSource = typeof(IdentityResources), + }; } return user; @@ -257,7 +278,11 @@ private void ValidateRefreshTokenExpiry(FshUser user) _logger.LogWarning( "Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}", user.Id, user.RefreshTokenExpiryTime, now); - throw new UnauthorizedException("refresh token is invalid or expired"); + throw new UnauthorizedException("refresh token is invalid or expired") + { + MessageKey = "Identity.RefreshTokenInvalidOrExpired", + ResourceSource = typeof(IdentityResources), + }; } } @@ -265,12 +290,20 @@ private static void ValidateUserStatus(FshUser user) { if (!user.IsActive) { - throw new UnauthorizedException("user is deactivated"); + throw new UnauthorizedException("user is deactivated") + { + MessageKey = "Identity.UserDeactivated", + ResourceSource = typeof(IdentityResources), + }; } if (!user.EmailConfirmed) { - throw new UnauthorizedException("email not confirmed"); + throw new UnauthorizedException("email not confirmed") + { + MessageKey = "Identity.EmailNotConfirmed", + ResourceSource = typeof(IdentityResources), + }; } } @@ -283,14 +316,24 @@ private void ValidateTenantStatus(AppTenantInfo tenant) if (!tenant.IsActive) { - throw new UnauthorizedException($"tenant {tenant.Id} is deactivated"); + throw new UnauthorizedException($"tenant {tenant.Id} is deactivated") + { + MessageKey = "Identity.TenantDeactivated", + MessageArgs = [tenant.Id], + ResourceSource = typeof(IdentityResources), + }; } // Honor the billing grace period: a lapsed tenant can still authenticate until // ValidUpto + grace (matching the request-time guard in MultitenancyModule). if (_timeProvider.GetUtcNow().UtcDateTime > tenant.ValidUpto.AddDays(_gracePeriodDays)) { - throw new UnauthorizedException($"tenant {tenant.Id} validity has expired"); + throw new UnauthorizedException($"tenant {tenant.Id} validity has expired") + { + MessageKey = "Identity.TenantValidityExpired", + MessageArgs = [tenant.Id], + ResourceSource = typeof(IdentityResources), + }; } } @@ -301,11 +344,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 +363,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..d5e45edafc 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -34,7 +35,11 @@ public async Task GetAsync(string userId, CancellationToken cancellatio .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; return new UserDto { @@ -48,6 +53,7 @@ public async Task GetAsync(string userId, CancellationToken cancellatio EmailConfirmed = user.EmailConfirmed, PhoneNumber = user.PhoneNumber, TwoFactorEnabled = user.TwoFactorEnabled, + Locale = user.Locale, }; } @@ -75,11 +81,15 @@ 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); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; Uri imageUri = user.ImageUrl ?? null!; // image is optional: text-only edits forward a null FileUploadRequest, so guard before @@ -101,6 +111,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) { @@ -112,7 +129,11 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, if (!result.Succeeded) { - throw new CustomException("Update profile failed"); + throw new CustomException("Update profile failed") + { + MessageKey = "Identity.UpdateProfileFailed", + ResourceSource = typeof(IdentityResources), + }; } } @@ -120,7 +141,11 @@ public async Task SetImageUrlAsync(string userId, string? imageUrl, Cancellation { EnsureValidTenant(); var user = await userManager.FindByIdAsync(userId) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; user.ImageUrl = string.IsNullOrWhiteSpace(imageUrl) ? null @@ -129,7 +154,11 @@ public async Task SetImageUrlAsync(string userId, string? imageUrl, Cancellation var result = await userManager.UpdateAsync(user); if (!result.Succeeded) { - throw new CustomException("Update profile image failed"); + throw new CustomException("Update profile image failed") + { + MessageKey = "Identity.UpdateProfileImageFailed", + ResourceSource = typeof(IdentityResources), + }; } await signInManager.RefreshSignInAsync(user); @@ -157,7 +186,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } 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/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs b/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs index 2650be91f0..ac9698d198 100644 --- a/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs +++ b/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; namespace Auditing.Tests.Contracts; @@ -140,6 +141,45 @@ public void Classify_Should_ReturnInformation_For_DerivedOperationCanceledExcept result.ShouldBe(AuditSeverity.Information); } + // The localization work introduced LocalizedUnauthorizedAccessException specifically so that + // subclassing the BCL type — rather than swapping it for a CustomException — keeps this + // classifier mapping unauthorized access to Warning. That intent lived only in a code + // comment: changing the base type would silently reclassify every unauthorized access as + // Error and no test would have noticed. This is the test that notices. + [Fact] + public void Classify_Should_ReturnWarning_For_LocalizedUnauthorizedAccessException() + { + // Arrange + var exception = new LocalizedUnauthorizedAccessException("Authentication failed.") + { + MessageKey = "Error.AuthenticationFailed", + }; + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Warning); + } + + // The KeyNotFound counterpart lands on Error either way; pinned so the classification is + // stated rather than left to be derived from the switch's default arm. + [Fact] + public void Classify_Should_ReturnError_For_LocalizedKeyNotFoundException() + { + // Arrange + var exception = new LocalizedKeyNotFoundException("Not found.") + { + MessageKey = "Error.NotFound", + }; + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1032:Implement standard exception constructors", Justification = "Test-only exception class")] private sealed class CustomCanceledException : OperationCanceledException { diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs new file mode 100644 index 0000000000..c415d51350 --- /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-BR 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_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // SharedResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // SharedResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } +} diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs new file mode 100644 index 0000000000..c860d93d65 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs @@ -0,0 +1,51 @@ +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>(); + } + + // Catalogs are named for specific cultures (SharedResources.pt-BR.resx), matching the front-end. + // The consequence is deliberate and pinned here: only pt-BR is served Portuguese. A bare `pt` or + // an unsupported variant like pt-PT walks its parent chain, finds no catalog of its own and lands + // on the neutral (English) one, rather than being silently handed Brazilian strings. + [Theory] + [InlineData("pt-BR", "Não encontrado")] // specific pt-BR resolves directly + [InlineData("pt", "Not Found")] // bare pt has no catalog -> neutral English + [InlineData("pt-PT", "Not Found")] // unsupported variant -> neutral English, NOT pt-BR + [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/SharedResourcesLocalizerFactory.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..150d667725 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,27 @@ +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 +{ + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider(); + } + + public static IStringLocalizer Create() => + BuildProvider().GetRequiredService>(); + + // The GlobalExceptionHandler resolves module-catalog keys through IStringLocalizerFactory + // (via CustomException.ResourceSource); tests build a real factory bound to the same setup. + public static IStringLocalizerFactory CreateFactory() => + BuildProvider().GetRequiredService(); +} diff --git a/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs b/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs new file mode 100644 index 0000000000..4bfb0594ce --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs @@ -0,0 +1,138 @@ +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 + [InlineData(null, "pt", null, "en-US")] // bare pt is not a supported tag -> default + [InlineData(null, "pt-PT", null, "en-US")] // unsupported variant -> default, never pt-BR + 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); + } + + // Localization negotiates the UI culture ONLY. CurrentCulture must stay invariant no matter what + // the request asks for, so no endpoint's ToString()/Parse()/interpolation shifts per request. Runs + // the real RequestLocalizationMiddleware, because this property comes out of the interaction + // between DefaultRequestCulture and a null SupportedCultures, not out of our provider. + [Theory] + [InlineData("pt-BR", null, null, "pt-BR")] // claim + [InlineData(null, "pt-BR", null, "pt-BR")] // header + [InlineData(null, null, "pt-BR", "pt-BR")] // query override + [InlineData(null, null, null, "en-US")] // nothing set + public async Task Formatting_culture_stays_invariant_while_ui_culture_negotiates( + string? claim, string? header, string? query, string expectedUiCulture) + { + var options = BuildOptions(); + var context = BuildContext(claim, header, query); + + string? formattingCulture = null; + string? uiCulture = null; + var middleware = new RequestLocalizationMiddleware( + _ => + { + formattingCulture = CultureInfo.CurrentCulture.Name; + uiCulture = CultureInfo.CurrentUICulture.Name; + return Task.CompletedTask; + }, + options, + NullLoggerFactory.Instance); + + var previous = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); + try + { + CultureInfo.CurrentCulture = new CultureInfo("pt-BR"); + await middleware.Invoke(context); + } + finally + { + CultureInfo.CurrentCulture = previous.Item1; + CultureInfo.CurrentUICulture = previous.Item2; + } + + uiCulture.ShouldBe(expectedUiCulture); + formattingCulture.ShouldBe( + string.Empty, + "CurrentCulture must be the invariant culture; a negotiated formatting culture would shift " + + "number and date rendering for every endpoint in the request."); + } + + // 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); + } +} diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs new file mode 100644 index 0000000000..7027fd9116 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; +using FSH.Framework.Core.Exceptions; +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 the ProblemDetails body +// from the shared resx under the ambient UI culture. Covers both the framework branches (raw +// KeyNotFoundException/InvalidOperationException) and the CustomException branch, whose Title now +// comes from the status-mapped catalog key and whose Detail is resolved from MessageKey (falling +// back to the English Message when no key is set). +public sealed class GlobalExceptionHandlerLocalizationTests +{ + private static async Task<(string? Title, string? Detail)> HandleAsync(Exception exception, string culture) + { + var (title, detail, _) = await HandleWithCodeAsync(exception, culture); + return (title, detail); + } + + private static async Task<(string? Title, string? Detail, string? Code)> HandleWithCodeAsync(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(), + SharedResourcesLocalizerFactory.CreateFactory()); + await handler.TryHandleAsync(context, exception, CancellationToken.None); + + var json = Encoding.UTF8.GetString(body.ToArray()); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var title = root.TryGetProperty("title", out var t) ? t.GetString() : null; + var detail = root.TryGetProperty("detail", out var d) ? d.GetString() : null; + var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; + return (title, detail, code); + } + 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 HandleAsync(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 HandleAsync(new InvalidOperationException("boom"), culture); + title.ShouldBe(expected); + } + + // CustomException Title is now the status-mapped catalog key (404 -> Error.NotFound), localized. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task CustomException_title_is_localized_by_status(string culture, string expected) + { + var (title, _) = await HandleAsync(new NotFoundException("some entity was not found"), culture); + title.ShouldBe(expected); + } + + // Detail resolves from MessageKey under the request culture (using an existing Core key). + [Theory] + [InlineData("pt-BR", "Não autorizado")] + [InlineData("en-US", "Unauthorized")] + public async Task CustomException_detail_is_localized_from_key(string culture, string expected) + { + var exception = new UnauthorizedException("english fallback") { MessageKey = "Error.Unauthorized" }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + // No MessageKey: Detail falls back to the literal (English) Message regardless of culture (non-breaking). + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task CustomException_detail_falls_back_to_message_without_key(string culture) + { + var (_, detail) = await HandleAsync(new NotFoundException("Plain English detail."), culture); + detail.ShouldBe("Plain English detail."); + } + + // Parameterless UnauthorizedException carries Error.AuthenticationFailed, so generic auth failures + // localize their Detail without any call-site key (English fallback stays "Authentication failed."). + [Theory] + [InlineData("pt-BR", "Falha na autenticação.")] + [InlineData("en-US", "Authentication failed.")] + public async Task Parameterless_unauthorized_detail_is_localized(string culture, string expected) + { + var (_, detail) = await HandleAsync(new UnauthorizedException(), culture); + detail.ShouldBe(expected); + } + + // Unknown MessageKey: ResourceNotFound path falls back to the English Message, never leaks the raw key. + [Fact] + public async Task CustomException_detail_falls_back_when_key_missing() + { + var exception = new NotFoundException("English fallback detail.") { MessageKey = "Does.Not.Exist" }; + var (_, detail) = await HandleAsync(exception, "pt-BR"); + detail.ShouldBe("English fallback detail."); + } + + // BCL-subclass exceptions (kept as their base type so audit severity classification is unaffected) + // still localize their Detail from MessageKey through the shared handler path. + [Theory] + [InlineData("pt-BR", "Falha na autenticação.")] + [InlineData("en-US", "Authentication failed.")] + public async Task LocalizedUnauthorizedAccess_detail_is_localized(string culture, string expected) + { + var exception = new LocalizedUnauthorizedAccessException("english fallback") + { + MessageKey = "Error.AuthenticationFailed", + }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task LocalizedKeyNotFound_detail_is_localized(string culture, string expected) + { + var exception = new LocalizedKeyNotFoundException("english fallback") + { + MessageKey = "Error.NotFound", + }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + // No MessageKey on a localized subclass → Detail falls back to the literal (English) message. + [Fact] + public async Task LocalizedUnauthorizedAccess_without_key_falls_back_to_message() + { + var (_, detail) = await HandleAsync(new LocalizedUnauthorizedAccessException("Plain English."), "pt-BR"); + detail.ShouldBe("Plain English."); + } + + // Detail is prose under the request culture, so the MessageKey travels as a stable "code" extension: + // clients branch on the code instead of matching localized text. Same key in every culture. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task CustomException_surfaces_the_message_key_as_code(string culture) + { + var exception = new UnauthorizedException("english fallback") { MessageKey = "Error.Unauthorized" }; + var (_, _, code) = await HandleWithCodeAsync(exception, culture); + code.ShouldBe("Error.Unauthorized"); + } + + // A localized BCL subclass carries its key through the same path. + [Fact] + public async Task LocalizedKeyNotFound_surfaces_the_message_key_as_code() + { + var exception = new LocalizedKeyNotFoundException("english fallback") { MessageKey = "Error.NotFound" }; + var (_, _, code) = await HandleWithCodeAsync(exception, "pt-BR"); + code.ShouldBe("Error.NotFound"); + } + + // No key → no code property at all, rather than a null or an invented one. + [Fact] + public async Task Exception_without_key_omits_the_code() + { + var (_, _, code) = await HandleWithCodeAsync(new NotFoundException("Plain English detail."), "pt-BR"); + code.ShouldBeNull(); + } + + // An unknown key still travels as the code even though Detail fell back to English: the code is the + // contract, the resx lookup is presentation. + [Fact] + public async Task Unknown_key_still_surfaces_as_code() + { + var exception = new NotFoundException("English fallback detail.") { MessageKey = "Does.Not.Exist" }; + var (_, detail, code) = await HandleWithCodeAsync(exception, "pt-BR"); + detail.ShouldBe("English fallback detail."); + code.ShouldBe("Does.Not.Exist"); + } + + // A raw BCL exception (no ILocalizableMessage) keeps its message and gets no code. + [Fact] + public async Task Raw_key_not_found_has_no_code() + { + var (_, detail, code) = await HandleWithCodeAsync(new KeyNotFoundException("missing"), "pt-BR"); + detail.ShouldBe("missing"); + code.ShouldBeNull(); + } + + // A 409 must not be titled "an unexpected error occurred". Before Error.Conflict existed the + // status-to-key map fell through to Error.Unexpected — which RESOLVES, so the type-name fallback + // never fired and every conflict in the API reported a title contradicting its own status and its + // own detail. There are 41 Conflict throw sites across Billing and Catalog. + [Theory] + [InlineData("en-US", "Conflict")] + [InlineData("pt-BR", "Conflito")] + public async Task Conflict_is_titled_as_a_conflict_not_as_unexpected(string culture, string expected) + { + var exception = new CustomException("Brand name already taken.", [], HttpStatusCode.Conflict); + + var (title, _) = await HandleAsync(exception, culture); + + title.ShouldBe(expected); + } + + // A status with no title of its own keeps the pre-localization behaviour — the exception type + // name — instead of claiming the error was unexpected. Status-consistent beats confidently wrong. + [Theory] + [InlineData("en-US")] + [InlineData("pt-BR")] + public async Task Untranslated_status_falls_back_to_the_exception_type_name(string culture) + { + var exception = new CustomException("Mailbox is locked.", [], HttpStatusCode.Locked); + + var (title, _) = await HandleAsync(exception, culture); + + title.ShouldBe(nameof(CustomException)); + } +} diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs index 5355719f66..8d941ac71a 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,10 @@ 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(), + SharedResourcesLocalizerFactory.CreateFactory()); await handler.TryHandleAsync(context, exception, CancellationToken.None); return context; } diff --git a/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs b/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs new file mode 100644 index 0000000000..2145754fba --- /dev/null +++ b/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs @@ -0,0 +1,56 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Identity.Tests.Data; + +public class IdentityDbContextModelTests +{ + private static IdentityDbContext CreateContext() + { + var accessor = Substitute.For>(); + accessor.MultiTenantContext.Returns(new MultiTenantContext(new AppTenantInfo())); + + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=arch;Database=arch;Username=arch;Password=arch") + .Options; + + var settings = Options.Create(new DatabaseOptions + { + Provider = "postgresql", + ConnectionString = string.Empty, + MigrationsAssembly = "FSH.Starter.Migrations.PostgreSQL", + }); + + var environment = Substitute.For(); + environment.EnvironmentName.Returns("Production"); + + return new IdentityDbContext(accessor, options, settings, environment); + } + + // User.Locale holds a BCP-47 tag, which is short and bounded. Unbounded `text` + // invites arbitrary input at the storage layer for a value the write boundary + // already restricts to SupportedCultures.Tags. 10 covers the longest form the + // platform could offer (language-script-region, e.g. zh-Hant-TW). + [Fact] + public void User_Locale_Is_Bounded_To_A_Bcp47_Tag_Length() + { + using var context = CreateContext(); + + var locale = context.Model.FindEntityType(typeof(FshUser))?.FindProperty(nameof(FshUser.Locale)); + + locale.ShouldNotBeNull(); + locale!.GetMaxLength().ShouldBe( + 10, + "an unbounded locale column accepts arbitrary input for a value that is always a short BCP-47 tag"); + } +} diff --git a/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs new file mode 100644 index 0000000000..10381c4c85 --- /dev/null +++ b/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs @@ -0,0 +1,100 @@ +using System.Security.Claims; +using FSH.Framework.Core.Context; +using FSH.Framework.Shared.Constants; +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_locale_but_preserves_identity_claims_and_injects_actor() + { + // Arrange — an authenticated operator in the same tenant as the target. + var actorUserId = Guid.NewGuid(); + _currentUser.IsAuthenticated().Returns(true); + _currentUser.GetUserId().Returns(actorUserId); + _currentUser.GetTenant().Returns(TenantId); + _currentUser.Name.Returns("operator"); + _currentUser.GetUserClaims().Returns(new List()); + + // A realistic target claim set: the persisted `locale` must be dropped, but every other + // identity claim (name, role, subject, tenant) must survive into the impersonation token. + var targetClaims = new List + { + new(JwtRegisteredClaimNames.Jti, "orig-jti"), + new(JwtRegisteredClaimNames.Sub, TargetUserId), + new(ClaimConstants.Tenant, TenantId), + new(ClaimTypes.Name, "Target User"), + new(ClaimTypes.Role, "Admin"), + 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(); + var issued = issuedClaims.ToList(); + + // (a) locale is stripped — the operator keeps reading in their own language. + issued.ShouldNotContain(c => c.Type == "locale"); + + // (b) every NON-locale identity claim survives — a mutation that over-strips (e.g. drops Name, + // role, sub or tenant) must fail here. + issued.ShouldContain(c => c.Type == ClaimTypes.Name && c.Value == "Target User"); + issued.ShouldContain(c => c.Type == ClaimTypes.Role && c.Value == "Admin"); + issued.ShouldContain(c => c.Type == JwtRegisteredClaimNames.Sub && c.Value == TargetUserId); + issued.ShouldContain(c => c.Type == ClaimConstants.Tenant && c.Value == TenantId); + + // (c) RFC 8693 actor claims are injected so the token records who is acting. + issued.ShouldContain(c => c.Type == ClaimConstants.ActorSubject && c.Value == actorUserId.ToString()); + issued.ShouldContain(c => c.Type == ClaimConstants.ActorTenant && c.Value == TenantId); + + // (d) the jti is swapped: the target's original jti is gone and exactly one fresh, non-empty + // jti is present (so the persisted grant row and the JWT share a new identifier). + issued.ShouldNotContain(c => c.Type == JwtRegisteredClaimNames.Jti && c.Value == "orig-jti"); + var jti = issued.Single(c => c.Type == JwtRegisteredClaimNames.Jti); + jti.Value.ShouldNotBe("orig-jti"); + jti.Value.ShouldNotBeNullOrWhiteSpace(); + } +} 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/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/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; + } +} diff --git a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs index ee98206a87..f486c769c4 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs @@ -1,5 +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; @@ -7,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() @@ -40,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 @@ -58,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 @@ -76,18 +93,69 @@ 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(); 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)] + // Empty/whitespace is treated as "not provided": the .When(!IsNullOrWhiteSpace) guard skips the + // rule, so an empty locale is valid (the caller simply isn't changing it). + [InlineData("", true)] + // Case matters: SupportedCultures.Tags.Contains is ordinal, so a wrong-case tag is rejected — + // pins the ordinal comparison against an accidental case-insensitive refactor. + [InlineData("pt-br", false)] + [InlineData("PT-BR", false)] + [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); + } + + [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-BR 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 — pin the actual Portuguese text, not merely the absence of the English one. + // "does not contain the English sentence" is satisfied by a blank message, by a raw + // resource key leaking through, and by any wrong-but-non-English string, so it stayed + // green through exactly the failures it existed to catch. + var message = result.Errors.Single(e => e.PropertyName == "Email").ErrorMessage; + message.ShouldBe("'Email' é um endereço de email inválido."); + } } From 28c3aaa270c5b7b0741ae04e3803167d8cf43db5 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:59:22 -0300 Subject: [PATCH 2/2] fix(localization): resolve exception bodies under the negotiated request culture No exception message this PR localizes was actually translated at runtime. Every detail resolved from a MessageKey and every title mapped from a status code came back from the neutral resx, whatever the client asked for. UseExceptionHandler is registered above UseHeroLocalization, and RequestLocalizationMiddleware assigns CultureInfo.CurrentUICulture inside its own async frame. That assignment belongs to the ExecutionContext of that frame and is gone by the time an exception unwinds up to the handler, so every localizer there resolved under the culture of the host process -- the invariant one in a container with no LANG, hence the neutral resx. GlobalExceptionHandler now reads the culture from HttpContext.Features.Get(), which the middleware sets on the request itself and therefore survives the unwind. Reading the negotiated culture rather than re-reading Accept-Language keeps the whole provider chain, including the user locale claim. Only CurrentUICulture is touched: AddHeroLocalization pins CurrentCulture to invariant on purpose. The previous value is restored in a finally so no request culture leaks onto the thread. When no feature is present -- an exception escaping before localization runs -- the ambient culture stands and no Content-Language is claimed. Also restores Content-Language on the problem body. ExceptionHandlerMiddleware clears the response before re-executing, which drops the header the localization middleware had already written, leaving the culture of the prose undeclared. GlobalExceptionHandlerLocalizationTests could not catch this: they assign CurrentUICulture by hand and call the handler directly, never through a pipeline. ExceptionLocalizationPipelineTests build the real pipeline and pin the ambient culture to invariant, which is what a container gives the API -- without that pin a developer machine whose own culture is the tested one reports a false pass. Against the handler as it stood before this commit, five of those cases fail; the negotiation baseline and the no-localization case pass either way. --- .../Web/Exceptions/GlobalExceptionHandler.cs | 44 +++++ .../Web/ExceptionLocalizationPipelineTests.cs | 187 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs index 903f6704ed..e585536714 100644 --- a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -1,10 +1,12 @@ using System.Diagnostics; using System; +using System.Globalization; using System.Net; using FSH.Framework.Core.Exceptions; using FSH.Framework.Core.Localization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; @@ -73,11 +75,44 @@ private void ApplyLocalizedDetail(ProblemDetails problemDetails, ILocalizableMes } } + // UseExceptionHandler sits ABOVE UseHeroLocalization in the pipeline, and + // RequestLocalizationMiddleware assigns CultureInfo.CurrentUICulture inside its own async frame — + // an assignment that belongs to that frame's ExecutionContext and is already gone by the time an + // exception unwinds up to this handler. Every localizer below would therefore resolve under the + // culture of the host process (the invariant one in a container with no LANG), and answer from the + // neutral resx no matter what the client asked for. The negotiated culture survives on the request + // itself, so take it from there and restore the ambient one afterwards. + // + // CurrentUICulture only: AddHeroLocalization pins CurrentCulture to invariant on purpose, so that + // no request can shift numeric or date formatting anywhere in the pipeline. public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(httpContext); ArgumentNullException.ThrowIfNull(exception); + var requestUiCulture = httpContext.Features.Get()?.RequestCulture.UICulture; + + // No feature means the exception escaped before localization ran (CORS, security headers, + // forwarded headers). Nothing was negotiated, so the ambient culture is all there is. + if (requestUiCulture is null) + { + return await WriteProblemDetailsAsync(httpContext, exception, requestUiCulture: null, cancellationToken).ConfigureAwait(false); + } + + var previousUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = requestUiCulture; + try + { + return await WriteProblemDetailsAsync(httpContext, exception, requestUiCulture, cancellationToken).ConfigureAwait(false); + } + finally + { + CultureInfo.CurrentUICulture = previousUiCulture; + } + } + + private async ValueTask WriteProblemDetailsAsync(HttpContext httpContext, Exception exception, CultureInfo? requestUiCulture, CancellationToken cancellationToken) + { var problemDetails = new ProblemDetails { Instance = httpContext.Request.Path @@ -165,6 +200,15 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e httpContext.Response.StatusCode = statusCode; + // ExceptionHandlerMiddleware clears the response before re-executing, which drops the + // Content-Language RequestLocalizationMiddleware had already written. Put it back, so a client + // can tell which culture the prose in this body is in. The invariant culture has an empty name + // and is not a valid header value. + if (requestUiCulture is not null && requestUiCulture.Name.Length > 0) + { + httpContext.Response.Headers.ContentLanguage = requestUiCulture.Name; + } + // Surface trace and correlation IDs so clients/support can correlate errors to traces var traceId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier; problemDetails.Extensions["traceId"] = traceId; diff --git a/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs b/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs new file mode 100644 index 0000000000..6efe140041 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs @@ -0,0 +1,187 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Exceptions; +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; + +namespace Framework.Tests.Web; + +// Pipeline-level counterpart to GlobalExceptionHandlerLocalizationTests. Those tests assign +// CultureInfo.CurrentUICulture by hand and call the handler directly, so they never exercise the one +// thing production depends on: that the culture RequestLocalizationMiddleware negotiates is still +// visible to the handler, which UseExceptionHandler runs from ABOVE that middleware. It is not — the +// assignment lives in the middleware own async frame — so the handler has to read the negotiated +// culture off the request instead. +// +// The ambient culture is pinned to invariant for the duration of each case, which is what a container +// with no LANG gives the API. Without the pin the developer machine culture decides the outcome and a +// pt-BR machine reports a false pass. +public sealed class ExceptionLocalizationPipelineTests +{ + private sealed record Outcome(string? Title, string? Detail, string? Code, string? NegotiatedUiCulture, string? ContentLanguage); + + // The production order: UseExceptionHandler at the top of the pipeline, localization further in. + private static Task InvokeAsync(string acceptLanguage, Exception exception) => + RunAsync(app => app.UseHeroLocalization(), acceptLanguage, exception); + + // A pipeline with no localization at all, standing in for the middleware registered between + // UseExceptionHandler and UseHeroLocalization: nothing negotiates a culture, so no feature exists. + private static Task InvokeWithoutLocalizationAsync(string acceptLanguage, Exception exception) => + RunAsync(_ => { }, acceptLanguage, exception); + + private static async Task RunAsync(Action configure, string acceptLanguage, Exception exception) + { + var previousCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + try + { + var configuration = new ConfigurationBuilder().Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMetrics(); + + // ExceptionHandlerMiddlewareImpl is activated from DI and takes a DiagnosticListener; + // the real host gets it from WebApplicationBuilder, so a bare ServiceCollection must supply it. + services.AddSingleton(_ => new DiagnosticListener("Microsoft.AspNetCore")); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddHeroLocalization(configuration); + services.AddExceptionHandler(); + services.AddProblemDetails(); + + await using var provider = services.BuildServiceProvider(); + + var app = new ApplicationBuilder(provider); + app.UseExceptionHandler(); + configure(app); + app.Run(_ => throw exception); + var pipeline = app.Build(); + + using var scope = provider.CreateScope(); + var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + context.Request.Path = "/api/v1/test"; + context.Request.Headers.AcceptLanguage = acceptLanguage; + using var body = new MemoryStream(); + context.Response.Body = body; + + await pipeline(context); + + using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(body.ToArray())); + var root = doc.RootElement; + var contentLanguage = context.Response.Headers.ContentLanguage.ToString(); + return new Outcome( + root.TryGetProperty("title", out var t) ? t.GetString() : null, + root.TryGetProperty("detail", out var d) ? d.GetString() : null, + root.TryGetProperty("code", out var c) ? c.GetString() : null, + context.Features.Get()?.RequestCulture.UICulture.Name, + string.IsNullOrEmpty(contentLanguage) ? null : contentLanguage); + } + finally + { + CultureInfo.CurrentUICulture = previousCulture; + } + } + + // Baseline: the middleware DOES negotiate the requested UI culture. If this fails, the gap is in + // negotiation and the assertions below say nothing about the handler. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task Request_culture_is_negotiated_from_the_accept_language_header(string acceptLanguage) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.NegotiatedUiCulture.ShouldBe(acceptLanguage); + } + + // The production symptom: a localized exception answered in English despite Accept-Language: pt-BR. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task Detail_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.Code.ShouldBe("Error.NotFound"); + outcome.Detail.ShouldBe(expected); + } + + // Title comes from the status-mapped catalog key through the injected IStringLocalizer, a separate + // resolution path from Detail IStringLocalizerFactory. Both read the ambient culture, so both broke. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task Title_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var outcome = await InvokeAsync(acceptLanguage, new NotFoundException("English fallback")); + + outcome.Title.ShouldBe(expected); + } + + // A raw exception takes the 500 branch, whose Title and Detail come straight off the injected + // localizer with no MessageKey involved. + [Theory] + [InlineData("pt-BR", "Ocorreu um erro inesperado")] + [InlineData("en-US", "An unexpected error occurred")] + public async Task Unexpected_error_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var outcome = await InvokeAsync(acceptLanguage, new InvalidOperationException("boom")); + + outcome.Title.ShouldBe(expected); + } + + // ExceptionHandlerMiddleware clears the response before re-executing, dropping the Content-Language + // the localization middleware had written. The handler restores it so the body culture is declared. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task Content_language_declares_the_culture_the_body_was_written_in(string acceptLanguage) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.ContentLanguage.ShouldBe(acceptLanguage); + } + + // No localization in the pipeline: the handler must still answer, from the ambient culture, and + // must not claim a Content-Language it never negotiated. + [Fact] + public async Task Handler_still_answers_when_no_culture_was_negotiated() + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeWithoutLocalizationAsync("pt-BR", exception); + + outcome.NegotiatedUiCulture.ShouldBeNull(); + outcome.ContentLanguage.ShouldBeNull(); + outcome.Code.ShouldBe("Error.NotFound"); + outcome.Detail.ShouldBe("Not Found"); + outcome.Title.ShouldBe("Not Found"); + } + + // The ambient culture is restored on the way out, so the handler cannot leak the request culture + // onto whatever else runs on this thread afterwards. + [Fact] + public async Task Ambient_culture_is_restored_after_handling() + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + var before = CultureInfo.CurrentUICulture; + + await InvokeAsync("pt-BR", exception); + + CultureInfo.CurrentUICulture.ShouldBe(before); + } +}