Skip to content

feat(i18n): internationalization across the API and both React apps#1344

Open
marcelo-maciel wants to merge 55 commits into
fullstackhero:mainfrom
marcelo-maciel:feat/i18n
Open

feat(i18n): internationalization across the API and both React apps#1344
marcelo-maciel wants to merge 55 commits into
fullstackhero:mainfrom
marcelo-maciel:feat/i18n

Conversation

@marcelo-maciel

Copy link
Copy Markdown
Contributor

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, no Accept-Language handling, English hardcoded throughout, and no locale on User. 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

  • Request culture resolution chain, highest to lowest priority: explicit query/cookie, JWT locale claim, Accept-Language header, a configurable DefaultCulture, then invariant.
  • A neutral SharedResources catalog plus one {Module}Resources catalog per bounded context, resolved through IStringLocalizer<T> with ResourcesPath = "" (co-located marker type and resx).
  • ProblemDetails and FluentValidation messages localize under the request culture. Exception bodies localize via a MessageKey / MessageArgs / ResourceSource mechanism on CustomException; the exception Message stays English for logs and as the fallback when a key is missing or fails to format. Logs stay English regardless of the negotiated culture.
  • BCL exceptions whose runtime type is load-bearing (audit severity classification keys off UnauthorizedAccessException and KeyNotFoundException) localize through small ILocalizableMessage subclasses that preserve their base type.
  • A new nullable User.Locale column (default en-US) with a user-to-default fallback chain. The migration is additive and reversible; existing null rows resolve to en-US transparently.

Frontend (admin and dashboard)

  • react-i18next with a language switcher, zero-cookie (localStorage) persistence, and enforced en-US / pt-BR key parity.
  • Locale-aware number, currency and date formatting.
  • The switcher persists the choice to the user profile (PUT /profile with locale) so the backend honors it through the JWT claim on the next token, without dropping other profile fields.

Testing

  • Backend unit and architecture tests are green; integration tests (Testcontainers/Postgres) pass at 733 passed / 1 skipped / 0 failed.
  • Frontend Playwright suites for both apps cover the switcher, key parity, formatting, and the app shell.

Notes

  • en-US and pt-BR are held at strict key parity, enforced by tests, so a missing translation fails the build instead of silently shipping English.
  • Documentation and a changelog entry are prepared as a companion PR against the docs site.

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.
…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).
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.
@marcelo-maciel

Copy link
Copy Markdown
Contributor Author

Companion docs PR: fullstackhero/docs#238

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +176 to +179
throw new ForbiddenException("This tenant has been deactivated. Contact your administrator.")
{
MessageKey = "Multitenancy.TenantDeactivated",
ResourceSource = typeof(MultitenancyResources),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +230 to +232
const onSelectLanguage = (tag: string) => {
void i18n.changeLanguage(tag);
updateProfile.mutate({ locale: tag });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant