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 60 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
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 👍 / 👎.
There was a problem hiding this comment.
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.
| 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 👍 / 👎.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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") |
There was a problem hiding this comment.
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.
|
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.
|
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.
|
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.
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.