Skip to content

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

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

marcelo-maciel wants to merge 22 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

Updated 2026-09-25: merged main after #1369, #1375, #1390 and #1395 landed; the dependency and MinIO hunks this PR used to carry are gone, so it now touches only its own slice and no longer touches src/Directory.Packages.props at all. The same merge brought in #1387; how this slice fits it is described below.

The #1395 merge kept this slice's dependencies on top of main's bumps: package.json keeps the i18n dependencies plus react-router-dom 7.18.4, and package-lock.json is main's lockfile with only the seven i18n entries added (i18next 26.3.6, react-i18next 17.0.10, i18next-browser-languagedetector 8.2.1, html-parse-stringify, void-elements, use-sync-external-store, @babel/runtime). No version the branch intended was rolled back, and npm ci accepts the lockfile.

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. 141 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 66 #1381 — the hard review
Module catalogs and wiring 301 (237 of its own) #1382 — depends on the framework slice
clients/admin 122 #1383 — independent
clients/dashboard 141 this one

Counts are each PR's own diff, git diff --name-only origin/main...HEAD (from the merge base), taken on 2026-09-25 after the latest commits. #1382 carries the framework slice by merge, so its 301 includes it; 237 is its diff against the framework branch.

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. The ["identity","me"] key the topbar reads is shared and refetched after every profile write, including the switch's own, and that refetch can land before the new locale does; re-hydrating from it would switch the UI back with no error and nothing for the user to act on. The lost update behind the original concern is now closed server-side by the If-Match precondition from #1387, and the guard still stands for the UI. A locale set on another device still carries over on a fresh mount.

If-Match on the profile (#1387)

#1387 added ETag/If-Match to PUT /identity/profile and to the profile page. The merged branch keeps that flow, and its new copy (the 412 conflict toast and the load-error banner) goes through t() in en-US and pt-BR.

  • The topbar switcher sends If-Match. It reads the profile and its ETag at save time (getMyProfileWithETag), sends the tag back as If-Match, and refetches the shared ["identity","me"] query after the save, because the save rotated the concurrency stamp. A locale-only save carries nothing the user typed, so reading at save time leaves no stale snapshot for If-Match to protect; a 412 in the read-to-PUT gap lands in the existing failed-save toast.
  • A clean Settings › Profile form adopts the refetched version. Before this, a 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 language switch. A clean form now adopts the refetched version together with its values; a dirty form keeps its own, so a real concurrent edit still surfaces as a conflict.
  • Two new tests in tests/i18n/switcher.spec.ts pin this: "sends If-Match from its own read and refreshes the cached tag after the save" and "a profile form left open across a language switch saves against the post-switch tag". Each was verified red with its fix reverted.

Testing

Everything below is this slice on its own, at main plus these 141 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, on 2026-09-25 after merging main with #1395 and the file-preview fix at the end of this description: 228/228 passed. At the time of the split the suite was 183 (the same count as on the unsplit branch, so nothing was lost in the cut). That includes tests/impersonation/handoff-locale.spec.ts, which passes with only this half of the handoff present.
  • CI at the current head (49b72419), checked on 2026-09-25: Frontend CI passes, with lint, build and E2E for both apps and both template scaffolds green. Backend CI reports pass as the gate check; its Unit Tests, Integration Tests, Coverage Gate and DbMigrator Container Smoke jobs are skipped by the src/** path filter because this PR no longer touches src/.

Notes

  • tests/i18n/hydration-guard.spec.ts exercises the guard's edges: "the document language attribute follows the active locale" switches to Português against a server that keeps answering en-US and expects html[lang] to read pt-BR, and "still hydrates from the server on a fresh session with no in-session choice" checks the guard does not break the carry-over it protects. The original race, an in-mount profile refetch driven through the Settings form, is still not reproduced: 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 (#1359) is closed server-side by #1387.
  • 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.

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.

The file preview's created date followed the browser. file-preview-dialog.tsx used a bare toLocaleString(), so a pt-BR app on an English browser printed an English date under Portuguese labels. It now uses formatDateTimeMono, the same locale-aware helper as the other dates in the app. A new case in tests/files/files.spec.ts runs the browser in en-US and the app in pt-BR, and it is red with the fix reverted.

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.
Drops the carried SourceLink/Testcontainers bumps and the MinIO quay pin in favour of main (fullstackhero#1369, fullstackhero#1375, fullstackhero#1390). Profile settings keep the fullstackhero#1387 ETag flow with its new copy routed through t(); the topbar language switch now reads the profile with its ETag, sends If-Match, and refetches the shared profile query so the settings form never seeds from a spent tag.
…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.
Takes fullstackhero#1395's Dependabot bumps (react-router-dom 7.18.4 and the lockfile patches) and keeps this slice's i18n packages. The lockfile is main's with only the i18n entries added, so no transitive patch is rolled back.
…guage

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