Skip to content

Commit 904bac6

Browse files
docs: internationalization (i18n) (#238)
* docs: document internationalization (i18n) Add a cross-cutting-concerns page covering end-to-end localization: backend IStringLocalizer with neutral-culture resx and RequestLocalization, the five-step culture-resolution chain, per-user User.Locale on the JWT locale claim, and both React apps on react-i18next with a language switcher. Includes an "adding a language" guide and a changelog entry. * docs: document the ProblemDetails code extension Localized problem details mean `detail` is prose in the caller's language, so clients must branch on the exception's MessageKey, now emitted as a `code` extension. Documents the field in Error handling, cross-links it from Internationalization, and extends the i18n changelog entry. Also corrects the CustomException row: `title` is the localized status title, not the exception type name (the type name is only the fallback). * docs(i18n): retarget the code anchor after the em dash sweep changed its slug * docs(i18n): name the localStorage key the detector actually writes The page said the resolved language is cached in localStorage and left the key implicit, which reads as the library default. It is not: each app writes its own namespaced key, matching everything else it persists. Someone clearing a stuck language, writing a handoff, or deploying a second i18next app on the same origin needs the actual name. * docs(i18n): describe the localization the code actually ships Catalogs are named for specific cultures (pt-BR), there is no culture cookie, only the UI culture is negotiated, the default is LocalizationOptions:DefaultCulture or en-US, module catalogs carry MessageKey/ResourceSource, and the dashboard switcher sends If-Match. * docs(i18n): note the container's invariant globalization and the PredefinedCulturesOnly setting * docs(i18n): only English must be complete; parity fails on orphans and placeholder drift * docs(i18n): non-default languages load on demand, one chunk per language * docs(i18n): exceptions attach keys with .Localized and missing keys fail the build Follows fullstackhero/dotnet-starter-kit#1382 after review: the four-line MessageKey/ResourceSource initializer is replaced by .Localized<TResource>(key, args) on CustomException, and a new Architecture.Tests guard fails the build on a key the code references but its catalog lacks. The internationalization page and the i18n changelog entry show the new form and describe the guard. * docs(i18n): frontend keys are typed and the lint blocks untranslated JSX text * docs(i18n): list the breaking changes for existing projects * docs(changelog): date the i18n entry for its merge and link the upgrade notes --------- Co-authored-by: iammukeshm <iammukeshm@gmail.com>
1 parent f67fcc9 commit 904bac6

4 files changed

Lines changed: 269 additions & 5 deletions

File tree

‎src/content/docs/changelog/index.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Notable changes to the kit, newest first.
1313

1414
## 2026-11-03
1515

16+
- **Internationalization (i18n): the kit is now localized end-to-end, with per-user language and `en-US` / `pt-BR` included.** A user's language lives on their account (`User.Locale`, a nullable BCP 47 tag) and rides on the JWT as a `locale` claim, so a signed-in user gets the same language in the UI and in server-produced messages across any browser. **Backend** localizes through `IStringLocalizer` (a shared `SharedResources` catalog in the Core building block plus one `{Module}Resources` catalog per module, each an English neutral resx and a `*.pt-BR.resx` named for the specific culture; `SupportedCultures.Tags` lists only specific tags, so a bare `pt` or an unsupported variant such as `pt-PT` resolves to the default culture) and a `RequestLocalization` pipeline (`AddHeroLocalization` / `UseHeroLocalization`) whose culture-resolution chain tries, in order, a `?culture=` query override, the JWT `locale` claim, `Accept-Language`, and the default culture (`LocalizationOptions:DefaultCulture` when it is supported, otherwise `en-US`). Only the UI culture is negotiated: `CultureInfo.CurrentCulture` stays invariant, so server-side formatting never shifts per caller and message arguments must be culture-insensitive. Exceptions carry a resource key attached with `.Localized<TResource>(key, args)` (or `.Localized(key, args)` for the shared catalog) and validators resolve through `IStringLocalizer`, so problem-details, FluentValidation and 401 challenge messages come out in the resolved culture. **Both React apps** localize with `react-i18next` + `i18next-browser-languagedetector` (JSON catalogs per namespace under `src/locales/<locale>/`, detection cached in `localStorage` under `fsh.admin.lng` / `fsh.dashboard.lng`), send the active language on `apiFetch`'s `Accept-Language`, format dates/numbers/currency locale-aware, and expose a topbar **language switcher** that persists the choice to `User.Locale` through `PUT /identity/profile` and refreshes the token. Per-deployment default is set via `LocalizationOptions:DefaultCulture` (backend) and `config.json` `defaultLanguage` (front-ends). Because a localized `detail` can no longer be pattern-matched, `GlobalExceptionHandler` now emits the exception's `MessageKey` as a **`code` ProblemDetails extension** - a stable, culture-independent discriminator clients branch on instead of the prose. Only the English catalogs have to be complete: a missing `pt-BR` key renders English, and the parity tests fail only on an orphan key or a placeholder that differs, so no contribution has to include Portuguese. A key referenced in code but missing from its catalog fails the build (`LocalizedKeyReferenceTests` on the backend; typed `t()` keys under `tsc -b` in both React apps), and untranslated JSX text in `src/pages` / `src/components` fails `npm run lint` (`eslint-plugin-i18next`). See [Internationalization](/docs/cross-cutting-concerns/internationalization/) and [Error handling](/docs/cross-cutting-concerns/error-handling/#code---the-machine-readable-discriminator). Existing projects should read [Upgrading an existing project](/docs/cross-cutting-concerns/internationalization/#upgrading-an-existing-project), because the ProblemDetails `title`, the `PagedQueryValidator<T>` constructor and the `CustomException` message setters changed. Shipped in [#1381](https://github.com/fullstackhero/dotnet-starter-kit/pull/1381), [#1382](https://github.com/fullstackhero/dotnet-starter-kit/pull/1382), [#1383](https://github.com/fullstackhero/dotnet-starter-kit/pull/1383) and [#1384](https://github.com/fullstackhero/dotnet-starter-kit/pull/1384).
1617
- **Authorization: a route group's `.RequirePermission()` still applies when an endpoint inside it adds its own (fix).** `RequiredPermissionAuthorizationHandler` read only the most specific permission metadata, so `.RequirePermission(b)` on an endpoint replaced `.RequirePermission(a)` on its route group and a user holding only `b` got through. The handler now combines every permission gate on the endpoint and requires all of them, the same all-of rule as `.RequirePermission(a, b)`. A blank gate anywhere in the chain denies the request. No kit endpoint gates a route group today, so nothing changes for the shipped API; your own group-gated endpoints become stricter. See [Authorization](/docs/security/authorization/#applying-a-gate), [#1429](https://github.com/fullstackhero/dotnet-starter-kit/issues/1429) and [#1452](https://github.com/fullstackhero/dotnet-starter-kit/pull/1452).
1718
- **Identity: `PUT /api/v1/identity/profile` no longer has an `email` field (breaking, fix).** `UpdateUserCommand.Email` was validated as an e-mail address but never saved, so a client that sent a new address got a `200` and nothing changed. Changing an e-mail needs a confirmation flow, so the field is removed from the command and its validator rather than wired through. The API's JSON options skip unknown properties, so a client that still sends `email` gets the same `200` and unchanged address as before, but a malformed address is no longer rejected with a `400`. The dashboard's profile save stopped sending it. See [Identity](/docs/modules/identity/), [#1431](https://github.com/fullstackhero/dotnet-starter-kit/issues/1431) and [#1454](https://github.com/fullstackhero/dotnet-starter-kit/pull/1454).
1819
- **Identity: narrowing a permission now revokes it from the built-in roles on existing tenants (fix, behaviour change).** `RolePermissionSyncer` used to only add the permissions missing from `Basic` and `Admin`, so a permission that stopped being `IsBasic`, or that a module stopped registering, stayed on those roles in every tenant that already existed, and the API refuses permission edits on system roles, so it could only be removed by hand in `RoleClaims`. The sync is now authoritative for those two roles: at every API start it also removes each permission claim the catalog no longer grants to the role (the root tenant's `Admin` keeps the `IsRoot` permissions), logs each removal at Warning with the role, the permission and the tenant, and invalidates the permission cache so live sessions lose it on their next request. Only claims of type `permission` are reconciled, and custom roles are untouched. **Upgrade note:** if you narrowed `IsBasic` or removed a permission in your fork, the next start revokes it from `Basic` (or `Admin`) in every existing tenant; check the Warning lines after deploying. If you run another host that registers `IdentityModule` with only some of the other modules (a worker, say), set the new **`PermissionSyncOptions:RemoveRevoked`** to `false` there before upgrading (default `true`), or its partial catalog revokes the missing modules' permissions in every tenant at startup; with it off, that host only adds. A role whose catalog target is empty is never stripped, and an instance that loses a removal race with another one starting at the same time logs it at Information instead of Error. During a rolling deploy, an old-version instance that restarts re-adds a revoked claim until the next start of a new-version instance. See [Authorization](/docs/security/authorization/#the-role-permission-syncer), [hosts that load only some modules](/docs/security/authorization/#hosts-that-load-only-some-modules) and [#1426](https://github.com/fullstackhero/dotnet-starter-kit/pull/1426).

‎src/content/docs/cross-cutting-concerns/error-handling.mdx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Plus `ValidationException` from FluentValidation (returned as 400 with the field
3838
| Exception | Status | Notes |
3939
|---|---|---|
4040
| `FluentValidation.ValidationException` | 400 | Per-field `errors` map in the response |
41-
| `CustomException` (incl. subclasses) | `e.StatusCode` | `title` = exception type name, `detail` = message, `errors` = `ErrorMessages` when present |
41+
| `CustomException` (incl. subclasses) | `e.StatusCode` | `title` = localized title for the status (falls back to the exception type name), `detail` = message, `errors` = `ErrorMessages` when present, `code` = `MessageKey` when set |
4242
| `UnauthorizedAccessException` | 401 | BCL fallback |
4343
| `KeyNotFoundException` | 404 | BCL fallback |
4444
| `BadHttpRequestException` | Its own `StatusCode` (usually 400) | Malformed request - missing required header/param, unreadable or oversized body |
@@ -48,6 +48,24 @@ The `BadHttpRequestException` mapping matters more than it looks: a request to a
4848

4949
Every response also carries `traceId` (the OpenTelemetry trace id, falling back to `HttpContext.TraceIdentifier`) and `correlationId` (the `X-Correlation-ID` request header when present) as ProblemDetails extensions.
5050

51+
### `code` - the machine-readable discriminator
52+
53+
When the thrown exception carries a `MessageKey` (see [Internationalization](/docs/cross-cutting-concerns/internationalization/)), the handler also surfaces that key as a `code` extension:
54+
55+
```json
56+
{
57+
"title": "Forbidden",
58+
"status": 403,
59+
"detail": "This tenant has been deactivated. Contact your administrator.",
60+
"code": "Multitenancy.TenantDeactivated",
61+
"traceId": "4a7d8e1f2c..."
62+
}
63+
```
64+
65+
`detail` is prose rendered under the caller's culture - the same error reads differently for an `Accept-Language: pt-BR` client. **A client that needs to branch on a specific error must key off `code`, never off `detail`.** The dashboard does exactly this: `isTenantDeactivatedError` matches `code === "Multitenancy.TenantDeactivated"` to route the user to the terminal `/tenant-deactivated` screen, and that detection keeps working in every language.
66+
67+
Exceptions thrown without a `MessageKey` omit the property entirely, so its absence is meaningful: there is no stable code to branch on for that error.
68+
5169
## The response shape
5270

5371
```http

‎src/content/docs/cross-cutting-concerns/index.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
---
22
title: Overview
33
lastUpdated: 2026-06-11
4-
description: Platform features that span every module - caching, jobs, observability, idempotency, feature flags, rate limiting, health, realtime, SSE, HTTP resilience, and error handling.
4+
description: Platform features that span every module - caching, jobs, observability, idempotency, feature flags, rate limiting, health, realtime, SSE, HTTP resilience, error handling, and internationalization.
55
sidebar:
66
order: 1
77
pageType: concept
88
seo:
99
title: 'Cross-cutting concerns in .NET 10 - fullstackhero platform'
10-
description: 'The eleven platform-level features that wire once and apply across every module in fullstackhero - caching, jobs, observability, idempotency, feature…'
10+
description: 'The twelve platform-level features that wire once and apply across every module in fullstackhero - caching, jobs, observability, idempotency, feature…'
1111
keywords: 'cross-cutting concerns .net 10, platform features asp.net core, hybridcache hangfire opentelemetry, idempotency feature flags'
1212
---
1313

14-
Eleven platform features span every module in fullstackhero. They wire once at the host layer, then any module consumes them through the same interfaces and conventions - no per-module bespoke setup.
14+
Twelve platform features span every module in fullstackhero. They wire once at the host layer, then any module consumes them through the same interfaces and conventions - no per-module bespoke setup.
1515

1616
<Callout type="tip" title="Toggle what you need">
1717
The kit's `FshPlatformOptions` (passed to `AddHeroPlatform`) toggle each optional sub-system. Caching, jobs, mailing, feature flags, SSE, realtime, and quotas are **off by default** - opt in to what you need. CORS, OpenAPI, OpenTelemetry, idempotency, and global exception handling are on by default because the cost is negligible.
1818
</Callout>
1919

20-
## The eleven concerns
20+
## The twelve concerns
2121

2222
<SectionIndex section="cross-cutting-concerns" />
2323

0 commit comments

Comments
 (0)