feat(i18n): internationalization across the API and both React apps#1344
feat(i18n): internationalization across the API and both React apps#1344marcelo-maciel wants to merge 55 commits into
Conversation
Add SupportedCultures constant (Default/Tags/RequestMatch) in BuildingBlocks/Core/Localization and reject unsupported locale tags in UpdateUserCommandValidator; null/empty locale still passes.
Inject IStringLocalizer<SharedResources> and swap hardcoded ProblemDetails titles (Validation/Unauthorized/NotFound/BadRequest/Unexpected + the 500 Detail) for resx keys. Exception-supplied Detail messages stay raw. Docker-free handler-level tests exercise the localized 404 and 500 branches under pt-BR and en-US.
Inject IStringLocalizer<SharedResources> into UpdateUserCommandValidator and resolve the three custom WithMessage literals lazily (Func overload, so the lookup runs per validation under the request culture, not at construction). Add the keys to both resx catalogs. Built-in FluentValidation messages localize automatically via CurrentUICulture (FV ships a pt catalog) — no LanguageManager wiring needed. Adds a resx key-parity test (neutral vs .pt) and updates the Task 2 validator test to supply a localizer.
Add a Language section to the profile dropdown, mirroring the Theme section: one item per SUPPORTED locale with an active-locale check. Selecting a locale calls i18n.changeLanguage and persists it via a new updateMyProfile mutation (PUT /identity/profile). The locale travels through the mutate argument (frontend rule fullstackhero#9). Current name/phone are echoed to avoid the backend wiping FirstName/LastName on a locale-only save. onSuccess triggers a best-effort token refresh so the new locale JWT claim is minted.
Mirror the admin Task 10 switcher on the tenant dashboard: a Language
section in the profile dropdown that switches the UI locale in place,
persists it, and re-mints the JWT locale claim.
- topbar: LanguageMenuItem + Language section (preventDefault keeps the
menu open so the section label re-localizes visibly); onSelectLanguage
calls i18n.changeLanguage, persists via updateMyProfile (locale by
mutate arg), and refreshes the token best-effort onSuccess. Profile
query is not invalidated so a refetch cannot revert the switch.
- api/identity: UpdateProfileInput gains locale; the PUT body echoes it
alongside the profile-read name/phone so a locale-only save cannot wipe
FirstName/LastName (backend sets them unconditionally).
- test: switcher spec asserts PUT {locale: pt-BR} with names preserved,
in-place localization, and the token refresh firing.
…e, settings, search)
…/combobox primitives
…ze validation detail
Admin language switcher no longer risks wiping the user's name/phone when the
topbar profile query has not resolved (or failed): updateMyProfile now re-reads
the profile from the server and merges firstName/lastName/phoneNumber with the
new locale, mirroring the dashboard. onSelectLanguage sends only { locale }.
Localization request-provider removal is now by type
(CookieRequestCultureProvider) instead of a brittle index, so a framework
reshuffle of the default provider order can't drop the wrong one.
GlobalExceptionHandler's ValidationException Detail is now localized via a new
Error.Validation.Detail key (added to both resx catalogs, parity preserved).
…sion, accept-language)
The test hosts pull 10.0.8 transitively, which carries HIGH-severity advisories (GHSA-23rf-6693-g89p, GHSA-8q5v-6pqq-x66h, GHSA-cvvh-rhrc-wg4q, GHSA-g8r8-53c2-pm3f) and trips NuGetAudit under TreatWarningsAsErrors, breaking the build of every test project. 10.0.10 is the patched servicing release. Mirrors the existing Microsoft.OpenApi transitive pin.
Locale catalogs (en-US, pt-BR) and t() wiring for both React apps: list/dialog/auth components and per-namespace translation files.
SharedResources catalog (en/pt) baseline entries and the app-boundary messages on GenerateTokenEndpoint routed through IStringLocalizer.
CustomException gains MessageKey/MessageArgs/ResourceSource (init-only, back-compat); base.Message stays the English fallback for logs. The GlobalExceptionHandler resolves the localized Title (by status) and Detail (via IStringLocalizerFactory + ResourceSource) under the request culture, falling back to the English Message when the key is absent (ResourceNotFound) or malformed (FormatException) — so migration is incremental and non-breaking. Logs now source exception_detail/type from the raw exception, never the localized ProblemDetails, and the LogContext properties are disposed so they do not leak across the request. Adds the Error.Forbidden catalog key and the handler localization tests (title by status, detail from key, fallbacks).
PagedQueryValidator<T> now takes IStringLocalizer<SharedResources> and resolves its page/size/sort messages from the catalog (reusing Validation.PageNumberMinimum, adding Validation.PageSizeRange and Validation.SortMaxLength). The three consumers (GetAudits, GetTenants, SearchUsers) receive and forward the localizer via DI. Validator DI already provides the localizer through AddHeroLocalization. Test instantiations pass a real localizer built from the embedded catalog.
Documents the hybrid catalog (Core vs per-module), the exception localization pattern (English Message + MessageKey/MessageArgs/ResourceSource, resolved at the boundary, logs stay English), validator localization, key naming, and the required parity + code-to-resx guard tests. Indexed in AGENTS.md.
New FilesResources catalog (en/pt, 18 domain keys) for file-domain messages; cross-cutting throws (no current user, invalid tenant) route to Core SharedResources via Error.NoCurrentUser/Error.InvalidTenant. All 22 user-facing throws across the module carry MessageKey/MessageArgs/ ResourceSource with the English literal preserved as base.Message for logs. Adds FilesResources parity + manifest-resolution tests; NoWarn S2094 for the empty marker.
New WebhooksResources catalog (en/pt) for the subscription-not-found message and the create-subscription validation messages (URL/events/SSRF target). Throws and the validator carry MessageKey/ResourceSource with the English literal preserved for logs. Adds parity + manifest-resolution tests; NoWarn S2094 for the marker.
New CatalogResources catalog (en/pt, 16 keys) covering product/brand/category not-found and uniqueness/hierarchy rule messages, plus the stock-delta validator. Throws carry MessageKey/MessageArgs/ResourceSource with the English literal kept for logs. The re-wrapped dynamic domain message (AdjustStock) stays as-is. Adds parity + manifest-resolution tests; NoWarn S2094.
New BillingResources catalog (en/pt, 10 keys) for invoice/plan/top-up not-found and operator/tenant-scope rule messages; the recurring tenant-context guard routes to Core Error.TenantContextRequired. All 31 user-facing throws carry MessageKey/MessageArgs/ResourceSource with the English literal kept for logs. Adds parity + manifest-resolution tests; NoWarn S2094.
New MultitenancyResources catalog (en/pt, 28 keys): 15 domain exceptions (tenant lifecycle, provisioning, theme) and 13 validator messages (tenant create/renew/validity + theme palette/typography/layout). Nested theme validators receive the localizer from their parent. GetTenantsQueryValidator untouched. Adds parity + manifest-resolution tests; NoWarn S2094.
New AuditingResources catalog (en/pt, 5 keys): 2 cross-tenant Forbidden messages and 3 date-range/window validation messages. GetAuditsQueryValidator keeps its SharedResources localizer for pagination and gains an AuditingResources localizer for its own messages; sibling audit-query validators inject AuditingResources. Generic.Tests instantiations updated for the dual localizer. Adds parity + manifest-resolution tests; NoWarn S2094.
New NotificationsResources catalog (en/pt) with the notification-not-found message; the recurring no-current-user guard routes to Core Error.NoCurrentUser. Billing email bodies are left English with a TODO — recipient-locale propagation for background email handlers is deferred to a follow-up PR. NoWarn S2094 (marker) + S1135 (the deferred-email TODO).
New ChatResources catalog (en/pt, 10 keys) for channel/message/reaction domain messages and the send-message body-or-attachment validation; the recurring no-current-user guard routes to Core Error.NoCurrentUser. Repeated messages collapse to one key each. Adds parity + manifest-resolution tests; NoWarn S2094.
New IdentityResources catalog (en/pt, 61 keys) covering group/user/role/ impersonation/session/token/two-factor domain messages; cross-cutting tenant/current-user guards route to Core keys. 92 user-facing throws carry MessageKey/MessageArgs/ResourceSource with the English literal kept for logs; the shared EnsureNotSystemRole helper takes a message key per call site. Validators were already localized and are untouched. Adds parity + resolution tests; NoWarn S2094.
Move the three hardcoded example placeholders (create role name/description, create tenant name) to i18n keys in the roles/tenants namespaces (en-US + pt-BR).
… Unauthorized) The AdjustStock overflow message and the parameterless UnauthorizedException kept a status-mapped localized Title but leaked an English Detail. - AdjustProductStockCommandHandler: carry MessageKey/MessageArgs/ResourceSource when re-wrapping the domain InvalidOperationException, so the negative-stock detail resolves against CatalogResources under the request culture. - UnauthorizedException(): default MessageKey to Error.AuthenticationFailed in the Core ctor, localizing the detail for every generic 401 (15 call sites in Identity, plus any future use) with no call-site change. English fallback and logs stay "Authentication failed.". - New Core key Error.AuthenticationFailed and Catalog key Catalog.StockAdjustmentNegative in both en/pt catalogs (parity enforced). - Tests: parameterless-Unauthorized detail localization (Framework.Tests) and StockAdjustmentNegative arg formatting in en/pt (Catalog.Tests).
…bility validator Second-pass audit (scan of every CustomException throw missing a MessageKey) found user-facing details still leaking English past the localized Title. - Tickets: the whole module had no resx catalog (previously assumed to carry no user-facing strings). Add TicketsResources (en/pt, 8 keys) and carry MessageKey/MessageArgs/ResourceSource across the 5 domain state-machine 409s and the 12 handler 404/401s. Add Tickets.Tests (the module's first unit test project) covering catalog parity and arg formatting. - ForbiddenException(): default MessageKey to Error.ForbiddenAccess in the Core ctor, mirroring UnauthorizedException — every generic 403 now localizes. - QuotaMeteredStorageService: carry Storage.QuotaExceeded (Core) with the usage/limit args on the 507. - ChangeFileVisibilityCommandValidator: the last .WithMessage literal now resolves Files.VisibilityInvalid via IStringLocalizer<FilesResources>. - New keys in en/pt catalogs (parity enforced); English fallbacks and logs unchanged. Status/verb enum tokens interpolated into messages stay English (not localized) — documented limitation. Verified: build 0/0; Tickets 3, Architecture 51, Files 25, Catalog 70, Framework 148, Identity 331; Integration 733 pass/1 skip/0 fail (Docker/WSL).
Third-pass audit widened the net to interpolated .WithMessage($"...") forms the earlier literal-only scan missed. Four validator messages still shipped English. - GetImpersonationGrantsQueryValidator / StartImpersonationCommandValidator: inject IStringLocalizer<SharedResources>, resolve Validation.Impersonation* keys with the bound as arg. - UserImageValidator: inject the localizer (forwarded from its parent UpdateUserCommandValidator, which already had it) and resolve Validation.AllowedExtensions / Validation.MaxFileSize with the file rules. - New keys in en/pt SharedResources (parity enforced). Verified: build 0/0; Identity 331; Integration 733 pass/1 skip/0 fail (Docker/WSL).
… subclasses The login/session 401s (UnauthorizedAccessException) and the audit-by-id 404 (KeyNotFoundException) leaked English details. Their BCL type is load-bearing — Audit.DefaultSeverity maps UnauthorizedAccessException to Warning and the audit exceptionType filter keys off the type — so they can't become a CustomException. - ILocalizableMessage (Core): interface exposing MessageKey/MessageArgs/ ResourceSource. CustomException now implements it (props already matched). - LocalizedUnauthorizedAccessException / LocalizedKeyNotFoundException: subclass the BCL types (so `is` checks and severity classification still hold) while carrying a localizable key. - GlobalExceptionHandler: shared LocalizeDetail() helper; the CustomException, UnauthorizedAccessException and KeyNotFoundException branches all resolve a localized detail when the exception is ILocalizableMessage. - Swap the 5 throw sites (GenerateToken "Invalid credentials"; SessionService view/revoke-others x3; GetAuditById) to the localized subclasses with keys in IdentityResources / AuditingResources (en/pt, parity enforced). - Audit.ForException records the BCL base type for these localization wrappers so exceptionType audit queries stay stable (fixes the type-filter regression). - Tests: handler localizes/falls-back for both subclasses (Framework.Tests). Verified: build 0/0; Framework 153, Identity 331, Auditing 65; Integration 733 pass/1 skip/0 fail (Docker/WSL).
Rounds out the audit by localizing the remaining CustomException/NotFoundException guards that can surface through GlobalExceptionHandler in a request: - RoleService: RoleManager-not-resolved / role-store-not-configured (404 when Identity DI is misconfigured) -> Identity.RoleManagerNotResolved / Identity.RoleStoreNotConfigured. - CurrentUserService: double-initialization guard (500) -> Identity.InScopeInitializationOnly. - Jobs.Extensions: missing DB options / unsupported Hangfire provider -> Jobs.DatabaseOptionsNotFound / Jobs.UnsupportedStorageProvider. These fire at startup (no request culture), so the key renders only if ever hit in-request; added for catalogue consistency (every CustomException carries a key). - New keys in en/pt (IdentityResources, SharedResources), parity enforced. The two WebhookDeliveryFailedException messages are intentionally left English: they are Hangfire background-job log/retry text with no HTTP surface, and the i18n design keeps logs English. Verified: build 0/0; Framework 153, Identity 331.
|
Companion docs PR: fullstackhero/docs#238 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08c455b0b6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| throw new ForbiddenException("This tenant has been deactivated. Contact your administrator.") | ||
| { | ||
| MessageKey = "Multitenancy.TenantDeactivated", | ||
| ResourceSource = typeof(MultitenancyResources), |
There was a problem hiding this comment.
Preserve deactivation detection under localized errors
When a dashboard user has pt-BR active, apiFetch now sends that as Accept-Language, so this MessageKey makes the deactivated-tenant 403 detail become Portuguese. The dashboard global error hook still detects this terminal state by matching the English substring tenant has been deactivated in clients/dashboard/src/lib/api-client.ts, so localized users stay on failing screens instead of being redirected to /tenant-deactivated; please add a stable error code/status discriminator or update the detector to avoid localized prose.
Useful? React with 👍 / 👎.
| const onSelectLanguage = (tag: string) => { | ||
| void i18n.changeLanguage(tag); | ||
| updateProfile.mutate({ locale: tag }); |
There was a problem hiding this comment.
Do not persist operator locale onto impersonated users
In an impersonation session, /api/v1/identity/profile is scoped to the impersonated subject because the endpoint forces the id from the JWT, while StartImpersonation explicitly strips the target locale so the operator keeps their own language. This language switch still calls updateMyProfile, which writes the selected locale to the target user's profile, and the profile hydration above also reads the target locale; guard this path during impersonation or keep the operator's language purely local.
Useful? React with 👍 / 👎.
Implements internationalization (i18n) with multi-language support across the backend API and both React front-ends, following up on the discussion in #1301 (branched off v10 GA as suggested).
Summary
The starter kit shipped with no localization: no
IStringLocalizer, no resource catalogs, noAccept-Languagehandling, English hardcoded throughout, and no locale onUser. This PR adds a complete, opt-in i18n foundation and ships English (en-US) plus Brazilian Portuguese (pt-BR) as a reference pair. Installations that change nothing keep behaving exactly as before (English), so the change is backward compatible.Backend
localeclaim,Accept-Languageheader, a configurableDefaultCulture, then invariant.SharedResourcescatalog plus one{Module}Resourcescatalog per bounded context, resolved throughIStringLocalizer<T>withResourcesPath = ""(co-located marker type and resx).ProblemDetailsand FluentValidation messages localize under the request culture. Exception bodies localize via aMessageKey/MessageArgs/ResourceSourcemechanism onCustomException; the exceptionMessagestays English for logs and as the fallback when a key is missing or fails to format. Logs stay English regardless of the negotiated culture.UnauthorizedAccessExceptionandKeyNotFoundException) localize through smallILocalizableMessagesubclasses that preserve their base type.User.Localecolumn (defaulten-US) with a user-to-default fallback chain. The migration is additive and reversible; existing null rows resolve toen-UStransparently.Frontend (admin and dashboard)
react-i18nextwith a language switcher, zero-cookie (localStorage) persistence, and enforceden-US/pt-BRkey parity.PUT /profilewithlocale) so the backend honors it through the JWT claim on the next token, without dropping other profile fields.Testing
Notes
en-USandpt-BRare held at strict key parity, enforced by tests, so a missing translation fails the build instead of silently shipping English.