feat(dashboard): localize the tenant app with react-i18next (4/4) - #1384
marcelo-maciel wants to merge 17 commits into
Conversation
Dashboard slice of the i18n work (split of fullstackhero#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 fullstackhero#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 fullstackhero#1333 merges. It is byte-identical to that PR.
…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 fullstackhero#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.
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`.
…ed 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.
|
Two follow-ups that belong to this PR rather than to the ones it sits next to: The switcher persists a field Adaptation needed once #1387 lands. #1387 rewrites |
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.
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.
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.
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.
The onError path had no coverage, so the toast could be removed and the suite would stay green.
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.
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 fullstackhero#1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands.
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).
`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.
`{{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.
…lback `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.
**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.
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.
clients/dashboardslice of the i18n work, split out of #1344. 134 files, of which 32 are JSON catalogs. Same shape as the admin slice.Mergeable before the backend slices, with two named dependencies (the earlier claim of full
independence was wrong, and review caught it):
PUT /identity/profile, and thelocalefieldonly exists once the framework slice (#1381)
lands. Until then the switch still applies, and still survives a page reload through the client
store, but the server drops the field.
isTenantDeactivatedErrorkeys off the ProblemDetailscode, which the API only sends once#1382 lands. It now also matches
the current English detail, so the terminal page is reached either way, and the text branch can be
deleted the day that slice merges.
clients/adminclients/dashboardWhat is in here
react-i18nextwiring insrc/i18n.ts. The chosen language is persisted to the user profile and sent to the API asAccept-LanguagethroughapiFetch; variants are canonicalised onto a supported tag before the call, so the API never sees a barept.en-USandpt-BRcatalogs per feature namespace — catalog, chat, tickets, files, billing, identity, settings — held at strict key and placeholder parity in both directions bytests/i18n/parity.spec.ts.html[lang]follows the active language through alanguageChangedlistener, replacing the staticlang="en"that nothing updated.localefrom the URL beforecreateRoot, which fixes the shell language andAccept-Languagein the same step. Harmless on its own: onmainnothing sends the parameter until the admin slice lands, and this PR'shandoff-locale.spec.tspasses with only this half present.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 — and the hydration effect would then switch the UI to it. A locale set on another device still carries over on a fresh mount.Testing
Everything below is this slice on its own, at
mainplus these 134 files — not a share of the unsplit branch's totals.npm ci,npm run build(tsc -b+vite build),npx tsc -b tsconfig.tests.jsonandnpm run lint: all exit 0.time of the split (the same count as on the unsplit branch, so nothing was lost in the cut) plus the
24 the review follow-ups below added. That includes
tests/impersonation/handoff-locale.spec.ts, which passes with only this half of the handoff present.src/Directory.Packages.propsOne backend file in a React PR, which needs explaining.
template-smoke.ymltriggers onpaths: clients/**and runsdotnet buildon the scaffolded solution, and.template.config/template.jsondoes not excludesrc/Tests/**— so a front-end-only PR still restores the full package graph and hitsNU1903/GHSA-q939-rpr3-3284onSSH.NET2025.1.0, pulled transitively by Testcontainers. That advisory failsrestoreonmaintoo, re-verified today at3f2959e6.What that file carries is the Testcontainers and SourceLink bumps, byte-identical to #1378, so both stay mergeable in either order and this copy can be dropped once #1378 lands. The explicit
SSH.NETpin it used to carry is gone: Testcontainers 4.14.0 already depends on the patched version. See the note at the end.Notes
element detached from the DOM). Three distinct approaches, then stopped rather than paper over it. The underlying lost update onPUT /identity/profileis a contract change and is tracked in #1359, where theponytail:comment in the topbar points.apiFetch, soAccept-Languageon the negotiate is the browser's. Named explicitly inhandoff-locale.spec.tsso any other channel that stops carrying the locale fails the test.Docs (Golden Rule #10)
fullstackhero/docs#238, kept as a single PR covering all four slices —
internationalization.mdxis one page whose sections map across the split. The Frontend (admin and dashboard) section is this slice and the admin one: catalogs, language detection and normalization,Accept-Language, the switcher and locale-aware formatting. That PR should merge after the last of the four, so the page never describes code that is not onmainyet.Review follow-ups
An independent review of this slice found one P1 and a handful of smaller things. All are fixed here:
localeclaim is minted, andrefreshAccessToken()cleared the token store on any non-okresponse — so a refresh token that had been revoked, rotated in another tab or dropped by a
reseed logged the user out for picking a language. Proved end to end before fixing it: a 401 on
/identity/token/refreshlanded the user on/login. Clearing the session now belongs to thecallers that know the request needed auth (the 401 retry and the boot probe); the switcher reports
the failure instead. The single-flight moved into
refreshAccessToken()at the same time, so theswitcher and the boot probe share the in-flight call rather than racing the server-side rotation.
{{var}}renders the placeholder as literal text and no key is missing, so the old gate passed.Verified by mutation.
list, so a pt-BR user read every label in their language and still had to type "helpdesk" to find
Chamados. The lists moved into the catalog, keyed by action id.
onSuccessand noonError, so arejected
PUT /identity/profileleft the UI switched with nothing on screen to say the choice wasnot stored — it reverts on the next fresh mount, which reads as the app forgetting on its own. It
now raises a toast, covered by a spec.
sent" and "no refresh fired" immediately after the label flipped, which is before either request
would have been issued. They now wait for the network to settle first.
Infra carve-outs, corrected after review. Two things in the out-of-topic hunks were wrong and
are fixed on the branch:
minio/minioto quay.io.minio/mcis gone from Docker Hub too(
hub.docker.com/v2/repositories/minio/mc/answers 404) and it is whatminio-initruns, so bothdotnet run --project src/Host/FSH.Starter.AppHostanddocker compose updied on the pull and thefshbucket was never created. Now pinned to the same quay tag #1388 uses.SSH.NETpin is gone: it pinned nothing. Its own comment claimed bumping Testcontainersdoes not help, but 4.14.0 — which this branch also carries — declares
SSH.NET >= 2026.0.0.Measured rather than argued: with the pin removed,
dotnet restore src/FSH.Starter.slnx --forcereports zero NU1902/NU1903 and exits 0. (The MessagePack pin next to it stays; removing that one
does bring its advisory straight back.)
With both applied,
deploy/docker/docker-compose.ymlandsrc/Directory.Packages.propsare nowgenuinely byte-identical to #1388 (
git diff --exit-code, checked today), which the earlier claimwas not.
A real defect in the fallback this PR introduced.
parseMissingKeyHandlertook only the keyand returned its capitalized last segment. i18next calls that handler for a missing key whether
or not the call site passed a
defaultValue, and the handler's return value is what renders, soevery
t(key, { defaultValue })in the app was being degraded to a truncation of its own key.Measured 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 arrives as the defaultValue (
nullwhen there is none, notundefined). The two rules (a caller's fallback wins, otherwise degrade to the last segment)moved to
lib/i18n-fallback.ts, andtests/i18n/missing-key.spec.tsdrives them through a reali18next instance rather than re-implementing the contract. Reverting the guard turns it red.
And the same number formatting.
{{count}}and the named count placeholders interpolated thenumber raw, so a Portuguese UI read "1234" beside correctly grouped currency and dates. They go
through i18next's
numberformatter now, except the two that must not: the files dropzoneinterpolates already formatted byte sizes, and the activity page pre-formats its own count.
Upload failures were still English prose built inside the hook and in module-scope XHR handlers
(cancel, transport failure, a rejected PUT, a blocked extension, an oversize file). They raise an
UploadErrorcarrying a catalog key now, namespaced because the resolver runs with whatevertthe display site is bound to, and one exported resolver renders it at the three places that show
it.
A later round: the storage key and a gate for the upload errors.
The language detector kept i18next's default
i18nextLngstorage key. Every other value this apppersists is namespaced (
fsh.dashboard.accessToken,fsh.dashboard.tenant,fsh.dashboard.impersonation.*), and the bare key is claimed by both apps on a shared origin andby any other i18next app deployed beside them. It is
fsh.dashboard.lngnow. The i18n page said the language was cached in localStorage and left the key implicit, which now reads as the library default; named there in fullstackhero/docs@0f4f1d97, on the same docs PR. The migration costis 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 namespacing above had no test of its own, which is the half that matters here: every upload
surface in this app binds
tto "files" while the messages live in "common", so a dropped prefixresolves nothing and renders the last segment of the key.
tests/i18n/upload-errors.spec.tsdrives the real avatar picker on /settings/profile under?culture=pt-BRthrough a storage PUT that never connects, one the bucket rejects with a 403, anda presign that never leaves the browser. The 403 case asserts the interpolated
{{status}}, notjust that some catalog string rendered; dropping
common:fromupload.networkErrorturns thefirst case red.
Writing it found one more English string on that path.
describeUploadErrorreturnede.messagefor any plain
Error, andapiFetchdoes not wrapfetch, so a presign that never reached theAPI surfaced as the browser's own
TypeError("Failed to fetch")ahead of the localized fallbackthe caller had already passed in. That branch logs the original for diagnosis and returns the
catalog string now.
And the
PhoneNumberConfirmedquestion from review is now pinned rather than answered. It doesnot reproduce:
UserProfileServicecompares the incoming phone to the stored one with a plainstring compare and only calls
SetPhoneNumberAsyncon a difference, andupdateMyProfilere-readsthe profile and echoes
phoneNumberback byte for byte, so a language switch never produces one.What made that worth a test is that the safe version and the damaging version look identical:
""andnullare not interchangeable to that comparison, so adding a.trim() || nullon thispath reads like tidying and would un-confirm a verified phone number for choosing a language, with
no error and no log. Three cases pin the echo now (
null,"", a real number), and that exactnormalization is what turns the
""case red.One timing change, flagged rather than buried.
expect.timeoutinplaywright.config.tsis10 s rather than the 5 s default, which aligns it with the action (10 s) and navigation (15 s)
budgets already in this config: every test ends in a
toBeVisible, and under CPU contention thefirst paint of a lazy route lands past 5 s while staying well inside the other two. It is a wait
budget, not a correctness threshold, and it is called out here so it can be vetoed rather than
discovered.