Skip to content

feat(dashboard): localize the tenant app with react-i18next (4/4) - #1384

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

marcelo-maciel wants to merge 17 commits into
fullstackhero:mainfrom
marcelo-maciel:feat/i18n-dashboard

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Carries two infrastructure fixes that are not this PR's topic. Without them CI cannot even reach this PR's code.

  • Dependency bump: Microsoft.SourceLink.GitHub to 10.0.401 and Testcontainers to 4.14.0, clearing NU1902/NU1903 so restore succeeds. Those are the versions #1375 (SourceLink) and #1369 (Testcontainers) carry: this hunk is the union of the two, plus one comment per pin naming the advisory it answers.
  • MinIO (b13fb9f8): minio/minio is gone from Docker Hub, so every Testcontainers-backed integration test dies on the image pull. Pulls from quay.io on a pinned tag instead. Same fix as #1388.

The MinIO hunk is byte-identical to #1388. The dependency hunk is not byte-identical to #1375 or #1369, which each carry half of it without the comments, but it is identical across all twelve PRs in this series: src/Directory.Packages.props resolves to the same blob (854deb95) at every head. Either way they merge in any order, and these copies can be dropped once the PRs that own them land.

Reopened from #1363. That PR was closed automatically on 2026-09-14, when the head fork
was deleted. It reopened at 23989b14, and review has added commits on top since then (the
commit list above is the current one). The earlier review history stays on #1363.


clients/dashboard slice 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):

  • The language the user picks is persisted through PUT /identity/profile, and the locale field
    only 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.
  • isTenantDeactivatedError keys off the ProblemDetails code, 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.
Slice Files PR
Framework 56 #1381 — the hard review
Module catalogs and wiring 229 #1382 — depends on the framework slice
clients/admin 111 #1383 — independent
clients/dashboard 134 this one

What is in here

  • react-i18next wiring in src/i18n.ts. The chosen language is persisted to the user profile and sent to the API as Accept-Language through apiFetch; variants are canonicalised onto a supported tag before the call, so the API never sees a bare pt.
  • en-US and pt-BR catalogs per feature namespace — catalog, chat, tickets, files, billing, identity, settings — held at strict key and placeholder parity in both directions by tests/i18n/parity.spec.ts.
  • Language switcher in the topbar, and html[lang] follows the active language through a languageChanged listener, replacing the static lang="en" that nothing updated.
  • Formatting stays in the presentation layer, the other half of the framework slice's UI-culture-only decision.
  • Impersonation handoff adopts locale from the URL before createRoot, which fixes the shell language and Accept-Language in the same step. Harmless on its own: on main nothing sends the parameter until the admin slice lands, and this PR's handoff-locale.spec.ts passes with only this half present.
  • The topbar stops hydrating the language from the profile once the user chooses one in-session. updateMyProfile is 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 main plus 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.json and npm run lint: all exit 0.
  • Playwright, full suite, one worker: 207 passed, 0 failed, 0 flaky, 0 skipped. That is 183 at the
    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.props

