Skip to content

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

Open
marcelo-maciel wants to merge 60 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 60 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.
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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c87be3b. Right call — localizing the detail silently broke every client that pattern-matched the prose, and this detector was one.

Rather than teaching the detector about localized strings, GlobalExceptionHandler now surfaces the exception's MessageKey as a code ProblemDetails extension, so there is a stable discriminator: { "detail": "<localized>", "code": "Multitenancy.TenantDeactivated" }. Additive — exceptions without a key omit the property and no existing field changes. isTenantDeactivatedError matches the code.

New E2E spec clients/dashboard/tests/system/tenant-deactivated.spec.ts covers the coded 403, the localized 403 (the actual regression — it fails against the old prose matcher) and an unrelated 403 that must not hijack the app. Docs updated in fullstackhero/docs#238.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 2cd6f33. Confirmed against the source: StartImpersonationCommandHandler strips the target's locale claim on purpose so the operator keeps reading in their own language, and the topbar was writing straight through that intent — persisting the operator's choice onto the impersonated user's profile and hydrating the operator's shell from the target's locale.

During an impersonation session (useAuth().impersonation) the switch is now client-side only: no PUT, no token re-mint, no hydration from the target's profile. Regression test in clients/dashboard/tests/i18n/switcher.spec.ts; the impersonation session seed moved to tests/helpers/auth-seed.ts so both specs share it. Verified by mutation — dropping the guard fails the test.

The dashboard topbar persisted every language switch to /identity/profile,
which is scoped to the impersonated subject: an operator switching language
mid-impersonation wrote their own locale onto the target user's profile, and
the profile hydration pulled the target's locale into the operator's shell.
StartImpersonation deliberately strips the target's `locale` claim so the
operator keeps reading in their own language; the client now honours that.

During an impersonation session the switch stays client-side only — no PUT,
no token re-mint, no hydration from the target's profile.

Adds an E2E regression test and lifts the impersonation session seed into
tests/helpers/auth-seed.ts so both specs share it.

@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: 2cd6f33931

ℹ️ 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".

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

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 Carry the operator locale through impersonation

When an admin operator has selected pt-BR in the admin app and opens a cross-app impersonation tab, this removes the target's locale claim but the admin handoff URL still only carries token/tenant/expiresAt, and the dashboard bootstrap only installs token/tenant. Because the dashboard normally runs on a separate dashboardUrl origin, it cannot read the admin app's i18nextLng; the API culture then falls through to the dashboard/browser/default locale, so localized dashboard API errors can switch away from the operator's chosen language during impersonation. Carry the actor locale in the handoff or token rather than only dropping the target locale. .agents/rules/localization.mdL3-L7

Useful? React with 👍 / 👎.

…ute handler

The Accept-Language specs captured the header into a variable from inside the
route handler and asserted it right after waitForRequest resolved. That resolves
on dispatch, before the handler runs, so the assertion could read null — it flaked
on CI. Read the header off the awaited Request instead, mirroring the pattern
already documented in switcher.spec.ts.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Localizing the ProblemDetails Detail broke every client that branched on the
error prose. The dashboard's deactivated-tenant guard was one: it matched the
English substring "tenant has been deactivated", so a pt-BR user whose tenant
was switched off got a localized 403, failed the match, and stayed stuck on
half-loaded screens instead of landing on /tenant-deactivated.

GlobalExceptionHandler now emits the exception's MessageKey as a `code`
extension alongside the localized Detail — a culture-independent discriminator
clients can branch on. Additive: exceptions without a key omit the property, and
no existing field changes. Covers the CustomException branch and both
ILocalizableMessage BCL subclasses.

isTenantDeactivatedError matches the code instead of the prose. Adds an E2E spec
covering the coded 403, the localized 403 (the actual regression) and an
unrelated 403 that must not hijack the app.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Both apps' tsconfig only covered `src` and `vite.config.ts`, so everything under
`tests/` was outside the typechecker: `npm run build` (tsc -b) and `eslint .`
both passed on a spec that referenced an undefined identifier, and the mistake
only surfaced when Playwright ran. On CI that means the Lint & Build job goes
green and the E2E job pays for the error minutes later.

Adds a `tsconfig.tests.json` per app (tests + playwright.config.ts, same strict
options as the app project) and references it from the solution tsconfig, so
`tsc -b` — already what `npm run build` runs — covers the suites. No workflow
change needed.

Fixes the four pre-existing errors this surfaced:
- switcher.spec.ts: a nullable local assigned only inside a route handler is
  narrowed to `null` at the assertion site, making every property read an
  error. Accumulate into an array instead.
- wallet.spec.ts: the mock echoes back a posted note that can be absent, so the
  fixture's `note` is `string | null`, not `string`.
- format.spec.ts (admin): the dynamic import inside page.evaluate is a Vite dev
  server URL resolved in the browser, not a module tsc can find on disk. Keep
  the specifier in a variable.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Both Playwright configs raise actionTimeout to 10s and navigationTimeout to 15s
but leave `expect` at its 5s default, so the assertion every test ends on had the
tightest deadline in the suite. Under CPU contention the first paint of a lazy
route lands past 5s while staying well inside the budgets the config already
chose, and the run fails on toBeVisible.

Measured on the dashboard suite at 16 workers, same machine, three runs:

  no expect timeout   13 failures, mostly `Timeout: 5000ms` on toBeVisible
  expect 10s           5 failures, none of them an assertion
  reverted            17 failures, 14 of them assertions

CI runs 2 workers with 2 retries, which is why this stayed hidden there: the
retry absorbs it and the job still reports green.

What remains under deliberate oversubscription is action and navigation
timeouts, i.e. a saturated machine rather than a budget mismatch. Not chased
further — inflating those would hide real regressions.
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