Repository navigation
feat(i18n): internationalization across the API and both React apps - #1344
marcelo-maciel wants to merge 78 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 #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
index.html ships a static `lang="en"` in both apps and nothing ever updated it, so a Portuguese UI kept declaring itself English to screen readers, browser translation offers and hyphenation. The whole point of the feature is that the page is in the reader's language; the attribute that tells assistive tech so was left behind. An i18next `languageChanged` listener registered before init, so it covers the initial detected language as well as every switch. Also the only language signal in these apps that a stale render cannot satisfy, which makes it the right assertion target for locale specs.
… language Audit finding (concurrency lens), verified at the source. `updateMyProfile` is a GET-then-PUT with no concurrency token, and Settings > Profile invalidates the SAME ["identity","me"] key the topbar reads. So a Settings save whose read preceded the language PUT echoes the pre-switch locale back and wins if it lands second. The topbar's hydration effect then saw `persistedLocale` change and called `changeLanguage` on it — the user watched the UI revert with no error and nothing to act on. Admin has the same shape via two rapid switches landing out of network order. Hydration now stops once the user picks a language in this session. A locale chosen on another device still carries over, because that is a fresh mount with no in-session choice — pinned by hydration-guard.spec.ts. This does NOT fix the lost update itself; the server can still end up holding the old locale, which is what the `ponytail:` notes in both topbars record. The damage is bounded from "the app changed language while I was using it" to "my language did not stick across a reload". The real fix is an ETag / RowVersion with If-Match on PUT /identity/profile — a contract change to an existing endpoint, its own PR. Not pinned by a regression test: reproducing it needs an in-mount profile refetch driven through the Settings form, and the click races the language-change re-render (element detached from the DOM). Three distinct attempts, then stopped rather than paper over it with retries. Recorded as unpinned rather than presented as verified.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Both verified at the source before changing anything.
UpdateUserCommandValidatorTests: the built-in-message test asserted only
ShouldNotContain("is not a valid email address"). A blank message, a raw resource key
leaking through, or any wrong-but-non-English string all satisfied that — the exact
failures it existed to catch. Now pins the actual Portuguese text, matching the
sibling test one method up. The literal came from the runtime, not from guessing.
ExceptionSeverityClassifierTests: LocalizedUnauthorizedAccessException subclasses the
BCL type precisely so the audit severity classifier keeps mapping unauthorized access
to Warning, and that intent existed only as a code comment. Changing the base type
would have silently reclassified every unauthorized access as Error with the whole
suite green. Added for both Localized subclasses.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Audit finding (data-contract lens), verified at the source. TitleKeyFor mapped four statuses and sent everything else to "Error.Unexpected". The type-name fallback next to it looked like it covered the rest, but it only fires on ResourceNotFound — and Error.Unexpected resolves, so it never fired. Every status outside those four reported a title contradicting its own status code and its own detail. The 41 Conflict throw sites across Billing and Catalog answered Status 409 with "An unexpected error occurred" and a detail describing an ordinary business-rule conflict. Before this branch, Title was `e.GetType().Name` — imperfect, but at least status-consistent. This regressed that. TitleKeyFor now returns null for a status with no title of its own, which routes back to the exception type name, and Conflict gets a real localized title (Error.Conflict, added to both shared catalogs). Any RFC7807-aware client branching on `title` for a conflict sees a coherent value again. Pinned in both directions: Conflict renders "Conflict"/"Conflito", and an untranslated status (Locked) falls back to the type name rather than claiming the error was unexpected.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Both parity specs iterate a hand-maintained namespace array. A namespace file added without a matching entry escaped every parity assertion in the file — it could ship half-translated with the suite green, which is precisely the failure these specs exist to prevent. The backend closed the equivalent gap generically via reflection; these two did not. Verified by mutation: dropping a new JSON catalog into src/locales/en-US fails the check naming the uncovered namespace.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Thanks — and the split is the right call. I'm not arguing with it, but I've closed the review on this branch first, and I want to explain the order rather than just do it. Three of these don't respect the split boundary. The impersonation locale fix spans both apps and only makes sense as one change. The Everything below was verified at the source, and every fix has a mutation gate: I revert the fix, require the pinning test to go red, and restore the file byte-exact. Where something isn't pinned I say so. Golden Rule #4. You're right and the omission was mine. The description now lists all eighteen
It wasn't a single switch, and the reason is worth recording:
Argument formatting. Since the localizer formats with The
JWT-carried locale. Documented as known behaviour, in the description and in the rules file, together with the impersonation and SignalR caveats. Docs. Will land with the framework PR. Changed after your review, and not reviewed by anyone.
Not pinned, stated plainly: the hydration guard has no regression test. Reproducing it needs an in-mount profile refetch through the Settings form and the click races the language-change re-render. Three distinct approaches, then I stopped rather than reach for retries or a longer timeout. CI is red on |
…ssue Both topbars carry a ponytail note explaining that the persisted locale can still be lost server-side, since PUT /identity/profile is a full-representation update with no concurrency token. The real fix is now filed upstream, so the notes point at it instead of only describing it. Comment-only change; no behaviour touched.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
SSH.NET 2025.1.0 is pulled transitively by the Testcontainers packages and fails NuGet audit (NU1903, GHSA-q939-rpr3-3284 / CVE-2026-48798), which breaks restore for the whole solution under TreatWarningsAsErrors. The advisory is not introduced by this branch -- restore fails identically on main -- and the fix properly belongs to #1333, which is still open. The file is taken byte-for-byte from #1333, comment included, so both pull requests stay mergeable in either order: git merge-tree on both orderings produces a clean tree with a single SSH.NET entry, and the merged file is identical to what #1333 alone produces. The byte identity is what buys that, so this copy should track #1333 rather than diverge, and can be dropped once #1333 lands first. Verified with the audit left ENABLED: dotnet restore on the solution exits 0, and the full backend suite is 1891 passed / 0 failed / 1 skipped across 15 assemblies, with zero NU1903 occurrences in the log.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Two updates since the reply above, both folded into the description. The Verified with the audit left enabled, not suppressed: Two audit findings are documented rather than quietly carried.
On the split: everything from the review is settled on this branch now, so the three slices can be cut from a tree that is already correct. Say the word and the framework PR goes up first, as you asked. |
|
Split done. Closing this in favour of the four PRs below; the discussion here stays linked from each of them, and every answer in this description travels to the slice it belongs to.
Four rather than three. Your three were framework / The split is asserted, not eyeballed. A script checks that the union of the four slices equals Each slice was verified on its own, not as a share of this branch's totals. Two boundary decisions worth flagging before you read the diffs:
The module PR is red on CI and that is expected: it does not compile without the framework slice ( Docs stay in fullstackhero/docs#238 as a single PR covering all four slices — |
…ocale (1/4) (#1381) * 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. * 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<IRequestCultureFeature>(), 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. * build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. * fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. * fix(identity): treat a blank profile locale as absent, not as a value UpdateUserCommandValidator guards its allow-list rule with .When(!IsNullOrWhiteSpace), so "" never reaches the allow-list. The service disagreed: `if (locale is not null)` wrote the empty string straight onto the user, so a body carrying locale: "" silently wiped a language the user had chosen. A form that serialises an untouched locale field as "" clears the preference on every unrelated save, and the allow-list never sees the value that got stored. Aligns the service with the validator's reading. The integration-test UserDto mirror gains Locale, which is why no existing test caught this. Gates: the new integration test fails on the previous code (Shouldly: dto.Locale) and passes after; UserProfileTests 8/8, Identity.Tests 330/330. * fix(migrations): order AddUserLocale after the migrations already on main The migration was generated as 20260720062947, which sorts *before* 20260807063015_DropIdentityOutbox that has since landed on main. The history was therefore inconsistent with itself: DropIdentityOutbox's designer snapshot, the later of the two by name, knows nothing about the Locale column the earlier one adds, so anything generated on top of that snapshot would try to add it again. Regenerated against main's snapshot rather than hand-edited: the two files were deleted, the snapshot restored from origin/main, and `dotnet ef migrations add AddUserLocale` re-run. The Up/Down bodies are unchanged (AddColumn Locale, varchar(10), nullable) and the resulting model snapshot is byte-identical to the one already committed (`git diff HEAD` empty), so this is purely a reordering. * test(framework): gate placeholder parity between the resx catalogs Matching key sets were the only gate, and they do not catch a translation that drops {0} or renumbers it: the argument is either swallowed or the message throws FormatException at the point it is built, and neither shows up as a missing key. The new assertion compares the placeholder index set per key, ignoring alignment and format specifier ({0,-10}, {0:N2} are the same argument). Verified by mutation: collapsing "{0}/{1} bytes" to "{0} bytes" in the pt-BR catalog turns it red. * refactor(framework): freeze the supported-culture whitelist SupportedCultures.Tags was a public static string[], so the whitelist that a validator, a culture provider and the request-localization setup all trust was writable by any caller holding a reference. FrozenSet with an ordinal comparer keeps the exact matching semantics (a wrong-case tag is still rejected) and makes the set immutable. * feat(identity): localize the 401 challenge body The 401 that JwtBearer's OnChallenge writes is the one error response the global exception handler never sees, so it stayed English while every other error was negotiated: a pt-BR reader got "Authentication is required to access this resource." in the middle of an otherwise translated app. It resolves IStringLocalizer<SharedResources> per request. That works because of the pipeline order this PR already relies on: UseRequestLocalization sits ahead of UseAuthorization, which is where the challenge is emitted, so the negotiated UI culture is in place by then. Error.AuthenticationRequired is new; the title reuses Error.Unauthorized. ChallengeLocalizationTests pins both the body and that ordering, hitting a protected endpoint with and without Accept-Language. * fix(infra): pull minio/mc from quay.io too, not just minio/minio The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers 404), and it is what `minio-init` runs: without it `dotnet run --project src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull, and the `fsh` bucket is never created, so the first upload fails with NoSuchBucket. Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands. * build(deps): drop the dead SSH.NET pin The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping Testcontainers does not help", but the branch also bumps Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two statements cannot both be true, and the bump is the one that is: with the pin removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903 and exits 0. It was carrying a transitive pin that no longer pins anything. The MessagePack pin above it stays: that one is still load-bearing (removing it brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe). * fix(api): let the container create the cultures request localization negotiates The chiseled runtime image sets DOTNET_SYSTEM_GLOBALIZATION_INVARIANT and ships no ICU, so new CultureInfo("pt-BR") threw CultureNotFoundException and the API never started in a container. PredefinedCulturesOnly=false allows named cultures there; resource lookup only needs the name, and formatting is pinned to InvariantCulture anyway. * feat(localization): localize the user-facing errors main added since the split The stale-profile 412 from #1387 gets Identity.ProfileChangedSinceLoaded and a status title (Error.PreconditionFailed) instead of 'CustomException'. The front-end-origin 400 from #1377 gets Frontend.OriginNotAllowed, with no arguments so the rejected Origin is never echoed. The idempotency in-progress 409 from #1378 is built by the filter, not thrown, so it resolves its own title and detail and carries the same code extension. Adds Identity.EmailConfirmed for the confirm-email success text the module slice now localizes. * docs(rules): name the admin language key the app actually uses * test(identity): drop a null-forgiving operator ShouldNotBeNull already covers SonarAnalyzer 10.34 (#1396) reports it as S8969, which fails the build under TreatWarningsAsErrors. * test(localization): allow a missing pt-BR entry, fail only on orphans and placeholder drift A key the pt-BR catalog lacks falls back to English at runtime, so requiring full key parity forced every contributor who adds text to write Portuguese. The parity test now fails only on a pt-BR key the neutral catalog does not have (left behind by a rename or delete) and keeps the placeholder check. A test-only probe catalog pins the runtime half of that contract: under the production AddHeroLocalization wiring, a key missing from the pt-BR resx resolves to its English value instead of leaking the raw key. --------- Co-authored-by: iammukeshm <iammukeshm@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* 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. * feat(localization): localize module messages and validators Module slice of the i18n work (split of #1344 as requested in review). Depends on the framework slice: it uses `SharedResources`, `SupportedCultures` and the `MessageKey` / `MessageArgs` / `ResourceSource` plumbing on `CustomException`, none of which exist on `main` yet. - One `{Module}Resources` catalog per module (en + pt-BR) for Auditing, Billing, Catalog, Chat, Files, Multitenancy, Notifications, Tickets and Webhooks, plus the Identity messages that were not part of the framework slice. - Domain and handler throws carry `MessageKey` and `ResourceSource` so the response body is localized while `Message` stays English for the logs. - Validators resolve their messages through `IStringLocalizer`, with message arguments kept culture-insensitive (`MaxWindowDays` as `int` rather than `MaxWindow.TotalDays` as `double`, which `string.Format` would render with a culture-dependent decimal separator). - `CatalogParityTests` pins key parity and placeholder parity across every catalog, so a missing or malformed translation fails the build rather than reaching a user. - `Tickets.Tests` joins the solution, which previously had no test project. Protected code (Golden Rule #4): this slice touches one file under `src/BuildingBlocks` — `Web/Validation/PagedQueryValidator.cs`. Its constructor now takes `IStringLocalizer<SharedResources>` so paging messages localize. All three subclasses live in modules (`GetAuditsQueryValidator`, `GetTenantsQueryValidator`, `SearchUsersQueryValidator`), so the base class travels with its callers rather than breaking them from another PR. Every other `BuildingBlocks` change is in the framework slice. The `SSH.NET` pin (`2026.0.0`) is byte-identical to #1333, so `dotnet restore` passes while that PR is open. * 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<IRequestCultureFeature>(), 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. * build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. * build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. * fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. * fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. * fix(identity): treat a blank profile locale as absent, not as a value UpdateUserCommandValidator guards its allow-list rule with .When(!IsNullOrWhiteSpace), so "" never reaches the allow-list. The service disagreed: `if (locale is not null)` wrote the empty string straight onto the user, so a body carrying locale: "" silently wiped a language the user had chosen. A form that serialises an untouched locale field as "" clears the preference on every unrelated save, and the allow-list never sees the value that got stored. Aligns the service with the validator's reading. The integration-test UserDto mirror gains Locale, which is why no existing test caught this. Gates: the new integration test fails on the previous code (Shouldly: dto.Locale) and passes after; UserProfileTests 8/8, Identity.Tests 330/330. * test(identity): pin the UI culture so validator assertions stop depending on the machine The localized validator messages resolve against CurrentUICulture, so the eleven tests that assert the exact English string pass on an English machine and fail on any other. On a pt-BR workstation the suite reports 11 failures for code that is correct; CI, being en-US, never sees it. Pinning the culture once for the assembly keeps the assertions as they read and makes the result a property of the code rather than of the developer's OS. * test(localization): pin the UI culture in the three remaining assemblies The earlier fix landed in Identity.Tests only, and the same defect was still in Catalog.Tests, Generic.Tests and Webhooks.Tests: a real IStringLocalizer resolves the message against CurrentUICulture while the assertion spells out the English string, so nine tests fail on a pt-BR workstation and pass on an en-US runner. Catalog 70/70, Generic 43/43, Webhooks 71/71 on a pt-BR machine, where they were 69/1, 37/6 and 69/2. * fix(migrations): order AddUserLocale after the migrations already on main The migration was generated as 20260720062947, which sorts *before* 20260807063015_DropIdentityOutbox that has since landed on main. The history was therefore inconsistent with itself: DropIdentityOutbox's designer snapshot, the later of the two by name, knows nothing about the Locale column the earlier one adds, so anything generated on top of that snapshot would try to add it again. Regenerated against main's snapshot rather than hand-edited: the two files were deleted, the snapshot restored from origin/main, and `dotnet ef migrations add AddUserLocale` re-run. The Up/Down bodies are unchanged (AddColumn Locale, varchar(10), nullable) and the resulting model snapshot is byte-identical to the one already committed (`git diff HEAD` empty), so this is purely a reordering. * test(framework): gate placeholder parity between the resx catalogs Matching key sets were the only gate, and they do not catch a translation that drops {0} or renumbers it: the argument is either swallowed or the message throws FormatException at the point it is built, and neither shows up as a missing key. The new assertion compares the placeholder index set per key, ignoring alignment and format specifier ({0,-10}, {0:N2} are the same argument). Verified by mutation: collapsing "{0}/{1} bytes" to "{0} bytes" in the pt-BR catalog turns it red. * refactor(framework): freeze the supported-culture whitelist SupportedCultures.Tags was a public static string[], so the whitelist that a validator, a culture provider and the request-localization setup all trust was writable by any caller holding a reference. FrozenSet with an ordinal comparer keeps the exact matching semantics (a wrong-case tag is still rejected) and makes the set immutable. * feat(identity): localize the 401 challenge body The 401 that JwtBearer's OnChallenge writes is the one error response the global exception handler never sees, so it stayed English while every other error was negotiated: a pt-BR reader got "Authentication is required to access this resource." in the middle of an otherwise translated app. It resolves IStringLocalizer<SharedResources> per request. That works because of the pipeline order this PR already relies on: UseRequestLocalization sits ahead of UseAuthorization, which is where the challenge is emitted, so the negotiated UI culture is in place by then. Error.AuthenticationRequired is new; the title reuses Error.Unauthorized. ChallengeLocalizationTests pins both the body and that ordering, hitting a protected endpoint with and without Accept-Language. * fix(localization): translate the enum arguments inside localized messages Seven messages take an enum as an argument, and string.Format wrote the C# member name straight into the translated sentence: a pt-BR reader got "Não é possível atribuir um chamado no status Closed", "Cota ... Quarantined", "... porque está Pending". The catalogs were fully translated and the visible result still was not. GlobalExceptionHandler now resolves an enum argument as "{EnumType}.{Member}" against the same catalog as the message. A member with no entry keeps ToString(), which is exactly what every argument does today, so nothing regresses if a new enum reaches a message before its keys do. Keys added for the five enums that get there: TicketStatus, FileAssetStatus, Visibility, TopupRequestStatus, TenantProvisioningStatus. Each module's resource test now asserts every member of its enums has an entry in both catalogs, so a new member cannot ship half-translated. Ticket.Assign carried the other half of the problem: the guard took the action as a string ("assign") and fed it to a generic key, so the verb was English no matter the culture. It has one caller, so the action moves into the key (Tickets.CannotAssignInStatus) and the argument list keeps only the status. * test(architecture): pin the premise Audit.RealExceptionType depends on Review flagged the single `BaseType` step in Audit.RealExceptionType as fragile: a subclass of a localization wrapper would report the wrapper as its type, and one BCL exception would then appear under two names in the exceptionType filter. That cannot happen today — both wrappers are sealed — so the walk is not worth writing. The premise is what needs protecting, so it is now a gate: every ILocalizableMessage that is not a CustomException must be sealed. Verified by mutation: dropping `sealed` from LocalizedKeyNotFoundException turns it red. * fix(infra): pull minio/mc from quay.io too, not just minio/minio The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers 404), and it is what `minio-init` runs: without it `dotnet run --project src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull, and the `fsh` bucket is never created, so the first upload fails with NoSuchBucket. Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands. * fix(infra): pull minio/mc from quay.io too, not just minio/minio The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers 404), and it is what `minio-init` runs: without it `dotnet run --project src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull, and the `fsh` bucket is never created, so the first upload fails with NoSuchBucket. Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands. * build(deps): drop the dead SSH.NET pin The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping Testcontainers does not help", but the branch also bumps Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two statements cannot both be true, and the bump is the one that is: with the pin removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903 and exits 0. It was carrying a transitive pin that no longer pins anything. The MessagePack pin above it stays: that one is still load-bearing (removing it brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe). * build(deps): drop the dead SSH.NET pin The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping Testcontainers does not help", but the branch also bumps Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two statements cannot both be true, and the bump is the one that is: with the pin removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903 and exits 0. It was carrying a transitive pin that no longer pins anything. The MessagePack pin above it stays: that one is still load-bearing (removing it brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe). * ci: run Tickets.Tests in the unit-test job This PR adds `Tickets.Tests` and registers it in `FSH.Starter.slnx`, but the unit-test job enumerates test projects by name rather than running the solution. The slnx entry puts the project in the build and nowhere else: every run on this branch has been green without executing one of its four tests. A gate CI never runs is not a gate. Verified locally before wiring it in: `dotnet test src/Tests/Tickets.Tests` passes 4/4 on this head. * test(localization): drop the claim that Notifications has no test project * fix(api): let the container create the cultures request localization negotiates The chiseled runtime image sets DOTNET_SYSTEM_GLOBALIZATION_INVARIANT and ships no ICU, so new CultureInfo("pt-BR") threw CultureNotFoundException and the API never started in a container. PredefinedCulturesOnly=false allows named cultures there; resource lookup only needs the name, and formatting is pinned to InvariantCulture anyway. * feat(localization): localize the user-facing errors main added since the split The stale-profile 412 from #1387 gets Identity.ProfileChangedSinceLoaded and a status title (Error.PreconditionFailed) instead of 'CustomException'. The front-end-origin 400 from #1377 gets Frontend.OriginNotAllowed, with no arguments so the rejected Origin is never echoed. The idempotency in-progress 409 from #1378 is built by the filter, not thrown, so it resolves its own title and detail and carries the same code extension. Adds Identity.EmailConfirmed for the confirm-email success text the module slice now localizes. * docs(rules): name the admin language key the app actually uses * test(identity): drop a null-forgiving operator ShouldNotBeNull already covers SonarAnalyzer 10.34 (#1396) reports it as S8969, which fails the build under TreatWarningsAsErrors. * feat(localization): localize the confirm-email success message Both SPAs render ConfirmEmailAsync's return value verbatim on the confirmation page, and it was a hard-coded English sentence pointing at /api/tokens. It now resolves Identity.EmailConfirmed in the request language. * test(localization): allow a missing pt-BR entry, fail only on orphans and placeholder drift A key the pt-BR catalog lacks falls back to English at runtime, so requiring full key parity forced every contributor who adds text to write Portuguese. The parity test now fails only on a pt-BR key the neutral catalog does not have (left behind by a rename or delete) and keeps the placeholder check. A test-only probe catalog pins the runtime half of that contract: under the production AddHeroLocalization wiring, a key missing from the pt-BR resx resolves to its English value instead of leaking the raw key. * test(localization): let module catalogs omit pt-BR entries Same contract as the shared catalog: a key missing from a module's pt-BR resx falls back to English at runtime, so it is no longer a build failure. CatalogParityTests and the per-module resource tests now fail only on a pt-BR key the neutral catalog lacks and on placeholder drift; a module with no pt-BR resx at all is skipped instead of flagged. The enum-member tests keep requiring the neutral entry and drop the pt-BR one. * build(deps): move the .NET 10 packages to 10.0.13 for the October advisories Advisories published on 2026-10-13 cover every 10.0.x up to 10.0.12 of System.Security.Cryptography.Xml, System.Security.Cryptography.Pkcs and Microsoft.AspNetCore.SignalR.Common, and NuGetAudit under TreatWarningsAsErrors fails restore for every project, main included. SignalR.Common 10.0.13 requires Microsoft.Extensions.* 10.0.13, so a transitive pin alone does not resolve; the whole 10.0.12 line moves to 10.0.13 and Xml/Pkcs stay pinned. * refactor(localization): attach exception keys through a one-line factory CustomException gains Localized<TResource>(key, args) for a module catalog and Localized(key, args) for the shared one, so a localized throw keeps the English message and the key on one line instead of a four-line initializer: throw new NotFoundException($"User {id} not found.") .Localized<IdentityResources>("Identity.UserNotFound", id); MessageKey, MessageArgs and ResourceSource move from init to private set. An init property with a hand-written backing field fails S2292 under the repo's analyzers, and keeping both syntaxes would let new code drift back to the long form. The init surface shipped only in #1381, which is not in a release yet. All 305 initializer sites are rewritten by a codemod whose gate compares the (file, key, catalog, args) multiset before and after. The localization wrappers around BCL exceptions (LocalizedKeyNotFoundException, LocalizedUnauthorizedAccessException) do not derive from CustomException and keep their initializers. * test(localization): fail the build on a key missing from its catalog The localization rule already promised a code-to-resx guard, but none existed: GlobalExceptionHandler answers a missing key or a FormatException with the English message, and IStringLocalizer answers a missing key with the key itself, so a typo or a key added to the wrong module's catalog shipped green. LocalizedKeyReferenceTests scans src/BuildingBlocks and src/Modules for every literal key passed to .Localized<T>(...), .Localized(...), a wrapper's MessageKey initializer and localizer["..."], and fails when the key is not in the neutral catalog it targets or when the entry's {n} placeholders need more arguments than the call passes. Per-pattern floors keep a broken pattern from passing vacuously, and keys passed through a variable must be listed by hand. Factory tests cover the module catalog and arguments end to end through the exception pipeline, the shared overload, the default Forbidden/Unauthorized keys and the blank-key guard. * docs(agents): scaffold localized throws, validators and module catalogs The scaffolding skills generated unlocalized code, so every AI-scaffolded feature landed in English only: - add-feature: throws end in .Localized<{X}Resources>(...), new keys go in English to the module resx (pt-BR optional), validators inject IStringLocalizer<{X}Resources> for custom messages. - add-entity: client-breakable business rules throw a localized CustomException with the matching status. - add-module: Localization/{Name}Resources marker + resx, the manifest-name constraint, S2094 in NoWarn, and how Architecture.Tests picks it up. - add-full-slice and query-patterns follow the same pattern. localization.md shows the factory and describes the code-to-resx guard that now exists; api-conventions.md points to it from the exceptions table. * fix(localization): localize the throws main added after the slice Merging main brought in three client-visible throws with no key: the wallet currency mismatch from #1325 in BillingService (new key Billing.WalletCurrencyMismatch, English and pt-BR) and the two "file not found" throws in ChangeFileVisibilityCommandHandler, which reuse Files.FileNotFound. * test(localization): harden the key guard after review - The argument splitter skips // and /* */ comments, so a comma or an apostrophe in a comment inside a call no longer shifts the argument count the placeholder check relies on; a theory pins the splitter on comments, interpolated and verbatim strings, char literals and nested calls. - The factory pattern accepts a qualified catalog type and whitespace before the parenthesis, so those call shapes are checked instead of skipped. - localization.md states what the scan does not see (an indexer with a variable key, a localizer not named *localizer) and the params trap of passing a string[] or a null array; add-module explains the manifest name through the SDK's DependentUpon convention, which is what produces it. --------- Co-authored-by: iammukeshm <iammukeshm@gmail.com>
* feat(admin): localize the operator app with react-i18next Admin slice of the i18n work (split of #1344 as requested in review). Self-contained: it needs nothing from the backend slices, and the backend needs nothing from it. - `react-i18next` wiring in `src/i18n.ts`, language detected from the stored preference and negotiated with the API through `Accept-Language`. - English and Brazilian Portuguese catalogs, split per feature namespace. - Language switcher in the topbar; the chosen language is persisted to the user profile so it survives a reload, and `html[lang]` follows it through a `languageChanged` listener rather than staying pinned to `en`. - Number, date and currency formatting moved to `src/lib/format.ts` so the presentation layer owns formatting. The API stays UI-culture-only. - Impersonation handoff carries `locale` in the URL so an operator keeps their language when landing in the tenant app. Harmless on its own: nothing reads the parameter until the dashboard slice ships. - Playwright specs pin catalog parity (keys and placeholders, both directions), the switcher, formatting and the handoff parameter. The `SSH.NET` pin (`2026.0.0`) rides along because `template-smoke.yml` runs on `clients/**` and builds the scaffolded solution, which fails `restore` with `NU1903` until #1333 merges. It is byte-identical to that PR. * feat(admin): emit defaultLanguage in the Docker and Terraform runtime config env.ts reads defaultLanguage from config.json, but neither supported deployment path emitted it: the Docker template and the Terraform runtime_config carried only apiBase, defaultTenant and dashboardUrl. Every production deployment therefore fell back to en-US and the advertised per-deployment default language was configurable only in the Vite development file. The entrypoint defaults the variable to en-US so an unset value and an absent config.json land on the same language, and initI18n already drops an unsupported tag back to en-US. * build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. * fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. * fix(admin): make the token refresh single-flight, not per-caller The server rotates the refresh token on every successful refresh, so two refreshes started close together send the same token twice: the loser gets a 401 and apiFetch's failure path calls tokenStore.clear(). The operator is signed out mid-work, with no message, because the language switcher swallows the error with .catch(() => undefined). The single-flight existed but lived inside apiFetch's 401 retry, so it only covered one of the three call sites. It moves into refreshAccessToken itself, which is the only place that knows a refresh is in flight; the other two callers (session bootstrap, the language switcher) now share it for free. Gate: the new spec switches language twice in a row against a refresh that is held open, and counts the calls. Two on the previous code, one after. tsc and lint clean, full admin Playwright suite 136 passed. * fix(admin): keep the session when a speculative refresh fails The single-flight added earlier stopped two *concurrent* refreshes from racing, but one failed refresh was still enough on its own: `refreshAccessToken()` cleared the token store on any non-ok response, and the language switcher fires it speculatively (it only re-mints the JWT so the new `locale` claim is issued). A refresh token that had been revoked, rotated in another tab or dropped by a reseed therefore signed the operator out for choosing a language. Ending the session now belongs to the callers that know the request needed auth: the 401 retry in `apiFetch` and the boot probe in `AuthProvider`, which already cleared. The switcher reports the failure instead of swallowing it. A failed save is reported too. The language mutation had an `onSuccess` and no `onError`, so a rejected `PUT /identity/profile` left the UI switched with nothing on screen to say the choice was not stored — it silently reverts on the next fresh mount, which reads as the app forgetting on its own. Both paths are covered: a 401 refresh keeps the session and the language, and a 500 save surfaces the toast. * test(admin): gate interpolation parity, not just keys Matching key sets do not catch a translation that drops or renames {{var}}: i18next renders the placeholder as literal text, or the value is silently lost, and no key is missing. The gate compares the variable set per key across locales, ignoring the formatter after the comma. * fix(admin): stop rendering raw catalog keys for backend status values Approving a top-up moves it to `Invoiced`, and the badge read "status.invoiced": the label key is built from the value the API sends, and the catalog had neither `status.invoiced` nor `status.cancelled`. It did have `status.approved`, which the backend never emits. On main the badge printed the raw enum name, so this slice turned something readable into a key. The TS union and the filter list carried the same phantom, so both now mirror `TopupRequestStatus` in Modules.Billing.Contracts (Pending, Invoiced, Completed, Rejected, Cancelled) — filtering by "Approved" could only ever return nothing. Two safety nets, because catalog parity cannot see keys that are built at run time (both locales can be missing the same one and still match): - `parseMissingKeyHandler` degrades a missing key to its last segment and warns in development, so the worst case is the un-localized name rather than the key. - `tests/i18n/status-keys.spec.ts` reads the members straight out of the backend enums and asserts each one resolves in both catalogs. Verified by mutation: removing `status.invoiced` from the pt-BR catalog turns it red. Also removes the deprecated `NAV_ITEMS` / `filterNavItems` export, dead since the nav moved to `sections`/`topNavTop` (no importer in src/ or tests/): it carried hardcoded English labels that would have shipped untranslated the moment anyone imported it. `tests/i18n/i18n.spec.ts` read the Accept-Language header off a variable its own route handler assigns, but `waitForRequest` resolves at dispatch, before the handler runs. It reads the resolved request instead. * fix(infra): pull minio/mc from quay.io too, not just minio/minio The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers 404), and it is what `minio-init` runs: without it `dotnet run --project src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull, and the `fsh` bucket is never created, so the first upload fails with NoSuchBucket. Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands. * build(deps): drop the dead SSH.NET pin The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping Testcontainers does not help", but the branch also bumps Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two statements cannot both be true, and the bump is the one that is: with the pin removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903 and exits 0. It was carrying a transitive pin that no longer pins anything. The MessagePack pin above it stays: that one is still load-bearing (removing it brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe). * feat(admin): localize the permission matrix, and stop dates following the browser The role detail screen renders eight group headings, eight blurbs and thirty-four permission rows straight out of `PERMISSION_CATALOG`, all of them English literals. A pt-BR operator opening a role read a fully translated shell wrapped around fifty English strings — the largest untranslated surface left in the app. Each group now carries a stable key, the English text stays in the file as the fallback, and the screen resolves through the `roles` catalog. `tests/i18n/status-keys.spec.ts` gates it: it resolves the permission constants the same way the app does (the catalog holds the permission *value* at run time, not the identifier it is written with) and asserts every group, blurb and entry has an entry in both locales. Seven call sites formatted dates with `toLocaleString()` and friends, which use the browser's locale, not the app's — so a browser in en-US showed `5/23/2026, 10:00:00 AM` next to Portuguese labels. `format.ts` gains `formatDateTime`/`formatTime` and the call sites use them. The impersonation card's "started … · expires …" was hardcoded English prose; it is a key now. Three tests that could not fail, all of them named in review: - `format.spec.ts` passed an explicit locale in all nine assertions, so `resolveLocale` — the branch every production call takes — was untested. - `html[lang]` had no assertion at all, while the PR claims screen readers and browser translation now see the real language. - `i18n.spec.ts` read the `Accept-Language` header off a variable its own route handler assigns, and `waitForRequest` resolves at dispatch, before the handler runs. It reads the resolved request now. * fix(i18n): stop the missing-key handler from eating every defaultValue `parseMissingKeyHandler` took only the key and returned the capitalized last segment. i18next calls it for a missing key whether or not the call site passed a `defaultValue`, and the handler's return value is what renders — so every `t(key, { defaultValue })` in the app was silently degraded to a truncation of its own key. The permission matrix was the visible case: an entry the catalog had not caught up with rendered "Create" where the fallback says "Create users". Confirmed against the installed i18next (26.3.6) before the fix: `t("perm.entry.Permissions.Users.Create", { defaultValue: "Create users" })` returned `"Create"`, and the handler's second argument arrived as the defaultValue (`null` when there is none, not `undefined`). The two rules — a caller's fallback wins, otherwise degrade to the last segment — move to `lib/i18n-fallback.ts`, and `tests/i18n/missing-key.spec.ts` drives them through a real i18next instance rather than re-implementing the contract. Reverting the guard turns the first of the three red. * fix(admin): number formatting, plurals and the upload errors review found Three things a PR claiming i18n coverage should not have left. **Counts were interpolated raw.** `{{count}}` and the page/total placeholders put the number in with no formatter, so a Portuguese UI read "1234" next to currency and dates that were correctly grouped. i18next's own `number` formatter runs Intl with the active language, so the fix is per catalog entry: every `count` (it is the plural selector, so it is always numeric) plus the named ones checked one at a time. Two are deliberately left alone — the files dropzone interpolates already formatted byte sizes, and the activity page pre-formats its own count. **The list header pluralized in English.** `EntityPageHeader` rendered `${unit}s`, and four pages passed the unit as an English literal. "organização" + "s" is not a word. It takes the `unit.*` plural keys now, the way the dashboard's header already does, with the token typed as a union so an already translated word cannot be handed to it and silently render as a missing key. **Upload failures reached the user in English.** Cancel, transport failure, a rejected PUT, a blocked extension and an oversize file were built as English prose inside the hook and in module-scope XHR handlers. They now raise an `UploadError` carrying a catalog key (namespaced, since the resolver runs with whatever `t` the display site is bound to), and one exported resolver turns it into text at every place that shows it. The new `format.spec.ts` case asserts both halves of the count chip on a real list under pt-BR: reverting either the formatter or the plural keys turns it red. * fix(admin): let a lost upload key fall through to the segment fallback `defaultValue: e.messageKey` would have rendered "common:upload.cancelled" on screen if the catalog ever lost the entry. Without it the missing-key handler degrades to "Cancelled", which is the readable floor it exists to provide. * fix(i18n): namespace the language key, localize the last upload path, gate both Three loose ends the review left open on this branch. **Storage key.** The detector kept i18next's default `i18nextLng`. Every other value this app persists is namespaced (`fsh.admin.accessToken`, `fsh.admin.theme`, `fsh.admin.sidebar.collapsed`), and the bare key is claimed by both apps on a shared origin and by any other i18next app deployed beside them. Now `fsh.admin.lng`. Migration cost is one session: a returning user's old value is not read, so the first paint after deploy falls to the browser locale or the deployment default, and the profile hydrate then restores `User.Locale`. The prose that named the old key follows it. **Upload failures.** `describeUploadError` returned `e.message` for any plain `Error`, and `apiFetch` does not wrap `fetch`, so a presign step that never reaches the API surfaced as the browser's own `TypeError("Failed to fetch")` - in English, under a Portuguese UI, ahead of the localized fallback the caller had already passed in. That branch now logs the original for diagnosis and returns the catalog string. **Gates.** `tests/i18n/upload-errors.spec.ts` drives the real avatar picker on /settings/profile under `?culture=pt-BR` through three failures: a storage PUT that never connects, one the bucket rejects with a 403 (the interpolated `{{status}}` is asserted, not just the key), and a presign that never leaves the browser. `format.spec.ts` gains the reactivity case: a date already on screen has to reformat when the switcher changes the language under it. `resolveLocale` reads `i18n.language` at call time and nothing in format.ts subscribes to `languageChanged`; what makes it work is that every component that formats also calls `useTranslation`, which a refactor can drop silently. Verified: `playwright test tests/i18n/upload-errors.spec.ts` 3/3 and `tests/i18n/format.spec.ts` 4/4. Mutations: returning `e.message` from the fallback branch fails the presign case; freezing `resolveLocale` to the language captured at module load fails the reactivity case. `tsc -b` and `eslint .` both exit 0. * test(i18n): pin the phone number the language switch echoes back Review flagged that a language switch could clear `PhoneNumberConfirmed`. It does not today, and this is what says so rather than an assurance. `UserProfileService` compares the incoming phone to the stored one with a plain string compare and calls `SetPhoneNumberAsync` on any difference, which clears the confirmation flag. The switcher sends a whole profile, so what protects the flag is that `updateMyProfile` re-reads the profile and echoes `phoneNumber` back byte for byte: `""` and `null` are not interchangeable to that comparison, and neither is a trimmed variant. Nothing asserted it, which made the safe version and the damaging version look identical - adding a `.trim() || null` on this path reads like tidying and would un-confirm a verified phone number for choosing a language, with no error and no log. Three cases now pin the echo (`null`, `""`, a real number), and that exact normalization is what turns the `""` case red. Verified: `playwright test tests/i18n/switcher.spec.ts` 8/8 in each app; normalizing the echo fails 1/8 in each. * docs(admin): say the profile endpoint takes If-Match now, and that the switcher does not send it * test(admin): allow a missing pt-BR key and fall back to English at runtime Parity now fails only on a pt-BR key en-US lacks (orphan) and on placeholder drift between the two; a missing pt-BR key or namespace file is allowed. fallbackLng ends in en-US (fallbackChain): on a pt-BR deployment the old single-value chain fell back from pt-BR to pt-BR and rendered a capitalized key segment. A CATALOGS row may now omit pt-BR. * perf(admin): load non-default language catalogs on demand en-US stays in the main bundle as the terminal fallback; pt-BR becomes one lazy chunk (src/locales/pt-BR.ts). initI18n awaits the detected language's catalog before the first render, and every switch loads the catalog before changeLanguage. A failed load keeps the current language, shows a toast and neither persists nor PUTs the new locale. * test(admin): delay the pt-BR catalog so a missing pre-render await shows up Without the delay a fast dev server can hide an unawaited catalog load behind the lazy route chunk; with it, the first-render spec fails 3/3 on that mutation. * fix(admin): report the language on screen when a catalog fails to load After a failed pt-BR load at boot, i18next keeps language=pt-BR while the text falls back to English. html[lang], Accept-Language, Intl formatting, the switcher's active item, profile hydration and the impersonation handoff locale now read resolvedLanguage. A superseded switch whose load fails resolves false instead of toasting, and a failed switch no longer blocks profile hydration. * chore(admin): fail the lint on untranslated JSX text eslint-plugin-i18next's no-literal-string, in jsx-text-only mode, over src/pages and src/components. Text inside <code> and <kbd>, glyph-only runs, the product name and the build tag are allowed by pattern; the four English strings it found go to the common catalog. * docs(admin): show t() in the SettingsSection usage example * feat(admin): type every t() key against the en-US catalogs src/i18next.d.ts declares the en-US catalogs as i18next's resources, so a key its namespace lacks fails `tsc -b` instead of rendering a guessed label. Helpers take TFunction<"ns">; keys held in data are ParseKeys<"ns"> (nav items, tabs, palette fields); enum labels go through maps typed against the enum, so a new grant status without a label stops compiling. Strings that cross a runtime boundary (zod messages) are narrowed to the union of keys the schema sets. Typing the webhook form surfaced one real defect: the secret's max-length rule had no message, so zod's English default reached t() and rendered as-is in every language. It now has its own key. * docs(agents): document the frontend t() / namespace pattern frontend/shared.md gains a Translations section: one namespace per area, flat dotted keys, plurals, cross-namespace translators, typed keys (TFunction<"ns">, ParseKeys<"ns">, enum maps) and what the no-literal-string lint does and does not see. add-react-page adds the catalog step before the page, puts the page's strings and toasts through t(), and verifies with build as well as lint. * fix(admin): check the status maps against the backend enums Typed keys moved invoice and grant status labels from template keys to maps, but status-keys.spec.ts still checked the old key formula, so a new C# member with catalog entries passed while the map lacked it. The maps now live in src/lib/status-keys.ts, free of runtime imports, and the spec reads them. An impersonation status the server adds after the build shows its raw value instead of an empty badge (i18next answers an undefined key with ""). * docs(agents): second translators and the status-key maps in the frontend guide --------- Co-authored-by: iammukeshm <iammukeshm@gmail.com>
) * feat(dashboard): localize the tenant app with react-i18next Dashboard slice of the i18n work (split of #1344 as requested in review). Self-contained: it needs nothing from the backend slices, and the backend needs nothing from it. - `react-i18next` wiring in `src/i18n.ts`, language detected from the stored preference and negotiated with the API through `Accept-Language`. - English and Brazilian Portuguese catalogs, split per feature namespace, covering catalog, chat, tickets, files, billing, identity and settings. - Language switcher in the topbar; the chosen language is persisted to the user profile, and `html[lang]` follows it through a `languageChanged` listener. - The topbar stops hydrating the language from the profile once the user has chosen one in the session, so a concurrent profile save cannot silently switch the app back. The underlying lost update on `PUT /identity/profile` is tracked in #1359 and fixed separately. - Impersonation handoff adopts `locale` from the URL before `createRoot`, so an operator arriving from the admin app keeps their language. Harmless on its own: on `main` nothing sends the parameter yet. - Playwright specs pin catalog parity (keys and placeholders, both directions), the switcher, the shell, the hydration guard and the handoff parameter. The `SSH.NET` pin (`2026.0.0`) rides along because `template-smoke.yml` runs on `clients/**` and builds the scaffolded solution, which fails `restore` with `NU1903` until #1333 merges. It is byte-identical to that PR. * build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. * fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. * fix(dashboard): pass the unit token to the count chip, not a translated word EntityPageHeader translates the `unit` prop itself (`t("unit." + unit)`), so seven callers that handed it an already translated string built a key no catalogue has: activity, audits, brands, categories, my-files, invoices and tickets rendered a literal `unit.evento` in pt-BR. en-US hid it because the English translation equals the token. The prop is now a union of the `unit.*` plural keys common.json declares rather than `string`, so the mistake is a compile error instead of a runtime string. That is what found the scope: typing it turned up exactly the seven callers and no others. The seven per-page `*.unit` keys the old callers read are dead and removed from both catalogues. Gates: tsc went 7 errors -> 0 across the change; the new pt-BR spec fails on the pre-fix tickets page (2 of 3) and passes after; lint clean; full Playwright suite 186 passed. * fix(dashboard): keep the session when a speculative refresh fails refreshAccessToken() cleared the token store on any non-ok response. The language switcher fires it speculatively — it only re-mints the JWT so the new `locale` claim is issued — so a dead refresh token logged the user out for choosing a language: clear() -> tokenStore.subscribe -> setUser(null) -> ProtectedRoute redirects to /login. Proved end to end before the fix (401 on /identity/token/refresh landed on /login) and the new spec goes red without it. Ending the session now belongs to the callers that know the request needed auth: the 401 retry in apiFetch and the boot probe in AuthProvider, which already cleared. The switcher reports the failure instead of swallowing it. The single-flight moves into refreshAccessToken() (it lived inside apiFetch's 401 branch), so the switcher and the boot probe share the in-flight call rather than racing it — the server rotates the refresh token, and the loser of that race presents one the server has already spent. Same shape as clients/admin. Also accept the deactivated-tenant 403 that carries no ProblemDetails `code`: `code` only reaches the wire once the localized-errors slice ships on the API, and until then the English detail is the only signal. Both shapes are covered. * test(dashboard): gate interpolation parity, not just keys Matching key sets let a translation drop or rename an interpolation: i18next then renders the placeholder as literal text, or loses the value entirely, and neither shows up as a missing key. The gate compares the `{{var}}` set per key across locales, ignoring the formatter after the comma. Verified by mutation: removing {{formatted}} from one pt-BR string turns it red. * feat(dashboard): localize the command palette search keywords Every action carried an English keyword list hardcoded in the component, so a pt-BR user read every label in their language and still had to guess the English term to find anything by search ("helpdesk" for Chamados, "night" for the dark theme). The lists move to the catalog like any other string, keyed by action id, with the technical tokens a pt-BR user also types (sse, rbac, redis, 2fa) kept. Covered end to end: "noite" finds the dark theme under pt-BR and "night" no longer does, "oled" still finds it under en-US. * fix(dashboard): tell the user when the language could not be saved The language mutation had an onSuccess and no onError, so a rejected PUT left the UI switched with nothing on screen to say the choice was not stored. It reverts on the next fresh mount, which reads as the app forgetting on its own. A toast now names it. * test(dashboard): cover the failed-save toast The onError path had no coverage, so the toast could be removed and the suite would stay green. * fix(dashboard): stop a raw catalog key from reaching the screen Several labels build their key from a value the API sends (`invoices.status.${x}`, the ticket status and priority maps). Catalog parity structurally cannot see those: both locales can be missing the same key and still match, so a value the backend emits and the catalog never learned about renders as the key itself. That is not hypothetical — the admin slice shipped exactly that break, where approving a top-up showed "status.invoiced" in the badge. Two nets. `parseMissingKeyHandler` degrades a missing key to its last segment and warns in development, so the worst case is an un-localized word rather than a key. `tests/i18n/status-keys.spec.ts` reads the members straight out of the backend enums (InvoiceStatus, TicketStatus, TicketPriority) and asserts each one resolves in both catalogs, so adding a member in C# without translating it fails here instead of on a screen. * fix(infra): pull minio/mc from quay.io too, not just minio/minio The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers 404), and it is what `minio-init` runs: without it `dotnet run --project src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull, and the `fsh` bucket is never created, so the first upload fails with NoSuchBucket. Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands. * build(deps): drop the dead SSH.NET pin The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping Testcontainers does not help", but the branch also bumps Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two statements cannot both be true, and the bump is the one that is: with the pin removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903 and exits 0. It was carrying a transitive pin that no longer pins anything. The MessagePack pin above it stays: that one is still load-bearing (removing it brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe). * fix(i18n): stop the missing-key handler from eating every defaultValue `parseMissingKeyHandler` took only the key and returned the capitalized last segment. i18next calls it for a missing key whether or not the call site passed a `defaultValue`, and the handler's return value is what renders — so every `t(key, { defaultValue })` in the app was silently degraded to a truncation of its own key. The permission matrix was the visible case: an entry the catalog had not caught up with rendered "Create" where the fallback says "Create users". Confirmed against the installed i18next (26.3.6) before the fix: `t("perm.entry.Permissions.Users.Create", { defaultValue: "Create users" })` returned `"Create"`, and the handler's second argument arrived as the defaultValue (`null` when there is none, not `undefined`). The two rules — a caller's fallback wins, otherwise degrade to the last segment — move to `lib/i18n-fallback.ts`, and `tests/i18n/missing-key.spec.ts` drives them through a real i18next instance rather than re-implementing the contract. Reverting the guard turns the first of the three red. * fix(dashboard): format counts as numbers, and localize upload failures `{{count}}` and the named count placeholders went into the string raw, so a Portuguese UI read "1234" beside currency and dates that were correctly grouped. i18next's `number` formatter runs Intl with the active language, so the fix is per catalog entry: every `count` (it is the plural selector, so always numeric) plus the named ones checked one at a time. Two are deliberately left alone — the files dropzone interpolates already formatted byte sizes, and the activity page pre-formats its own count, so a second pass would either double-format or produce nothing. Upload failures still reached the user in English: cancel, transport failure, a rejected PUT, a blocked extension, an oversize file. They were built as prose inside the hook and in module-scope XHR handlers where no translator is in scope. They raise an `UploadError` carrying a catalog key now, namespaced because the resolver runs with whatever `t` the display site is bound to (the product image manager binds "files"), and one exported resolver turns it into text at the three places that show it. * fix(dashboard): let a lost upload key fall through to the segment fallback `defaultValue: e.messageKey` would have rendered "common:upload.cancelled" on screen if the catalog ever lost the entry. Without it the missing-key handler degrades to "Cancelled", which is the readable floor it exists to provide. * fix(i18n): namespace the language key and localize the last upload path **Storage key.** The detector kept i18next's default `i18nextLng`. Every other value this app persists is namespaced (`fsh.dashboard.accessToken`, `fsh.dashboard.tenant`, `fsh.dashboard.impersonation.*`), and the bare key is claimed by both apps on a shared origin and by any other i18next app deployed beside them. Now `fsh.dashboard.lng`. Migration cost is one session: a returning user's old value is not read, so the first paint after deploy falls to the browser locale or the deployment default, and the profile hydrate then restores `User.Locale`. The prose that named the old key follows it. **Upload failures.** `describeUploadError` returned `e.message` for any plain `Error`, and `apiFetch` does not wrap `fetch`, so a presign step that never reaches the API surfaced as the browser's own `TypeError("Failed to fetch")` - in English, under a Portuguese UI, ahead of the localized fallback the caller had already passed in. That branch now logs the original for diagnosis and returns the catalog string. **Gate.** `tests/i18n/upload-errors.spec.ts` drives the real avatar picker on /settings/profile under `?culture=pt-BR` through three failures: a storage PUT that never connects, one the bucket rejects with a 403 (the interpolated `{{status}}` is asserted, not just the key), and a presign that never leaves the browser. Namespacing is the sharp edge in this app: every upload surface binds `t` to "files" while the messages live in "common", so the keys the hook raises have to carry the prefix explicitly. Verified: `playwright test tests/i18n/upload-errors.spec.ts` 3/3. Mutation: dropping the `common:` prefix from `upload.networkError` fails the first case. `tsc -b` and `eslint .` both exit 0. * test(i18n): pin the phone number the language switch echoes back Review flagged that a language switch could clear `PhoneNumberConfirmed`. It does not today, and this is what says so rather than an assurance. `UserProfileService` compares the incoming phone to the stored one with a plain string compare and calls `SetPhoneNumberAsync` on any difference, which clears the confirmation flag. The switcher sends a whole profile, so what protects the flag is that `updateMyProfile` re-reads the profile and echoes `phoneNumber` back byte for byte: `""` and `null` are not interchangeable to that comparison, and neither is a trimmed variant. Nothing asserted it, which made the safe version and the damaging version look identical - adding a `.trim() || null` on this path reads like tidying and would un-confirm a verified phone number for choosing a language, with no error and no log. Three cases now pin the echo (`null`, `""`, a real number), and that exact normalization is what turns the `""` case red. Verified: `playwright test tests/i18n/switcher.spec.ts` 8/8 in each app; normalizing the echo fails 1/8 in each. * fix(dashboard): keep a clean profile form on the current ETag across a language switch The topbar switch rotates the concurrency stamp. A Settings > Profile form already open kept the tag it was seeded with, so its next save answered 412 and told the user someone else had changed the profile, for their own switch. A clean form now adopts a refetched version together with its values; a dirty form keeps its version, so a real concurrent edit still surfaces as a conflict. * fix(dashboard): format the file preview's created date in the app language A bare toLocaleString() followed the browser, so a pt-BR app on an English browser printed an English date under Portuguese labels. The preview now uses the same locale-aware helper as every other date in the app. * test(dashboard): allow a missing pt-BR key and fall back to English at runtime Parity now fails only on a pt-BR key en-US lacks (orphan) and on placeholder drift between the two; a missing pt-BR key or namespace file is allowed. fallbackLng ends in en-US (fallbackChain): on a pt-BR deployment the old single-value chain fell back from pt-BR to pt-BR and rendered a capitalized key segment. A catalogs row may now omit pt-BR. * perf(dashboard): load non-default language catalogs on demand en-US stays in the main bundle as the terminal fallback; pt-BR becomes one lazy chunk (src/locales/pt-BR.ts). initI18n awaits the detected language's catalog before the first render, and every switch (topbar, profile hydration, impersonation handoff) loads the catalog before changeLanguage. A failed load keeps the current language, shows a toast and neither persists nor PUTs the new locale. The command palette spec now waits for the shell before pressing Ctrl+K: the pt-BR first render lands after the load event. * fix(dashboard): report the language on screen when a catalog fails to load After a failed pt-BR load at boot, i18next keeps language=pt-BR while the text falls back to English. html[lang], Accept-Language, Intl formatting, the switcher's active item, profile hydration and the impersonation handoff comparison now read resolvedLanguage. A superseded switch whose load fails resolves false instead of toasting, and a failed switch no longer blocks profile hydration. * chore(dashboard): fail the lint on untranslated JSX text eslint-plugin-i18next's no-literal-string, in jsx-text-only mode, over src/pages and src/components. Text inside <code> and <kbd>, glyph-only runs, the product name and the build tag are allowed by pattern; the poll interval, the font sample and the accent's hue/chroma readout it found go to their catalogs. * feat(dashboard): type every t() key against the en-US catalogs src/i18next.d.ts declares the en-US catalogs as i18next's resources, so a key its namespace lacks fails `tsc -b` instead of rendering a guessed label. Helpers take TFunction<"ns">; keys held in data are ParseKeys<"ns"> (nav, tabs, cards, ticket and audit enums); invoice and top-up statuses go through maps next to their types, with the raw value for a status the server adds later. The activity page keeps reading audit labels through its ["activity", "audits"] translator, now typed as such. Redundant prefixes are gone ("common:" on a translator already bound to common): typed keys only accept a prefix for a namespace the translator lists. The upload hook's keys were prefixed so any translator could resolve them; the two files-bound callers now pass a common translator, which the hook's signature requires. * docs(agents): document the frontend t() / namespace pattern frontend/shared.md gains a Translations section: one namespace per area, flat dotted keys, plurals, cross-namespace translators, typed keys (TFunction<"ns">, ParseKeys<"ns">, enum maps) and what the no-literal-string lint does and does not see. add-react-page adds the catalog step before the page, puts the page's strings and toasts through t(), and verifies with build as well as lint. * fix(dashboard): check the status maps against the backend enums Invoice and top-up labels now come from maps, so status-keys.spec.ts reads them from src/lib/status-keys.ts (free of runtime imports) instead of re-deriving the old key formula; top-up requests are covered for the first time. Also: the topbar's language JSDoc sits on its function again, and a test comment names the translator the upload toast now uses. * docs(agents): second translators and the status-key maps in the frontend guide --------- Co-authored-by: iammukeshm <iammukeshm@gmail.com>
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).
src/BuildingBlocks(Golden Rule #4)The previous description omitted this, which was wrong. Eighteen files, needing maintainer sign-off:
Core—Core.csproj;Exceptions/(CustomException,ForbiddenException,UnauthorizedException, and the newILocalizableMessage,LocalizedKeyNotFoundException,LocalizedUnauthorizedAccessException);Localization/(newSharedResourcesmarker +SupportedCultures+ the two shared catalogs).Web—Extensions.cs(registers and orders the localization middleware, +6 lines);Exceptions/GlobalExceptionHandler.cs;Validation/PagedQueryValidator.cs; newLocalization/(LocalizationExtensions,UserLocaleRequestCultureProvider).Jobs—Extensions.cs, one exception message.Storage—QuotaMeteredStorageService.cs, one exception message.No existing behaviour of other building blocks is altered.
src/Directory.Packages.propscarries one addition, theSSH.NETpin discussed at the end of this description; the pin this branch used to carry forSystem.Security.Cryptography.Xmlis already onmain, so nothing else in that file differs.UseRequestLocalizationsets the UI culture onlyYou asked whether UI-culture-only was considered. It is now what ships.
mainhas no request localization at all, so pinning the formatting culture is less change than negotiating it:CultureInfo.CurrentCulturebehaves exactly as it does onmaintoday, and only resource lookup follows the request. For an API whose output is JSON that is the safer default, and it makes the CA1305 question moot rather than merely bounded.It is not one switch.
RequestLocalizationMiddleware.SetCurrentThreadCultureassigns both cultures unconditionally, so the culture half has to be pinned:DefaultRequestCulturecarries(InvariantCulture, configured default). The middleware resolves the culture half ascultureInfo ??= DefaultRequestCulture.Culture, making invariant the only reachable value.SupportedCulturesisnull, so the middleware skips culture filtering entirely. A one-element[InvariantCulture]list behaves identically but logsUnsupportedCultureson every request — the middleware's parent-culture walk bails at the empty culture name, so invariant is unmatchable by design.With formatting out of the negotiation, the neutral
pt/enentries bought nothing and are gone;SupportedCultures.Tagsis the single, specific-only list. A request asking for a bareptor an unsupported variant now resolves to the configured default. Both React apps canonicalise variants onto supported tags before calling the API, so app traffic is unaffected; a hand-rolled client sending bareptgets the default.Message arguments are culture-insensitive too. The localizer formats with
string.FormatunderCurrentCulture, so adoubleorDateTimein a message would render with an invariant separator. EveryMessageArgssite and everylocalizer["…", …]call site was enumerated: allint,long,stringor enum, exceptMaxWindow.TotalDaysin the two audit-window validators, which is now anintat the source.Catalogs are named for specific cultures
SharedResources.pt.resxand the ten module catalogs are now*.pt-BR.resx, matching the front-end. Pure renames, no string changed.The asymmetry you flagged is gone, and so is the trap behind it: a future
pt-PTis no longer served Brazilian strings by parent fallback. The documented consequence is that a bareptor an unsupported variant lands on the neutral English catalog rather than on Portuguese. Adding a language is now: add the specific tag toSupportedCultures.Tags, add a*.{tag}.resxper catalog, add the JSON catalogs to both apps, and drop it from the front-endCANONmap if it was being folded into another tag..agents/rules/localization.mdrecords all of this.The
LocalecolumnThe old summary was wrong: there is no DB default. The column is nullable with
en-USas a code-level fallback, and it is nowcharacter varying(10)rather than unboundedtext— 10 covers language-script-region (zh-Hant-TW).AddUserLocalewas edited in place rather than stacked with anALTER, since it has never shipped in a release.Confirmed as you asked:
Validation.UnsupportedLocaleis wired at the write boundary.UpdateUserCommandValidatorrestrictsLocaletoSupportedCultures.Tags, onPUT /identity/profile, via the MediatorValidationBehavior. The column constraint is the storage-level backstop, not the validation.Known behaviour (documented, not bugs)
localeclaim lags a language switch by one token. The provider reads the JWT claim, so a switch reaches the API at the next token issue. The front-end persists to the profile and re-mints, so it converges; in between, the shell can be in the new language while an API error is still in the old one. The alternative is a per-request DB read on every authenticated call.apiFetch, soAccept-Languageon the negotiate is the browser's. Applies to every session, not just impersonation. Named explicitly inhandoff-locale.spec.tsso any other channel that stops carrying the locale fails the test.UseExceptionHandler()sits ahead ofUseHeroLocalization(), which in turn has to sit afterUseAuthentication()because the culture provider reads thelocaleclaim offHttpContext.User. An exception thrown by anything in between — HTTPS redirection, CORS, static files, routing — is therefore rendered in the configured default culture rather than the caller's. Endpoint handlers, where every localized exception in this codebase is actually thrown, are unaffected. Moving the exception handler below localization would leave those middlewares with noProblemDetailsat all, which is the worse trade, so this stays as documented behaviour rather than being papered over.Fixed since the last review
Three reviewer bots' P2s, plus what a five-lens adversarial pass turned up. Each was verified at the source before being treated as real, and each fix has a mutation gate — the fix is reverted, the pinning test must go red, and the file is restored byte-exact.
StartImpersonationstrips the target'slocaleclaim on purpose, and the two apps normally sit on different origins, so the handoff URL had no way to convey it and the API fell through to the dashboard's own browser detection. The handoff now carrieslocale; the dashboard adopts it beforecreateRoot, which fixes the shell andAccept-Languageat once. Chosen over an actor-locale token claim: it leaves the token contract and the framework culture provider untouched.TitleKeyForsent everything outside four statuses toError.Unexpected; the type-name fallback beside it only fires onResourceNotFound, and that key resolves, so it never fired. Every one of the 41Conflictthrow sites across Billing and Catalog answeredStatus: 409with a title contradicting its own detail. This branch regressed that — before it,Titlewas the exception type name. Unmapped statuses now fall back to the type name again, andConflictgets a real localized title.updateMyProfileis a read-modify-write with no concurrency token, and Settings › Profile invalidates the same["identity","me"]key the topbar reads, so a save whose read preceded the language PUT could echo the old locale back and win. The topbar's hydration effect then changed the UI language to it. Hydration now stops once the user chooses a language in-session; a locale set on another device still carries over on a fresh mount. The underlying lost update is a contract change toPUT /identity/profileand is tracked separately in #1359.lang="en"that nothing updated, so a Portuguese UI announced itself as English to screen readers and browser translation.ExceptionSeverityClassifierwas never exercised with theLocalized*subclasses, even though those subclass the BCL types precisely to keep audit severity classification working — changing a base type would have silently reclassified every unauthorized access with the suite green.CatalogParityTestsdiscovers every catalog by reflection and compares each culture's own key set withtryParents: false, plus the placeholder-index set per key —{1}present in one culture and not the other throwsFormatExceptionat render time, in that culture only. New module catalogs are covered without a new test.The
SSH.NETpin is carried from #1333NU1903/GHSA-q939-rpr3-3284onSSH.NET2025.1.0, pulled transitively by Testcontainers, failsrestorefor the whole solution underTreatWarningsAsErrors— onmaintoo:dotnet restore src/FSH.Starter.slnxat3f2959e6fails identically. It is not introduced here, and the fix properly belongs to #1333.Rather than leave this PR red on someone else's advisory, the pin is carried here byte-identical to #1333's version of the file, comment included. That keeps both mergeable in either order:
git merge-treeon both orderings yields a clean tree holding a singleSSH.NETentry, and the merged file is identical to what #1333 alone produces. The byte identity is what buys that — the same pin under a reworded comment conflicts. So if #1333's pin changes during review this copy should be matched rather than allowed to drift, and once #1333 lands first it can simply be dropped from here.Testing
Not pinned, and stated rather than glossed: the hydration guard has no regression test. Reproducing it needs an in-mount profile refetch driven through the Settings form, and the click races the language-change re-render (element detached from the DOM). Three distinct approaches, then stopped rather than paper over it with retries or a longer timeout.
Notes
en-USandpt-BRare held at strict key and placeholder parity, enforced by tests, so a missing or mis-arged translation fails the build instead of shipping English.codeon ProblemDetails and the new config section are public contract.