One backend file in a React PR, which needs explaining. template-smoke.yml triggers on paths: clients/** and runs dotnet build on the scaffolded solution, and .template.config/template.json does not exclude src/Tests/** — so a front-end-only PR still restores the full package graph and hits NU1903 / GHSA-q939-rpr3-3284 on SSH.NET 2025.1.0, pulled transitively by Testcontainers. That advisory fails restore on main too, re-verified today at 3f2959e6.

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.NET pin it used to carry is gone: Testcontainers 4.14.0 already depends on the patched version. See the note at the end.

Notes

  • 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. The underlying lost update on PUT /identity/profile is a contract change and is tracked in #1359, where the ponytail: comment in the topbar points.
  • SignalR does not carry the app locale: the hub client builds its own requests instead of going through apiFetch, so Accept-Language on the negotiate is the browser's. Named explicitly in handoff-locale.spec.ts so 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.mdx is 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 on main yet.

Review follow-ups

An independent review of this slice found one P1 and a handful of smaller things. All are fixed here:

  • Choosing a language could end the session. The switcher fires a token refresh so the new
    locale claim is minted, and refreshAccessToken() cleared the token store on any non-ok
    response — 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/refresh landed the user on /login. Clearing the session now belongs to the
    callers 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 the
    switcher and the boot probe share the in-flight call rather than racing the server-side rotation.
  • Catalog parity now covers interpolation, not just keys. A translation that drops or renames
    {{var}} renders the placeholder as literal text and no key is missing, so the old gate passed.
    Verified by mutation.
  • The command palette searched in English only. Every action carried a hardcoded English keyword
    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.
  • A failed save was invisible. 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 reverts on the next fresh mount, which reads as the app forgetting on its own. It
    now raises a toast, covered by a spec.
  • Two negative assertions could not fail. The impersonation switcher spec asserted "no PUT was
    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:

  • The MinIO carve-out only moved minio/minio to quay.io. minio/mc is gone from Docker Hub too
    (hub.docker.com/v2/repositories/minio/mc/ answers 404) and it is what minio-init runs, so both
    dotnet run --project src/Host/FSH.Starter.AppHost and docker compose up died on the pull and the
    fsh bucket was never created. Now pinned to the same quay tag #1388 uses.
  • The SSH.NET pin is gone: it pinned nothing. Its own comment claimed bumping Testcontainers
    does 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 --force
    reports 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.yml and src/Directory.Packages.props are now
genuinely byte-identical to #1388 (git diff --exit-code, checked today), which the earlier claim
was not.


A real defect in the fallback this PR introduced. parseMissingKeyHandler took only the key
and 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, so
every 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 (null when there is none, not
undefined). The two rules (a caller's fallback wins, otherwise degrade to the last segment)
moved 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 it red.

And the same number formatting. {{count}} and the named count placeholders interpolated the
number raw, so a Portuguese UI read "1234" beside correctly grouped currency and dates. They go
through i18next's number formatter now, except the two that must not: the files dropzone
interpolates 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
UploadError carrying a catalog key now, namespaced because the resolver runs with whatever t
the 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 i18nextLng storage key. 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. It is fsh.dashboard.lng now. 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 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 namespacing above had no test of its own, which is the half that matters here: every upload
surface in this app binds t to "files" while the messages live in "common", so a dropped prefix
resolves nothing and renders the last segment of the key.
tests/i18n/upload-errors.spec.ts drives the real avatar picker on /settings/profile under
?culture=pt-BR through a storage PUT that never connects, one the bucket rejects with a 403, and
a presign that never leaves the browser. The 403 case asserts the interpolated {{status}}, not
just that some catalog string rendered; dropping common: from upload.networkError turns the
first case red.

Writing it found one more English string on that path. describeUploadError returned e.message
for any plain Error, and apiFetch does not wrap fetch, so a presign that never reached the
API surfaced as the browser's own TypeError("Failed to fetch") ahead of the localized fallback
the caller had already passed in. That branch logs the original for diagnosis and returns the
catalog string now.

And the PhoneNumberConfirmed question from review is now pinned rather than answered. It does
not reproduce: UserProfileService compares the incoming phone to the stored one with a plain
string compare and only calls SetPhoneNumberAsync on a difference, and updateMyProfile re-reads
the profile and echoes phoneNumber back 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:
"" and null are not interchangeable to that comparison, so 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 pin the echo now (null, "", a real number), and that exact
normalization is what turns the "" case red.

One timing change, flagged rather than buried. expect.timeout in playwright.config.ts is
10 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 the
first 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.

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

Copy link
Copy Markdown
Contributor Author

Two follow-ups that belong to this PR rather than to the ones it sits next to:

The switcher persists a field main does not have. updateMyProfile({ locale }) depends on the locale column and the JWT claim introduced in #1381 and #1382. Merged out of order, the PUT silently drops the value. Worth a line in the PR body naming that dependency so the merge order is explicit.

Adaptation needed once #1387 lands. #1387 rewrites updateMyProfile to take the profile the form was seeded from plus its ETag ({ profile, expectedETag, firstName, lastName, phoneNumber }) and deletes getMyProfile in favour of getMyProfileWithETag. clients/dashboard/src/components/layout/topbar.tsx imports both and calls updateMyProfile({ locale: tag }), so whichever of the two merges second fails tsc -b in Frontend CI. Agreed order: #1387 first, this PR adapts — the switcher reads through getMyProfileWithETag and passes profile, expectedETag and locale. The ponytail: comment in that file already anticipates this and can be removed with the change, since the ETag is exactly the fix it points at (#1359).

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant