diff --git a/.agents/rules/frontend/shared.md b/.agents/rules/frontend/shared.md
index 9974e44e35..6ebd4fc94a 100644
--- a/.agents/rules/frontend/shared.md
+++ b/.agents/rules/frontend/shared.md
@@ -72,6 +72,17 @@ So "neutrals must be chroma 0" is a **dashboard** rule. Admin neutrals are inten
`src/realtime/realtime-context.tsx`: one `HubConnection` to `/api/v1/realtime/hub`. `@microsoft/signalr` is **dynamically imported** (lazy ~37KB) only when an authed session opens the hub. Auth via `accessTokenFactory`; a `tokenEpoch` (bumped by `tokenStore.subscribe`) forces reconnect on login/refresh/impersonation. Consume with `useRealtimeEvent("EventName", handler, deps)` (handler kept in a ref). Pre-register new event names in the provider's event list.
+## Translations (i18next)
+
+Every string a user reads goes through `t()`; English and Portuguese ship today. The backend negotiates its own messages from the `Accept-Language` that `apiFetch` sends, so ProblemDetails text is shown as-is (`.agents/rules/localization.md`).
+
+- **One namespace per area**: `src/locales/en-US/{ns}.json`, registered with one line in the catalog map in `src/i18n.ts` (`CATALOGS` in admin, `catalogs` in dashboard). `src/locales/pt-BR/{ns}.json` is optional: `src/locales/pt-BR.ts` picks every file up into one lazy chunk, and a missing key renders in English.
+- **Keys are flat and dotted** inside the file (`"header.title"`, `"create.validation.url"`). Interpolate with `{{name}}`; counts use `_one` / `_other` plus `{{count, number}}` and are called with `{ count }`.
+- **In a component**: `const { t } = useTranslation("ns")`. Another namespace through the list form, `useTranslation(["activity", "audits"])` with `t("audits:…")`, or through a second translator when a helper requires one bound to it (`const { t: tCommon } = useTranslation("common")` for `describeUploadError`). Never prefix a key with the namespace the translator is already bound to.
+- **Keys are typed.** `src/i18next.d.ts` types every key against the en-US catalogs, so add the key to the JSON first: a key the namespace lacks fails `tsc -b`. A helper that receives the translator takes `TFunction<"ns">`; a key stored in data (nav items, tabs, enum labels) is `ParseKeys<"ns">`, and an enum label goes through a map typed against the enum (`satisfies Record>`) rather than a template key, so a new member without a label stops compiling. Billing and impersonation statuses have their maps in `src/lib/status-keys.ts`, read through `statusKey()`, which returns `undefined` for a status added after the build so the caller can show the raw value; `tests/i18n/status-keys.spec.ts` checks every member of the backend enum against those maps and both catalogs. A template key still compiles when the call passes `defaultValue`, but its key is not checked; a few remain.
+- **The lint blocks untranslated JSX text** (`i18next/no-literal-string`, `jsx-text-only`, in `src/pages` and `src/components`). Text inside ``/``, glyph-only runs, the product name and the build tag are allowed by pattern in `eslint.config.js`; anything else is moved to a catalog, never silenced with `eslint-disable`. It does not see attribute values (`aria-label`, `placeholder`, `title`), strings outside JSX (`toast.success("…")`, zod messages), or JSX outside those two folders, so those stay a review item.
+- **Tests:** `tests/i18n/parity.spec.ts` fails on a pt-BR key en-US lacks or on different `{{variables}}` between the two; a pt-BR key may be missing.
+
## Testing (Playwright, route-mocked)
- `playwright.config.ts`: `testDir: ./tests`, chromium, auto-boots `npm run dev`, no real backend.
@@ -83,7 +94,7 @@ So "neutrals must be chroma 0" is a **dashboard** rule. Admin neutrals are inten
## Add a page/feature (shared steps)
1. API: extend `src/api/{feature}.ts` — hand-written types + `apiFetch` calls.
-2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`.
+2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`. Text through `t()` from the area's namespace, with the keys added to `src/locales/en-US/{ns}.json` (see Translations).
3. Route: add `const X = lazyNamed(() => import("@/pages/area/name"), "XPage")` and a child route under `AppShell`.
4. Test: `tests/{area}/{name}.spec.ts` with seed + shell mocks + page mocks.
diff --git a/.agents/skills/add-react-page/SKILL.md b/.agents/skills/add-react-page/SKILL.md
index 49d9d0d4f9..b25f2411da 100644
--- a/.agents/skills/add-react-page/SKILL.md
+++ b/.agents/skills/add-react-page/SKILL.md
@@ -47,10 +47,28 @@ export async function create{Resource}(input: Create{Resource}Input) {
(dashboard: inline `type PagedResponse = …`, inline the path, camelCase params, mutations often return `Promise`.)
-## Step 2 — Page (`src/pages/{area}/...tsx`, named export)
+## Step 2 — Catalog keys (`src/locales/en-US/{ns}.json`)
+
+Every string the page shows is a key in its area's namespace. Add the English entries **before** using
+them: keys are typed against the en-US catalogs, so `t("{resources}.title")` fails `tsc -b` until the
+key exists. A new area gets a new `{ns}.json` plus one line in the catalog map in `src/i18n.ts`.
+`src/locales/pt-BR/{ns}.json` is optional; a missing key renders in English.
+
+```json
+{
+ "{resources}.title": "{Resources}",
+ "{resources}.count_one": "{{count, number}} {resource}",
+ "{resources}.count_other": "{{count, number}} {resources}",
+ "{resources}.created": "{Resource} created.",
+ "{resources}.createFailed": "Could not create the {resource}."
+}
+```
+
+## Step 3 — Page (`src/pages/{area}/...tsx`, named export)
```tsx
export function {Resource}ListPage() {
+ const { t } = useTranslation("{ns}");
const [search, setSearch] = useState(""); // debounce → reset page to 1 on change
const [pageNumber, setPage] = useState(1);
const query = useQuery({
@@ -58,11 +76,12 @@ export function {Resource}ListPage() {
queryFn: () => search{Resources}({ pageNumber, search: search || undefined }),
placeholderData: keepPreviousData,
});
- // render with components/ui/* + components/list/* (admin: PageHeader/Field…; dashboard: Entity* family)
+ // render with components/ui/* + components/list/* (admin: PageHeader/Field…; dashboard: Entity* family),
+ // every visible string through t():
{t("{resources}.title")}
}
```
-## Step 3 — Mutation (race-safe `mutate(arg)`)
+## Step 4 — Mutation (race-safe `mutate(arg)`)
Pass per-call data through `mutate(arg)`; read it from the callback `variables` — never from a closed-over render variable.
@@ -70,8 +89,9 @@ Pass per-call data through `mutate(arg)`; read it from the callback `variables`
const qc = useQueryClient();
const createMut = useMutation({
mutationFn: (input: Create{Resource}Input) => create{Resource}(input),
- onSuccess: () => { toast.success("Created"); qc.invalidateQueries({ queryKey: ["{resources}"] }); },
- onError: (e) => toast.error(e instanceof ApiRequestError ? e.message : "Failed"),
+ onSuccess: () => { toast.success(t("{resources}.created")); qc.invalidateQueries({ queryKey: ["{resources}"] }); },
+ // ProblemDetails text is already in the user's language: apiFetch sends Accept-Language.
+ onError: (e) => toast.error(e instanceof ApiRequestError ? e.message : t("{resources}.createFailed")),
});
// admin: const form = useForm({ resolver: zodResolver(schema) }); form.handleSubmit(v => createMut.mutate(v))
// dashboard: controlled useState fields; onSubmit(e){ e.preventDefault(); createMut.mutate(payload); }
@@ -79,7 +99,7 @@ const createMut = useMutation({
If you need to track the in-flight item (e.g. a per-row busy state), use `onMutate: (arg) => setBusyId(arg)` reading the `mutate(arg)` value (pattern: `admin/src/pages/settings/sessions.tsx`).
-## Step 4 — Register the route (`routes.tsx`)
+## Step 5 — Register the route (`routes.tsx`)
```tsx
const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage");
@@ -89,11 +109,11 @@ const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Reso
{ path: "{area}/{resources}", element: withSuspense(<{Resource}ListPage />) },
```
-## Step 5 — (admin only) mirror the permission
+## Step 6 — (admin only) mirror the permission
Add the constant to `src/lib/permissions.ts` (`{Module}Permissions.{Resources}.View` = `"Permissions.{Resources}.View"`), and a `PERMISSION_CATALOG` entry if it belongs in the Role editor. See `add-permission`.
-## Step 6 — Playwright test (`tests/{area}/{resource}.spec.ts`)
+## Step 7 — Playwright test (`tests/{area}/{resource}.spec.ts`)
```ts
test.beforeEach(async ({ page }) => {
@@ -105,17 +125,22 @@ test.beforeEach(async ({ page }) => {
Use `mockProblemDetails(...)` for error states. Dashboard: scope row assertions with `.last()` / dialog scoping (lists render mobile + desktop copies → strict-mode double match).
-## Step 7 — Verify
+## Step 8 — Verify
```bash
-cd clients/{app} && npm run lint && npm run test:e2e
+cd clients/{app} && npm run lint && npm run build && npm run test:e2e
```
+`lint` fails on untranslated JSX text in `src/pages` / `src/components`; `build` (`tsc -b`) fails on a key its
+namespace lacks. Neither sees a literal in `aria-label`, `placeholder`, `title` or a `toast("…")`: check those by
+hand (`.agents/rules/frontend/shared.md`, Translations).
+
## Checklist
- [ ] API module: hand-written types, `apiFetch`, correct param casing per app (Pascal=admin, camel=dashboard)
+- [ ] English keys in `src/locales/en-US/{ns}.json` (pt-BR optional); every visible string, toast and `aria-label` through `t()`
- [ ] Page is a **named export**; `useQuery` key hierarchical + `placeholderData: keepPreviousData`
- [ ] Mutation passes data via `mutate(arg)`, invalidates in `onSuccess`
- [ ] Route via `lazyNamed`; admin wraps in ``, dashboard in `withSuspense`
- [ ] (admin) permission mirrored in `lib/permissions.ts`
-- [ ] Playwright test: seed + shell mocks + page mocks; `lint` + `test:e2e` green
+- [ ] Playwright test: seed + shell mocks + page mocks; `lint` + `build` + `test:e2e` green
diff --git a/AGENTS.md b/AGENTS.md
index c76d8a5724..930276719d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -136,7 +136,7 @@ records for DTOs/events/value objects · `default!` for required non-nullable st
- **Feature** — Contracts command/query → handler → validator → endpoint → wire in module `MapEndpoints()` → tests. Details: `api-conventions.md`.
- **Module** — new `Modules.{Name}` + `.Contracts`, implement `IModule` w/ assembly-level `[assembly: FshModule(typeof(XModule), order)]`, register in **all four places**, add migration folder + tests. Details: `architecture.md`.
-- **React page** — API module (`src/api/`) → page → register lazy route → (admin) mirror permission + RouteGuard → Playwright test. Details: `frontend/shared.md`.
+- **React page** — API module (`src/api/`) → English keys in `src/locales/en-US/{ns}.json` → page (`t()`) → register lazy route → (admin) mirror permission + RouteGuard → Playwright test. Details: `frontend/shared.md`.
## AI tooling resources
diff --git a/clients/admin/docker/config.json.template b/clients/admin/docker/config.json.template
index e3d5dc73d3..b6a6d3084d 100644
--- a/clients/admin/docker/config.json.template
+++ b/clients/admin/docker/config.json.template
@@ -1,5 +1,6 @@
{
"apiBase": "${FSH_API_URL}",
"defaultTenant": "${FSH_DEFAULT_TENANT}",
- "dashboardUrl": "${FSH_DASHBOARD_URL}"
+ "dashboardUrl": "${FSH_DASHBOARD_URL}",
+ "defaultLanguage": "${FSH_DEFAULT_LANGUAGE}"
}
diff --git a/clients/admin/docker/docker-entrypoint.sh b/clients/admin/docker/docker-entrypoint.sh
index 8e6bce4f3a..1d42317dbd 100644
--- a/clients/admin/docker/docker-entrypoint.sh
+++ b/clients/admin/docker/docker-entrypoint.sh
@@ -5,10 +5,12 @@ set -e
: "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}"
: "${FSH_DASHBOARD_URL:?FSH_DASHBOARD_URL is required (e.g. https://app.example.com)}"
-# Defaults for non-required values.
+# Defaults for non-required values. The language default matches the one the bundle falls back to,
+# so an unset variable and an absent config.json land on the same UI language.
: "${FSH_DEFAULT_TENANT:=root}"
+: "${FSH_DEFAULT_LANGUAGE:=en-US}"
-export FSH_API_URL FSH_DASHBOARD_URL FSH_DEFAULT_TENANT
+export FSH_API_URL FSH_DASHBOARD_URL FSH_DEFAULT_TENANT FSH_DEFAULT_LANGUAGE
# Render the runtime config into nginx's web root on every start. The template
# lives outside the web root, so it is never served and survives a restart.
diff --git a/clients/admin/eslint.config.js b/clients/admin/eslint.config.js
index 083fdcba77..9a2c928375 100644
--- a/clients/admin/eslint.config.js
+++ b/clients/admin/eslint.config.js
@@ -2,6 +2,10 @@ import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import jsxA11y from 'eslint-plugin-jsx-a11y';
+import i18next from 'eslint-plugin-i18next';
+// The plugin exports no defaults object; this is 6.x's internal path, and an upgrade that moves it
+// fails the lint at import rather than silently dropping the defaults.
+import i18nextDefaults from 'eslint-plugin-i18next/lib/options/defaults.js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
@@ -31,4 +35,31 @@ export default tseslint.config(
'jsx-a11y/label-has-associated-control': ['error', { depth: 3 }],
},
},
+ {
+ // Text rendered in JSX goes through t(): an unwrapped literal would ship in English to every
+ // language. Scoped to where the UI lives; tests sit outside src/ and are not linted for this.
+ files: ['src/pages/**/*.tsx', 'src/components/**/*.tsx'],
+ plugins: { i18next },
+ rules: {
+ 'i18next/no-literal-string': [
+ 'error',
+ {
+ mode: 'jsx-text-only',
+ // Code identifiers and key caps are not prose.
+ 'jsx-components': { exclude: [...i18nextDefaults['jsx-components'].exclude, 'code', 'kbd'] },
+ // The plugin replaces, not merges, a list it is given, so its defaults are spread back in.
+ words: {
+ exclude: [
+ ...i18nextDefaults.words.exclude,
+ // Separators, units and glyphs (·, %, /, ✓) carry no words to translate.
+ /^[\p{P}\p{S}\p{N}\s]+$/u,
+ // The product name and the build tag stay as they are in every language.
+ /^(fullstack|hero|fullstackhero)$/,
+ /^v\d+(\.\d+)*( · admin)?$/,
+ ],
+ },
+ },
+ ],
+ },
+ },
);
diff --git a/clients/admin/package-lock.json b/clients/admin/package-lock.json
index 0ea88ccd42..53f9f86059 100644
--- a/clients/admin/package-lock.json
+++ b/clients/admin/package-lock.json
@@ -18,11 +18,14 @@
"@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "i18next": "^26.3.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
+ "react-i18next": "^17.0.10",
"react-router-dom": "^7.18.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
@@ -37,6 +40,7 @@
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
+ "eslint-plugin-i18next": "^6.1.5",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18",
@@ -284,6 +288,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -3913,6 +3926,19 @@
}
}
},
+ "node_modules/eslint-plugin-i18next": {
+ "version": "6.1.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.5.tgz",
+ "integrity": "sha512-xCTfstbK9ZpQ6UFT5S1s6zbZMhKn2o2jRSQYiU2UkV7wt8y1m3WjkUYGkGlipgRdFInYI9+LAqE+JQU/L73HmA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "requireindex": "~1.1.0"
+ },
+ "engines": {
+ "node": ">=18.10.0"
+ }
+ },
"node_modules/eslint-plugin-jsx-a11y": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
@@ -4523,6 +4549,52 @@
"node": ">= 0.4"
}
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
+ "node_modules/i18next": {
+ "version": "26.3.6",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
+ "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5914,6 +5986,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.10",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
+ "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -6090,6 +6189,16 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
+ "node_modules/requireindex": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz",
+ "integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.5"
+ }
+ },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -6725,7 +6834,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -6887,6 +6996,15 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
@@ -6962,6 +7080,15 @@
}
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
diff --git a/clients/admin/package.json b/clients/admin/package.json
index af28898016..9e6c68e3b8 100644
--- a/clients/admin/package.json
+++ b/clients/admin/package.json
@@ -22,11 +22,14 @@
"@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "i18next": "^26.3.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
+ "react-i18next": "^17.0.10",
"react-router-dom": "^7.18.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
@@ -41,6 +44,7 @@
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
+ "eslint-plugin-i18next": "^6.1.5",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18",
diff --git a/clients/admin/playwright.config.ts b/clients/admin/playwright.config.ts
index 31ecc38abc..4e4cee173b 100644
--- a/clients/admin/playwright.config.ts
+++ b/clients/admin/playwright.config.ts
@@ -18,6 +18,11 @@ export default defineConfig({
actionTimeout: 10_000,
navigationTimeout: 15_000,
},
+ // Assertions get the same budget as actions. Left at the 5s default they were
+ // the tightest deadline in the suite — every test ends in a toBeVisible, and
+ // under CPU contention the first paint of a lazy route lands past 5s while
+ // staying well inside the action and navigation budgets.
+ expect: { timeout: 10_000 },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "npm run dev",
diff --git a/clients/admin/public/config.json b/clients/admin/public/config.json
index 015dc0c1a2..c760e9d765 100644
--- a/clients/admin/public/config.json
+++ b/clients/admin/public/config.json
@@ -3,5 +3,6 @@
"defaultTenant": "root",
"dashboardUrl": "http://localhost:5174",
"inactivityIdleMs": 600000,
- "inactivityWarningMs": 60000
+ "inactivityWarningMs": 60000,
+ "defaultLanguage": "en-US"
}
diff --git a/clients/admin/src/App.tsx b/clients/admin/src/App.tsx
index f8b80fc335..bb6f4f875c 100644
--- a/clients/admin/src/App.tsx
+++ b/clients/admin/src/App.tsx
@@ -2,6 +2,7 @@ import { Suspense } from "react";
import { RouterProvider } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
+import { useTranslation } from "react-i18next";
import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2 } from "lucide-react";
import { queryClient } from "@/lib/query-client";
import { AuthProvider } from "@/auth/auth-context";
@@ -10,6 +11,7 @@ import { ThemeProvider, useTheme } from "@/components/theme/theme-provider";
import { router } from "@/routes";
export function App() {
+ const { t } = useTranslation("common");
return (
@@ -22,7 +24,7 @@ export function App() {
fallback={
}
diff --git a/clients/admin/src/api/billing.ts b/clients/admin/src/api/billing.ts
index 2a0941fd77..a99a8ab583 100644
--- a/clients/admin/src/api/billing.ts
+++ b/clients/admin/src/api/billing.ts
@@ -1,5 +1,7 @@
+import type { ParseKeys } from "i18next";
import { apiFetch, ApiRequestError } from "@/lib/api-client";
import type { PagedResponse } from "@/lib/api-types";
+import { INVOICE_STATUS_KEYS, statusKey } from "@/lib/status-keys";
import { env } from "@/env";
import { tokenStore } from "@/auth/token-store";
@@ -7,6 +9,11 @@ import { tokenStore } from "@/auth/token-store";
export type InvoiceStatus = "Draft" | "Issued" | "Paid" | "Void" | (string & {});
+/** The `billing` catalog key for a status, or undefined for one the server added after this build. */
+export function invoiceStatusKey(status: InvoiceStatus): ParseKeys<"billing"> | undefined {
+ return statusKey(INVOICE_STATUS_KEYS, status);
+}
+
export type SubscriptionStatus = "Active" | "Suspended" | "Cancelled" | (string & {});
export type InvoiceLineItemKind = "BaseFee" | "Overage" | "Adjustment" | (string & {});
diff --git a/clients/admin/src/api/users.ts b/clients/admin/src/api/users.ts
index e082822225..fefb6a750c 100644
--- a/clients/admin/src/api/users.ts
+++ b/clients/admin/src/api/users.ts
@@ -12,6 +12,8 @@ export type UserDto = {
phoneNumber?: string | null;
imageUrl?: string | null;
twoFactorEnabled?: boolean;
+ /** Persisted BCP 47 UI language tag (e.g. "pt-BR"); null when the user never chose. */
+ locale?: string | null;
};
export type UserRoleDto = {
@@ -76,6 +78,39 @@ export async function setProfileImage(imageUrl: string | null): Promise {
});
}
+export type UpdateMyProfileInput = {
+ firstName?: string | null;
+ lastName?: string | null;
+ phoneNumber?: string | null;
+ /** BCP 47 UI language tag persisted on the user (drives the JWT locale claim). */
+ locale?: string | null;
+};
+
+/**
+ * Self-update of the authenticated user's profile (PUT /identity/profile,
+ * server forces the id to the caller). The backend sets FirstName/LastName
+ * unconditionally from the command, so a partial update (e.g. the language
+ * switcher sending only `locale`) would wipe the others. Reads the current
+ * profile from the server first and merges, so any field the caller omits
+ * keeps its persisted value instead of being nulled — never trust a possibly
+ * stale/undefined client-side snapshot for the echoed fields.
+ */
+export async function updateMyProfile(input: UpdateMyProfileInput): Promise {
+ const profile = await getMyProfile();
+ await apiFetch(`${IDENTITY}/profile`, {
+ method: "PUT",
+ body: JSON.stringify({
+ id: profile.id,
+ firstName: input.firstName ?? profile.firstName ?? null,
+ lastName: input.lastName ?? profile.lastName ?? null,
+ phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null,
+ locale: input.locale ?? profile.locale ?? null,
+ email: profile.email,
+ deleteCurrentImage: false,
+ }),
+ });
+}
+
export async function changePassword(input: {
password: string;
newPassword: string;
diff --git a/clients/admin/src/api/wallet.ts b/clients/admin/src/api/wallet.ts
index c2e46a4d38..f575135d7e 100644
--- a/clients/admin/src/api/wallet.ts
+++ b/clients/admin/src/api/wallet.ts
@@ -3,11 +3,14 @@ import type { PagedResponse } from "@/lib/api-types";
// ─── shared enums ────────────────────────────────────────────────────
+// Mirrors TopupRequestStatus in Modules.Billing.Contracts/BillingEnums.cs. "Approved" was never
+// one of them: approving a request moves it to Invoiced.
export type TopupRequestStatus =
| "Pending"
- | "Approved"
- | "Rejected"
+ | "Invoiced"
| "Completed"
+ | "Rejected"
+ | "Cancelled"
| (string & {});
// ─── top-up requests ─────────────────────────────────────────────────
diff --git a/clients/admin/src/auth/protected-route.tsx b/clients/admin/src/auth/protected-route.tsx
index 1ebe746496..2cf962b78e 100644
--- a/clients/admin/src/auth/protected-route.tsx
+++ b/clients/admin/src/auth/protected-route.tsx
@@ -1,4 +1,5 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { useAuth } from "@/auth/use-auth";
import { ForbiddenView } from "@/components/forbidden-view";
@@ -14,6 +15,7 @@ type ProtectedRouteProps = {
export function ProtectedRoute({ permissions = [] }: ProtectedRouteProps) {
const { isAuthenticated, isInitializing, user } = useAuth();
const location = useLocation();
+ const { t } = useTranslation("common");
// Resolving a stored session (silent token refresh) — hold rendering so we
// neither flash a protected surface with a stale/expired token nor bounce to
@@ -25,7 +27,7 @@ export function ProtectedRoute({ permissions = [] }: ProtectedRouteProps) {
role="status"
aria-busy="true"
>
- Restoring your session…
+ {t("protectedRoute.restoring")}
- Resolving permissions
+ {t("routeGuard.resolving")}
);
diff --git a/clients/admin/src/components/auth/auth-shell.tsx b/clients/admin/src/components/auth/auth-shell.tsx
index 68981ec30c..9ae418c27e 100644
--- a/clients/admin/src/components/auth/auth-shell.tsx
+++ b/clients/admin/src/components/auth/auth-shell.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
import { BrandMarkXL } from "@/components/brand-mark";
import { cn } from "@/lib/cn";
@@ -44,6 +45,7 @@ export function AuthShell({
/** Form area below the blurb. */
children: ReactNode;
}) {
+ const { t } = useTranslation("common");
return (
{/* ─── Left pane — brand stage ───────────────────────────────── */}
@@ -60,22 +62,22 @@ export function AuthShell({
diff --git a/clients/admin/src/components/forbidden-view.tsx b/clients/admin/src/components/forbidden-view.tsx
index 782a7f69c7..7a9c571041 100644
--- a/clients/admin/src/components/forbidden-view.tsx
+++ b/clients/admin/src/components/forbidden-view.tsx
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { ShieldOff } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
type ForbiddenViewProps = {
@@ -13,6 +14,7 @@ type ForbiddenViewProps = {
* as code-chips so the operator can paste them into a permission grant.
*/
export function ForbiddenView({ missing }: ForbiddenViewProps) {
+ const { t } = useTranslation("common");
return (
- Built-in role — read only
+ {t("detail.builtin.title")}
- {role.name} ships with the framework.
- Its name, description, and permissions are managed centrally. Create a custom role if
- you need a different set of grants.
+ {role.name}{t("detail.builtin.body")}
{searchActive
- ? "Adjust filters or invite a new user."
- : "Register the first member to seed this tenant."}
+ ? t("empty.withFilters")
+ : t("empty.noData")}
@@ -229,17 +233,17 @@ function Row({
variant={sub.isActive ? "success" : "muted"}
className="font-mono uppercase tracking-[0.14em]"
>
- {sub.isActive ? "Active" : "Inactive"}
+ {sub.isActive ? t("badge.active") : t("badge.inactive")}
- Test
+ {t("list.test")}
diff --git a/clients/admin/tests/i18n/fallback-chain.spec.ts b/clients/admin/tests/i18n/fallback-chain.spec.ts
new file mode 100644
index 0000000000..5e758cc430
--- /dev/null
+++ b/clients/admin/tests/i18n/fallback-chain.spec.ts
@@ -0,0 +1,43 @@
+import { createInstance } from "i18next";
+import { expect, test } from "@playwright/test";
+import { fallbackChain, missingKeyFallback } from "../../src/lib/i18n-fallback";
+
+// A pt-BR translation is allowed to lag behind en-US, so a key pt-BR lacks must render the English
+// string. The trap is a deployment whose default is pt-BR: a fallback chain of just the deployment
+// default falls back from pt-BR to pt-BR, misses again, and the missing-key handler renders a
+// capitalized key segment instead. Runs against the real i18next with the app's own handler.
+const SUPPORTED = ["en-US", "pt-BR"] as const;
+
+const instance = createInstance({
+ lng: "pt-BR",
+ fallbackLng: fallbackChain("pt-BR", SUPPORTED),
+ supportedLngs: [...SUPPORTED],
+ ns: ["users"],
+ defaultNS: "users",
+ resources: {
+ "en-US": { users: { title: "Users", greeting: "Hello" } },
+ "pt-BR": { users: { title: "Usuários" } },
+ },
+ interpolation: { escapeValue: false },
+ parseMissingKeyHandler: missingKeyFallback,
+});
+
+test.beforeAll(async () => {
+ await instance.init();
+});
+
+test.describe("fallback chain", () => {
+ test("a key pt-BR lacks renders in English on a pt-BR deployment", () => {
+ expect(instance.t("greeting")).toBe("Hello");
+ });
+
+ test("a translated key still renders in pt-BR", () => {
+ expect(instance.t("title")).toBe("Usuários");
+ });
+
+ test("the chain always ends in en-US, without repeating it", () => {
+ expect(fallbackChain("pt-BR", SUPPORTED)).toEqual(["pt-BR", "en-US"]);
+ expect(fallbackChain("en-US", SUPPORTED)).toBe("en-US");
+ expect(fallbackChain("fr-FR", SUPPORTED)).toBe("en-US");
+ });
+});
diff --git a/clients/admin/tests/i18n/format.spec.ts b/clients/admin/tests/i18n/format.spec.ts
new file mode 100644
index 0000000000..44baa50c93
--- /dev/null
+++ b/clients/admin/tests/i18n/format.spec.ts
@@ -0,0 +1,204 @@
+import { expect, test } from "@playwright/test";
+import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
+import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks";
+import { mockJsonResponse } from "../helpers/api-mocks";
+
+// Task 11 — locale-aware Intl formatters (src/lib/format.ts). Exercised through the
+// real Vite module graph (dynamic import over the dev server) so the `@/i18n`
+// alias and i18n wiring match production. Explicit-locale calls assert
+// locale-correct grouping/currency; null/invalid inputs assert the fallbacks.
+
+test.beforeEach(async ({ page }) => {
+ await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] });
+ await installAdminShellMocks(page);
+ await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 0 }));
+ await mockJsonResponse(page, "**/api/v1/billing/plans**", []);
+ await mockJsonResponse(page, "**/api/v1/billing/invoices**", paged([], { totalCount: 0 }));
+});
+
+test.describe("format.ts", () => {
+ test("formatters render per explicit locale and fall back safely", async ({ page }) => {
+ await page.goto("/");
+ await expect(
+ page.getByRole("button", { name: /open profile menu/i }),
+ ).toBeVisible({ timeout: 10_000 });
+
+ const out = await page.evaluate(async () => {
+ // Resolved by the Vite dev server in the browser, not by the bundler or by
+ // tsc — a served URL, not a module specifier. Kept in a variable so the
+ // typechecker doesn't try (and fail) to resolve it from disk.
+ const formatModuleUrl = "/src/lib/format.ts";
+ const m = await import(/* @vite-ignore */ formatModuleUrl);
+ return {
+ curPt: m.formatCurrency(1234.5, "BRL", "pt-BR"),
+ curEn: m.formatCurrency(1234.5, "BRL", "en-US"),
+ numPt: m.formatNumber(1234567.89, "pt-BR"),
+ numEn: m.formatNumber(1234567.89, "en-US"),
+ datePt: m.formatDate("2026-01-15T12:00:00Z", "pt-BR"),
+ dateEn: m.formatDate("2026-01-15T12:00:00Z", "en-US"),
+ dateNull: m.formatDate(null),
+ dateBad: m.formatDate("not-a-date"),
+ curBad: m.formatCurrency(10, "NOTACUR", "en-US"),
+ };
+ });
+
+ // Currency: BRL symbol + locale-specific grouping/decimal separators.
+ expect(out.curPt).toContain("R$");
+ expect(out.curPt).toContain("1.234,50");
+ expect(out.curEn).toContain("1,234.50");
+
+ // Number grouping differs by locale.
+ expect(out.numPt).toBe("1.234.567,89");
+ expect(out.numEn).toBe("1,234,567.89");
+
+ // Date renders per locale and carries the year; the two locales differ.
+ expect(out.datePt).toContain("2026");
+ expect(out.dateEn).toContain("2026");
+ expect(out.datePt).not.toBe(out.dateEn);
+
+ // Documented fallbacks: nullish -> em dash, unparseable -> echo, bad currency -> amount + code.
+ expect(out.dateNull).toBe("—");
+ expect(out.dateBad).toBe("not-a-date");
+ expect(out.curBad).toBe("10.00 NOTACUR");
+ });
+
+ // Every assertion above passes an explicit locale, so `resolveLocale` — the branch every
+ // production call site actually takes — was never exercised: replacing it with `() => "en-US"`
+ // kept the spec green while pt-BR users saw 1,234,567.89.
+ test("formatters follow the active locale with no locale argument", async ({ page }) => {
+ await page.goto("/?culture=pt-BR");
+ // The profile-menu label is itself translated, so wait on something language-neutral.
+ await expect(page.locator("html")).toHaveAttribute("lang", "pt-BR");
+
+ const out = await page.evaluate(async () => {
+ const formatModuleUrl = "/src/lib/format.ts";
+ const m = await import(/* @vite-ignore */ formatModuleUrl);
+ return {
+ num: m.formatNumber(1234567.89),
+ date: m.formatDate("2026-01-15T12:00:00Z"),
+ };
+ });
+
+ expect(out.num).toBe("1.234.567,89");
+ expect(out.date).toContain("2026");
+ expect(out.date).not.toMatch(/Jan\b/);
+ });
+
+ test("a list count is grouped and pluralized by the locale, not by English rules", async ({ page }) => {
+ // Two separate defects met in this one chip. The count reached i18next as a raw number, so a
+ // Portuguese UI read "1234" beside currency and dates that were correctly grouped. And the
+ // header pluralized by appending "s" to the unit, which is an English rule: "organização" + "s"
+ // is not a word. Both are the catalog's job now, so both are asserted on the rendered chip.
+ await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 1234, pageSize: 20 }));
+
+ await page.goto("/tenants?culture=pt-BR");
+ await expect(page.locator("html")).toHaveAttribute("lang", "pt-BR");
+
+ await expect(page.getByText("1.234 organizações", { exact: true })).toBeVisible();
+ await expect(page.getByText("1.234 organizações registradas nesta instância.")).toBeVisible();
+ });
+
+ // `resolveLocale` reads `i18n.language` at call time, so a date formatted on a previous render
+ // keeps the old locale until something re-renders the component. Nothing in format.ts subscribes
+ // to `languageChanged`; what makes the switch reach a mounted list is that every component that
+ // formats also calls useTranslation, and that subscription is easy to drop in a refactor with no
+ // test noticing. Driving the real switcher against an already-rendered date is what notices.
+ test("reformats a date already on screen when the language changes under it", async ({ page }) => {
+ let invoiceReads = 0;
+ await page.route("**/api/v1/billing/invoices?*", async (route) => {
+ if (route.request().method() !== "GET") {
+ await route.fallback();
+ return;
+ }
+ invoiceReads += 1;
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(
+ paged([
+ {
+ id: "inv-1",
+ tenantId: "acme",
+ invoiceNumber: "INV-2026-0001",
+ periodYear: 2026,
+ periodMonth: 5,
+ currency: "USD",
+ subtotalAmount: 129.5,
+ status: "Draft",
+ // Midday UTC so the rendered day is May 1 whatever timezone the runner sits in.
+ createdAtUtc: "2026-05-01T12:00:00Z",
+ issuedAtUtc: null,
+ dueAtUtc: null,
+ paidAtUtc: null,
+ voidedAtUtc: null,
+ notes: null,
+ lineItems: [],
+ },
+ ]),
+ ),
+ });
+ });
+
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ if (route.request().method() === "PUT") {
+ await route.fulfill({ status: 200 });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ });
+ // The token re-mint is covered by switcher.spec.ts and is not what this asserts; it still has
+ // to succeed, because the failure path clears the session and takes the list off screen.
+ await page.route("**/api/v1/identity/token/refresh", async (route) => {
+ const b64url = (obj: unknown) =>
+ btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
+ const now = Math.floor(Date.now() / 1000);
+ const token = [
+ b64url({ alg: "HS256", typ: "JWT" }),
+ b64url({
+ sub: "u-test-1",
+ email: TEST_USER.email,
+ name: "Root Admin",
+ tenant: "root",
+ locale: "pt-BR",
+ permissions: [...ADMIN_PERMS],
+ exp: now + 3600,
+ iat: now,
+ }),
+ "sig",
+ ].join(".");
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, refreshToken: "fresh-refresh-token" }),
+ });
+ });
+
+ await page.goto("/billing/invoices");
+
+ const main = page.getByRole("main");
+ await expect(main.getByText("INV-2026-0001", { exact: true })).toBeVisible({ timeout: 10_000 });
+ // "May 01, 2026" under en-US. The invoice date is the only date this list renders.
+ await expect(main).toContainText("May");
+
+ await page.getByRole("button", { name: /open profile menu/i }).click();
+ await page.getByRole("menuitem", { name: "Português (BR)" }).click();
+ await expect(page.getByText("Idioma", { exact: true })).toBeVisible();
+
+ // "01 de mai. de 2026". Not a remount: the route never changed and the list was read once.
+ await expect(main).toContainText("mai.");
+ await expect(main).not.toContainText("May");
+ expect(invoiceReads).toBe(1);
+ });
+});
diff --git a/clients/admin/tests/i18n/i18n.spec.ts b/clients/admin/tests/i18n/i18n.spec.ts
new file mode 100644
index 0000000000..d470101a93
--- /dev/null
+++ b/clients/admin/tests/i18n/i18n.spec.ts
@@ -0,0 +1,115 @@
+import { expect, test } from "@playwright/test";
+import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
+import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks";
+import { mockJsonResponse } from "../helpers/api-mocks";
+
+// These specs cover Task 8 (i18n bootstrap) + Task 9 (Accept-Language header).
+// The dashboard route ("/") renders the AppShell + Topbar; the Topbar's profile
+// menu button is a stable "the app mounted" signal. main.tsx awaits initI18n()
+// before mounting React, so a boot failure there would leave nothing to find.
+
+test.beforeEach(async ({ page }) => {
+ await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] });
+ await installAdminShellMocks(page);
+
+ // Dashboard load endpoints (page content); topbar renders regardless, but
+ // mocking these keeps the route quiet and deterministic.
+ await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 0 }));
+ await mockJsonResponse(page, "**/api/v1/billing/plans**", []);
+ await mockJsonResponse(page, "**/api/v1/billing/invoices**", paged([], { totalCount: 0 }));
+});
+
+test.describe("i18n", () => {
+ test("app boots with i18n initialized", async ({ page }) => {
+ await page.goto("/");
+
+ // The Topbar (which consumes the i18n instance for the profile-locale sync)
+ // rendered → initI18n() resolved and React mounted.
+ await expect(
+ page.getByRole("button", { name: /open profile menu/i }),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("apiFetch sends Accept-Language matching the active locale", async ({ page }) => {
+
+ // Registered AFTER installAdminShellMocks so this handler wins (LIFO) and
+ // can inspect the request header. Return a profile whose locale matches the
+ // default active locale so the sync effect does not switch languages.
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ if (route.request().method() !== "GET") {
+ await route.fallback();
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ });
+
+ const profileReq = page.waitForRequest(
+ (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "GET",
+ { timeout: 10_000 },
+ );
+ await page.goto("/");
+ // Read off the resolved request, not off a variable the route handler assigns:
+ // waitForRequest resolves at dispatch, before the handler has run.
+ const headers = (await profileReq).headers();
+
+ expect(headers["accept-language"]).toBe("en-US");
+ });
+
+ test("apiFetch sends Accept-Language: pt-BR once the locale is Portuguese", async ({ page }) => {
+ // Boot the app in Portuguese via the ?culture querystring — the i18n detector gives
+ // querystring top priority (order: ["querystring", ...]), so the active locale is pt-BR
+ // before the first apiFetch runs. A hardcoded "en-US" in apiFetch would fail this.
+
+ // Return a profile whose locale already matches pt-BR so the topbar's sync effect does not
+ // switch the language back.
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ if (route.request().method() !== "GET") {
+ await route.fallback();
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "pt-BR",
+ }),
+ });
+ });
+
+ const profileReq = page.waitForRequest(
+ (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "GET",
+ { timeout: 10_000 },
+ );
+ await page.goto("/?culture=pt-BR");
+ // Read off the resolved request, not off a variable the route handler assigns:
+ // waitForRequest resolves at dispatch, before the handler has run.
+ const headers = (await profileReq).headers();
+
+ expect(headers["accept-language"]).toBe("pt-BR");
+ });
+});
+
+// The `languageChanged` listener that keeps html[lang] in sync had no test: deleting it left the
+// suite green while the PR claimed screen readers and browser translation now see the real
+// language.
+test.describe("document language", () => {
+ test("html[lang] follows the active locale", async ({ page }) => {
+ await page.goto("/?culture=pt-BR");
+ await expect(page.locator("html")).toHaveAttribute("lang", "pt-BR");
+
+ await page.goto("/?culture=en-US");
+ await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
+ });
+});
diff --git a/clients/admin/tests/i18n/lazy-catalog.spec.ts b/clients/admin/tests/i18n/lazy-catalog.spec.ts
new file mode 100644
index 0000000000..e2f8990a7a
--- /dev/null
+++ b/clients/admin/tests/i18n/lazy-catalog.spec.ts
@@ -0,0 +1,101 @@
+import { expect, test } from "@playwright/test";
+import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
+import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks";
+import { mockJsonResponse } from "../helpers/api-mocks";
+
+// Only en-US ships in the main bundle; pt-BR is one lazy chunk (src/locales/pt-BR.ts). That makes
+// two failure modes possible that a static import could not have: a pt-BR user seeing English
+// until the chunk lands, and a catalog that never arrives. In dev the "chunk" is the module request
+// for src/locales/pt-BR.ts and its JSON files; in a build it is assets/pt-BR-.js.
+const PT_BR_CATALOG = /\/(src\/locales\/pt-BR|assets\/pt-BR-)/;
+
+test.describe("lazy pt-BR catalog", () => {
+ test("a pt-BR catalog that fails at boot leaves every language signal on English", async ({ page }) => {
+ await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] });
+ await installAdminShellMocks(page);
+ await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 0 }));
+ await mockJsonResponse(page, "**/api/v1/billing/plans**", []);
+ await mockJsonResponse(page, "**/api/v1/billing/invoices**", paged([], { totalCount: 0 }));
+ await mockJsonResponse(page, "**/api/v1/identity/profile", {
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "pt-BR",
+ });
+ await page.route(PT_BR_CATALOG, (route) => route.abort());
+
+ const profileReq = page.waitForRequest(
+ (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "GET",
+ );
+ await page.goto("/?culture=pt-BR");
+
+ // The text falls back to English, so the document, the API and the switcher must say so too:
+ // a pt-BR lang over English text misleads screen readers and asks the API for Portuguese.
+ await expect(page.getByRole("button", { name: /open profile menu/i })).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
+ expect((await profileReq).headers()["accept-language"]).toBe("en-US");
+ });
+
+ test("a pt-BR visitor gets Portuguese on the first render, never English first", async ({ page }) => {
+ // Record every state the app root goes through, from before the app's own scripts run.
+ await page.addInitScript(() => {
+ const w = window as unknown as { __sawEnglish: boolean };
+ w.__sawEnglish = false;
+ new MutationObserver(() => {
+ if (document.getElementById("root")?.textContent?.includes("Platform Admin")) {
+ w.__sawEnglish = true;
+ }
+ }).observe(document, { childList: true, subtree: true, characterData: true });
+ });
+ // A slow catalog is what turns a missing await into a visible English first paint; on a fast
+ // local dev server the lazy route chunk alone can hide it.
+ await page.route(PT_BR_CATALOG, async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, 750));
+ await route.continue();
+ });
+
+ await page.goto("/login?culture=pt-BR");
+
+ await expect(page.getByText("Administração da Plataforma").first()).toBeVisible();
+ expect(await page.evaluate(() => (window as unknown as { __sawEnglish: boolean }).__sawEnglish)).toBe(false);
+ });
+
+ test("a switch whose catalog fails to load stays in English and saves nothing", async ({ page }) => {
+ await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] });
+ await installAdminShellMocks(page);
+ await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 0 }));
+ await mockJsonResponse(page, "**/api/v1/billing/plans**", []);
+ await mockJsonResponse(page, "**/api/v1/billing/invoices**", paged([], { totalCount: 0 }));
+
+ let profilePuts = 0;
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ if (route.request().method() === "PUT") profilePuts++;
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ });
+ await page.route(PT_BR_CATALOG, (route) => route.abort());
+
+ await page.goto("/?culture=en-US");
+ await page.getByRole("button", { name: /open profile menu/i }).click();
+ await page.getByRole("menuitem", { name: "Português (BR)" }).click();
+
+ await expect(page.getByText("Language not changed")).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
+ expect(await page.evaluate(() => localStorage.getItem("fsh.admin.lng"))).toBe("en-US");
+ expect(profilePuts).toBe(0);
+ });
+});
diff --git a/clients/admin/tests/i18n/missing-key.spec.ts b/clients/admin/tests/i18n/missing-key.spec.ts
new file mode 100644
index 0000000000..7c452e4940
--- /dev/null
+++ b/clients/admin/tests/i18n/missing-key.spec.ts
@@ -0,0 +1,41 @@
+import { createInstance } from "i18next";
+import { expect, test } from "@playwright/test";
+import { missingKeyFallback } from "../../src/lib/i18n-fallback";
+
+// i18next calls parseMissingKeyHandler for a missing key whether or not the call site passed a
+// defaultValue, and the handler's return value is what renders. The first version of this handler
+// only took the key, which quietly turned every `{ defaultValue }` in the app into a truncation of
+// its own key — the permission matrix would have degraded to "Create" instead of "Create users".
+// These run against the real i18next so the contract, not a re-implementation of it, is what holds.
+const instance = createInstance({
+ lng: "pt-BR",
+ fallbackLng: "en-US",
+ resources: {
+ "pt-BR": { translation: { greeting: "Olá" } },
+ "en-US": { translation: {} },
+ },
+ interpolation: { escapeValue: false },
+ parseMissingKeyHandler: missingKeyFallback,
+});
+
+test.beforeAll(async () => {
+ await instance.init();
+});
+
+test.describe("missing key fallback", () => {
+ test("a caller's defaultValue survives the handler", () => {
+ expect(
+ instance.t("perm.entry.Permissions.Users.Create", { defaultValue: "Create users" }),
+ ).toBe("Create users");
+ });
+
+ test("a key built from a server value degrades to its last segment", () => {
+ // No defaultValue to fall back on: `status.${topup.status}` for a status the catalog predates.
+ expect(instance.t("status.invoiced")).toBe("Invoiced");
+ expect(instance.t("billing:status.refunded")).toBe("Refunded");
+ });
+
+ test("a translated key is untouched", () => {
+ expect(instance.t("greeting")).toBe("Olá");
+ });
+});
diff --git a/clients/admin/tests/i18n/parity.spec.ts b/clients/admin/tests/i18n/parity.spec.ts
new file mode 100644
index 0000000000..81ee42e9a6
--- /dev/null
+++ b/clients/admin/tests/i18n/parity.spec.ts
@@ -0,0 +1,70 @@
+import { expect, test } from "@playwright/test";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+
+// en-US is the source catalog; pt-BR is a translation that may lag behind it. A key (or a whole
+// namespace file) pt-BR lacks is fine: it renders in English at runtime (fallback-chain.spec.ts).
+// What breaks the UI is a pt-BR key nothing renders (an orphan, usually a renamed or deleted
+// en-US key) and a translation whose placeholders drift from the English, so those two fail here.
+// Read via fs (cwd = the admin app dir) to avoid ESM JSON import-attribute rules.
+const readCatalog = (locale: string, ns: string): Record =>
+ JSON.parse(readFileSync(path.resolve("src/locales", locale, `${ns}.json`), "utf8"));
+
+// A contributor who adds a namespace without a translation ships no pt-BR file at all.
+const readTranslation = (ns: string): Record => {
+ try {
+ return readCatalog("pt-BR", ns);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
+ throw error;
+ }
+};
+
+const catalogFiles = (locale: string): string[] =>
+ readdirSync(path.resolve("src/locales", locale))
+ .filter((f) => f.endsWith(".json"))
+ .map((f) => f.replace(/\.json$/, ""))
+ .sort();
+
+const namespaces = ["common", "nav", "auth", "settings", "sessions", "users", "roles", "impersonation", "billing", "tenants", "webhooks", "audits", "notifications", "health", "dashboard"];
+
+test.describe("catalog parity", () => {
+ // The list above is hand-maintained, so it can drift from what actually ships: a namespace
+ // file added without an entry here escapes every parity assertion below. This is the
+ // completeness check that makes the hand-maintained list safe to keep.
+ test("the namespace list covers every catalog file on disk", () => {
+ expect(catalogFiles("en-US")).toEqual([...namespaces].sort());
+ });
+
+ test("every pt-BR catalog file has an en-US counterpart", () => {
+ expect(catalogFiles("pt-BR").filter((ns) => !namespaces.includes(ns))).toEqual([]);
+ });
+
+ for (const ns of namespaces) {
+ test(`${ns}: pt-BR has no key en-US lacks`, () => {
+ const en = readCatalog("en-US", ns);
+ const orphans = Object.keys(readTranslation(ns)).filter((key) => !(key in en));
+ expect(orphans).toEqual([]);
+ });
+ }
+
+ // A translation that drops or renames an interpolation renders the placeholder as literal text
+ // ("Olá, {{name}}") or silently loses the value, and neither shows up as a missing key. i18next
+ // resolves `{{name}}` and `{{count, number}}` alike, so the variable name is taken up to the
+ // first comma and the formatter ignored.
+ const placeholders = (value: string): string[] =>
+ [...value.matchAll(/{{\s*([^},]+?)\s*(?:,[^}]*)?}}/g)].map((m) => m[1]).sort();
+
+ for (const ns of namespaces) {
+ test(`${ns}: en-US and pt-BR interpolate the same variables`, () => {
+ const en = readCatalog("en-US", ns);
+ const pt = readTranslation(ns);
+ const divergent = Object.keys(en)
+ .filter((key) => typeof en[key] === "string" && typeof pt[key] === "string")
+ .map((key) => ({ key, en: placeholders(en[key]), pt: placeholders(pt[key]) }))
+ .filter(({ en: a, pt: b }) => a.join("|") !== b.join("|"));
+
+ expect(divergent).toEqual([]);
+ });
+ }
+});
diff --git a/clients/admin/tests/i18n/status-keys.spec.ts b/clients/admin/tests/i18n/status-keys.spec.ts
new file mode 100644
index 0000000000..196cee2b4e
--- /dev/null
+++ b/clients/admin/tests/i18n/status-keys.spec.ts
@@ -0,0 +1,126 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+import { IMPERSONATION_STATUS_KEYS, INVOICE_STATUS_KEYS, statusKey } from "../../src/lib/status-keys";
+
+// Several labels build their catalog key from a value the API sends: `status.${status}`. Catalog
+// parity cannot see those — both locales can be missing the same key and still match — so a status
+// the backend emits and the catalog never learned about renders as the key itself. That is exactly
+// what shipped for top-ups: approving one moves it to `Invoiced`, and the badge read
+// "status.invoiced".
+//
+// The member list is read from the backend enum rather than copied here, so adding a member to the
+// C# enum without translating it fails this test instead of reaching a screen. Where the app maps a
+// status to its key (src/lib/status-keys.ts) the test reads that map, so a member the map lacks fails
+// too; where it still builds the key from the value, the test applies the same transformation.
+
+const repoRoot = path.resolve("../..");
+
+function enumMembers(relativePath: string, enumName: string): string[] {
+ const source = readFileSync(path.join(repoRoot, relativePath), "utf8");
+ const declaration = new RegExp(`enum\\s+${enumName}\\s*\\{([^}]*)\\}`, "s").exec(source);
+ expect(declaration, `${enumName} not found in ${relativePath}`).not.toBeNull();
+ const members = [...declaration![1].matchAll(/^\s*([A-Z]\w*)\s*(?:=\s*-?\d+\s*)?,?\s*$/gm)].map(
+ (m) => m[1],
+ );
+ expect(members.length, `${enumName} parsed as empty`).toBeGreaterThan(0);
+ return members;
+}
+
+const readCatalog = (locale: string, ns: string): Record =>
+ JSON.parse(readFileSync(path.resolve("src/locales", locale, `${ns}.json`), "utf8"));
+
+/** The transformation the top-up call sites apply: first letter lowered, rest kept. */
+const camel = (member: string) => `status.${member.charAt(0).toLowerCase()}${member.slice(1)}`;
+
+/** Looks a member up in the app's map; a member it lacks has no key. */
+const mapped = (keys: Record) => (member: string) =>
+ Object.hasOwn(keys, member) ? keys[member] : `(no entry for ${member} in src/lib/status-keys.ts)`;
+
+const CASES = [
+ {
+ label: "top-up requests",
+ ns: "billing",
+ file: "src/Modules/Billing/Modules.Billing.Contracts/BillingEnums.cs",
+ enumName: "TopupRequestStatus",
+ key: camel,
+ },
+ {
+ label: "invoices",
+ ns: "billing",
+ file: "src/Modules/Billing/Modules.Billing.Contracts/BillingEnums.cs",
+ enumName: "InvoiceStatus",
+ key: mapped(INVOICE_STATUS_KEYS),
+ },
+ {
+ label: "impersonation grants",
+ ns: "impersonation",
+ file: "src/Modules/Identity/Modules.Identity.Contracts/v1/Impersonation/ImpersonationGrantDto.cs",
+ enumName: "ImpersonationGrantStatus",
+ key: mapped(IMPERSONATION_STATUS_KEYS),
+ },
+];
+
+test.describe("status keys built from backend enums", () => {
+ for (const testCase of CASES) {
+ test(`${testCase.label}: every member has a label in both locales`, () => {
+ const members = enumMembers(testCase.file, testCase.enumName);
+ const en = readCatalog("en-US", testCase.ns);
+ const pt = readCatalog("pt-BR", testCase.ns);
+
+ const missing = members
+ .map((member) => testCase.key(member))
+ .filter((key) => !(key in en) || !(key in pt));
+
+ expect(missing).toEqual([]);
+ });
+ }
+});
+
+test("a status the server added after this build has no key, so the badge shows the raw value", () => {
+ expect(statusKey(IMPERSONATION_STATUS_KEYS, "Suspended")).toBeUndefined();
+ expect(statusKey(INVOICE_STATUS_KEYS, "constructor")).toBeUndefined();
+ expect(statusKey(IMPERSONATION_STATUS_KEYS, "Active")).toBe("status.active");
+});
+
+// The permission matrix is the other place keys are built at run time, and the largest: eight
+// groups and thirty-plus entries, rendered on the role detail screen. The English text stays in
+// permissions.ts as the fallback, so a missing key degrades rather than showing "perm.entry.x" —
+// but degrading means an English row in a Portuguese table, which is what this catches.
+test.describe("permission catalog keys", () => {
+ test("every group and entry has a label in both locales", () => {
+ const source = readFileSync(path.resolve("src/lib/permissions.ts"), "utf8");
+ const groups = [...source.matchAll(/key: "([^"]+)",/g)].map((m) => m[1]);
+ // The catalog holds the permission VALUE at run time ("Permissions.Users.Create"), not the
+ // identifier it is written with, so resolve the constants the same way the app does.
+ const values = new Map();
+ for (const constant of source.matchAll(
+ /export const (\w+) = Object\.freeze\(\{([\s\S]*?)\r?\n\} as const\);/g,
+ )) {
+ for (const group of constant[2].matchAll(/(\w+): \{([^}]*)\}/g)) {
+ for (const entry of group[2].matchAll(/(\w+): "([^"]+)"/g)) {
+ values.set(`${constant[1]}.${group[1]}.${entry[1]}`, entry[2]);
+ }
+ }
+ for (const entry of constant[2].matchAll(/^ {2}(\w+): "([^"]+)",/gm)) {
+ values.set(`${constant[1]}.${entry[1]}`, entry[2]);
+ }
+ }
+ const entries = [...source.matchAll(/name: ([A-Za-z.]+),\s*description:/g)].map((m) => {
+ const value = values.get(m[1]);
+ expect(value, `${m[1]} does not resolve to a permission string`).toBeDefined();
+ return value!;
+ });
+ expect(groups.length).toBeGreaterThan(0);
+ expect(entries.length).toBeGreaterThan(0);
+
+ const en = readCatalog("en-US", "roles");
+ const pt = readCatalog("pt-BR", "roles");
+ const expected = [
+ ...groups.flatMap((key) => [`perm.group.${key}`, `perm.blurb.${key}`]),
+ ...entries.map((name) => `perm.entry.${name}`),
+ ];
+
+ expect(expected.filter((key) => !(key in en) || !(key in pt))).toEqual([]);
+ });
+});
diff --git a/clients/admin/tests/i18n/switcher.spec.ts b/clients/admin/tests/i18n/switcher.spec.ts
new file mode 100644
index 0000000000..604cebc5c4
--- /dev/null
+++ b/clients/admin/tests/i18n/switcher.spec.ts
@@ -0,0 +1,404 @@
+import { expect, test } from "@playwright/test";
+import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
+import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks";
+import { mockJsonResponse } from "../helpers/api-mocks";
+
+// Task 10 — the topbar language switcher. Switching to Português must:
+// (a) localize the UI in place (the "Language" section label becomes "Idioma"),
+// (b) PUT the chosen locale to /identity/profile, and
+// (c) trigger a token refresh so the new `locale` JWT claim is minted.
+
+/** Minimal decodable JWT for the refreshed session (auth-context decodes it). */
+function fakeJwt(payload: Record): string {
+ const b64url = (obj: unknown) =>
+ btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
+ return [b64url({ alg: "HS256", typ: "JWT" }), b64url(payload), "sig"].join(".");
+}
+
+test.beforeEach(async ({ page }) => {
+ await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] });
+ await installAdminShellMocks(page);
+
+ await mockJsonResponse(page, "**/api/v1/tenants**", paged([], { totalCount: 0 }));
+ await mockJsonResponse(page, "**/api/v1/billing/plans**", []);
+ await mockJsonResponse(page, "**/api/v1/billing/invoices**", paged([], { totalCount: 0 }));
+});
+
+test.describe("language switcher", () => {
+ test("switching to Português localizes the UI, persists the locale and refreshes the token", async ({
+ page,
+ }) => {
+ let refreshCalled = false;
+
+ // GET returns the current (en-US) profile with a name so we can assert it is
+ // preserved; the PUT body is read off the resolved request below (race-free);
+ // both methods share this one route (LIFO wins).
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ const method = route.request().method();
+ if (method === "PUT") {
+ await route.fulfill({ status: 200 });
+ return;
+ }
+ if (method === "GET") {
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ return;
+ }
+ await route.fallback();
+ });
+
+ // The onSuccess token refresh — capture the call and return a fresh session
+ // carrying the new locale claim so the auth context stays valid.
+ await page.route("**/api/v1/identity/token/refresh", async (route) => {
+ refreshCalled = true;
+ const token = fakeJwt({
+ sub: "u-test-1",
+ email: TEST_USER.email,
+ name: "Root Admin",
+ tenant: "root",
+ locale: "pt-BR",
+ permissions: [...ADMIN_PERMS],
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ });
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, refreshToken: "fresh-refresh-token" }),
+ });
+ });
+
+ await page.goto("/");
+
+ // Open the profile dropdown, then the (default en-US) language section.
+ await page.getByRole("button", { name: /open profile menu/i }).click();
+ await expect(page.getByText("Language", { exact: true })).toBeVisible();
+
+ const putRequest = page.waitForRequest(
+ (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "PUT",
+ );
+ await page.getByRole("menuitem", { name: "Português (BR)" }).click();
+ // Read the body straight off the resolved request — waitForRequest fires on
+ // dispatch, before the route handler could capture it into a shared variable.
+ const putBody = (await putRequest).postDataJSON() as {
+ locale?: string;
+ firstName?: string;
+ lastName?: string;
+ };
+
+ // (b) the chosen locale was persisted, name preserved (no data loss).
+ expect(putBody.locale).toBe("pt-BR");
+ expect(putBody.firstName).toBe("Root");
+ expect(putBody.lastName).toBe("Admin");
+
+ // (a) the section label localized in place (menu kept open on select).
+ await expect(page.getByText("Idioma", { exact: true })).toBeVisible();
+
+ // (c) the token refresh fired to re-mint the locale claim.
+ await expect.poll(() => refreshCalled).toBe(true);
+ });
+
+ // Regression (data-loss): the PUT body must be built from a fresh server read
+ // inside updateMyProfile, NOT from the topbar's ["identity","profile"] query
+ // snapshot. If that query is still pending (or failed) when the user switches
+ // language, the old code sent firstName/lastName = undefined and the backend
+ // wiped the name. We gate every GET so the profile is provably NOT loaded in
+ // the component at click time, then release it and assert the PUT still
+ // carries the name.
+ test("preserves firstName/lastName even when the profile query has not loaded", async ({
+ page,
+ }) => {
+ // A gate held closed until we've already clicked the language item, so at
+ // click time no GET has resolved — profile.data in the topbar is undefined.
+ let releaseGet: () => void = () => {};
+ const getGate = new Promise((resolve) => {
+ releaseGet = resolve;
+ });
+
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ const method = route.request().method();
+ if (method === "PUT") {
+ await route.fulfill({ status: 200 });
+ return;
+ }
+ if (method === "GET") {
+ await getGate;
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ return;
+ }
+ await route.fallback();
+ });
+
+ await page.route("**/api/v1/identity/token/refresh", async (route) => {
+ const token = fakeJwt({
+ sub: "u-test-1",
+ email: TEST_USER.email,
+ name: "Root Admin",
+ tenant: "root",
+ locale: "pt-BR",
+ permissions: [...ADMIN_PERMS],
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ });
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, refreshToken: "fresh-refresh-token" }),
+ });
+ });
+
+ await page.goto("/");
+
+ // The dropdown and language list render from i18n/SUPPORTED, not the profile,
+ // so the menu is usable while the (gated) profile GET is still pending.
+ await page.getByRole("button", { name: /open profile menu/i }).click();
+ await expect(page.getByText("Language", { exact: true })).toBeVisible();
+
+ const putRequest = page.waitForRequest(
+ (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "PUT",
+ );
+ await page.getByRole("menuitem", { name: "Português (BR)" }).click();
+
+ // Only now let the profile reads resolve: updateMyProfile's own GET feeds the PUT.
+ releaseGet();
+ // Read the body straight off the resolved request (race-free — waitForRequest
+ // fires on dispatch, before the route handler would have captured anything).
+ const putBody = (await putRequest).postDataJSON() as {
+ locale?: string;
+ firstName?: string;
+ lastName?: string;
+ };
+
+ expect(putBody.locale).toBe("pt-BR");
+ expect(putBody.firstName).toBe("Root");
+ expect(putBody.lastName).toBe("Admin");
+ });
+
+ // Regression (session loss): the server rotates the refresh token on every refresh, so a
+ // second refresh started while the first is still open sends a token the server has already
+ // spent. It comes back 401, and apiFetch's failure path calls tokenStore.clear() — the
+ // operator is signed out mid-work, silently, because the switcher swallows the error. Two
+ // language switches in a row are enough to line that up.
+ test("two quick switches issue one token refresh and keep the session", async ({ page }) => {
+ let refreshCount = 0;
+
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ if (route.request().method() === "PUT") {
+ await route.fulfill({ status: 200 });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ });
+
+ await page.route("**/api/v1/identity/token/refresh", async (route) => {
+ refreshCount += 1;
+ if (refreshCount > 1) {
+ // The server's view of a replayed refresh token: already rotated, so 401.
+ await route.fulfill({ status: 401, body: "" });
+ return;
+ }
+ // Hold the first refresh open long enough for the second switch to land while
+ // it is still in flight — the whole point of the single-flight.
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ const token = fakeJwt({
+ sub: "u-test-1",
+ email: TEST_USER.email,
+ name: "Root Admin",
+ tenant: "root",
+ permissions: [...ADMIN_PERMS],
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ });
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, refreshToken: "fresh-refresh-token" }),
+ });
+ });
+
+ await page.goto("/");
+
+ await page.getByRole("button", { name: /open profile menu/i }).click();
+ await expect(page.getByText("Language", { exact: true })).toBeVisible();
+
+ await page.getByRole("menuitem", { name: "Português (BR)" }).click();
+ await expect(page.getByText("Idioma", { exact: true })).toBeVisible();
+ await page.getByRole("menuitem", { name: "English (US)" }).click();
+ await expect(page.getByText("Language", { exact: true })).toBeVisible();
+
+ // Let the held refresh resolve and anything it triggers settle.
+ await page.waitForTimeout(3000);
+
+ expect(refreshCount).toBe(1);
+ // Still signed in: a cleared token store routes the app to /login.
+ expect(new URL(page.url()).pathname).not.toBe("/login");
+ expect(
+ await page.evaluate(() => window.localStorage.getItem("fsh.admin.accessToken")),
+ ).not.toBeNull();
+ });
+
+ // Regression (session loss), the single-switch case: before the fix refreshAccessToken() cleared
+ // the token store on ANY non-ok response, so one dead refresh token — revoked, rotated in another
+ // tab, dropped by a reseed — signed the operator out for choosing a language.
+ test("a failed token re-mint keeps the session and the new language", async ({ page }) => {
+ let refreshCalled = false;
+
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ if (route.request().method() === "PUT") {
+ await route.fulfill({ status: 200 });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ });
+
+ await page.route("**/api/v1/identity/token/refresh", async (route) => {
+ refreshCalled = true;
+ await route.fulfill({ status: 401, body: "" });
+ });
+
+ await page.goto("/");
+ await page.getByRole("button", { name: /open profile menu/i }).click();
+ await page.getByRole("menuitem", { name: "Português (BR)" }).click();
+
+ await expect.poll(() => refreshCalled).toBe(true);
+ // The redirect this guards against is a client-side route change with no network of its own,
+ // so it cannot be awaited directly; networkidle lets the failed refresh settle first.
+ await page.waitForLoadState("networkidle");
+
+ expect(new URL(page.url()).pathname).not.toBe("/login");
+ await expect(page.getByText("Idioma", { exact: true })).toBeVisible();
+ expect(
+ await page.evaluate(() => window.localStorage.getItem("fsh.admin.accessToken")),
+ ).not.toBeNull();
+ });
+
+ // Regression (silent data change): Identity compares the incoming phone number to the stored one
+ // with a plain string compare and calls SetPhoneNumberAsync on any difference, which clears
+ // PhoneNumberConfirmed. The switcher sends a whole profile, so the phone it echoes has to be the
+ // one the server just gave it, unchanged - "" and null are not interchangeable here, and neither
+ // is a trimmed variant. Nothing asserted that, so a normalization added anywhere on this path
+ // would un-confirm a verified phone number for choosing a language, with no error and no log.
+ for (const stored of [null, "", "+1 555 0142"] as const) {
+ test(`echoes the stored phone number (${JSON.stringify(stored)}) unchanged on a language switch`, async ({
+ page,
+ }) => {
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ if (route.request().method() === "PUT") {
+ await route.fulfill({ status: 200 });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: stored,
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ });
+ await page.route("**/api/v1/identity/token/refresh", (route) =>
+ route.fulfill({ status: 500, body: "" }),
+ );
+
+ await page.goto("/");
+ await page.getByRole("button", { name: /open profile menu/i }).click();
+
+ const putRequest = page.waitForRequest(
+ (r) => r.url().includes("/api/v1/identity/profile") && r.method() === "PUT",
+ );
+ await page.getByRole("menuitem", { name: "Português (BR)" }).click();
+ const putBody = (await putRequest).postDataJSON() as { phoneNumber?: string | null };
+
+ expect(putBody.phoneNumber).toBe(stored);
+ });
+ }
+});
+
+// The UI switches on click and the save is what can fail. Without an onError the language
+// silently reverts on the next fresh mount, which reads as the app forgetting the choice.
+test.describe("language switcher when the save fails", () => {
+ test("the save failure is surfaced to the user", async ({ page }) => {
+ await page.route("**/api/v1/identity/profile", async (route) => {
+ if (route.request().method() === "PUT") {
+ await route.fulfill({
+ status: 500,
+ headers: { "Content-Type": "application/problem+json" },
+ body: JSON.stringify({ status: 500, title: "Server Error" }),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: "u-test-1",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "",
+ isActive: true,
+ emailConfirmed: true,
+ locale: "en-US",
+ }),
+ });
+ });
+ await page.goto("/");
+ await page.getByRole("button", { name: /open profile menu/i }).click();
+ await page.getByRole("menuitem", { name: "Português (BR)" }).click();
+ // The switch still applies locally…
+ await expect(page.getByText("Idioma", { exact: true })).toBeVisible();
+ // …and the user is told it did not stick.
+ await expect(page.getByText("Idioma não salvo")).toBeVisible();
+ });
+});
\ No newline at end of file
diff --git a/clients/admin/tests/i18n/upload-errors.spec.ts b/clients/admin/tests/i18n/upload-errors.spec.ts
new file mode 100644
index 0000000000..fbc8eb0b70
--- /dev/null
+++ b/clients/admin/tests/i18n/upload-errors.spec.ts
@@ -0,0 +1,95 @@
+import { expect, test } from "@playwright/test";
+import { mockJsonResponse } from "../helpers/api-mocks";
+import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
+import { installAdminShellMocks, ADMIN_PERMS } from "../helpers/shell-mocks";
+
+// Upload failures are raised deep in the hook, where no translator is in scope, so they travel as
+// catalog keys on an UploadError and are resolved at the toast by describeUploadError. Nothing in
+// the suite walked that path end to end: the keys could be missing, namespaced wrong, or carry the
+// wrong interpolation params and every spec stayed green while a Portuguese operator read English.
+//
+// The avatar editor on /settings/profile is the admin mount of ImageInput, and its picker is an
+// input created in JS and clicked programmatically — reachable through the filechooser event, not
+// through a selector.
+
+const PROFILE = {
+ id: "u-test-1",
+ userName: "rootadmin",
+ email: "admin@root.com",
+ firstName: "Root",
+ lastName: "Admin",
+ phoneNumber: "+1 555 0142",
+ isActive: true,
+ emailConfirmed: true,
+ twoFactorEnabled: false,
+ imageUrl: null,
+};
+
+const STORAGE_URL = "https://storage.test.invalid/bucket/avatar.png";
+
+const PRESIGNED = {
+ fileAssetId: "fa-1",
+ uploadUrl: STORAGE_URL,
+ requiredHeaders: {},
+ expiresAt: "2026-05-01T12:15:00Z",
+};
+
+/** A one-pixel PNG, so the extension and size checks in the hook let the upload start. */
+const PNG_BYTES = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ "base64",
+);
+
+async function pickAvatar(page: import("@playwright/test").Page) {
+ await page.goto("/settings/profile?culture=pt-BR");
+ await expect(page.locator("html")).toHaveAttribute("lang", "pt-BR");
+
+ const chooser = page.waitForEvent("filechooser");
+ await page.getByRole("button", { name: "Escolher imagem" }).click();
+ await (await chooser).setFiles({ name: "avatar.png", mimeType: "image/png", buffer: PNG_BYTES });
+}
+
+test.beforeEach(async ({ page }) => {
+ await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] });
+ await installAdminShellMocks(page);
+ await mockJsonResponse(page, "**/api/v1/identity/profile", PROFILE);
+});
+
+test.describe("upload failures are localized", () => {
+ test("a storage PUT that never connects reports a network error in Portuguese", async ({
+ page,
+ }) => {
+ await mockJsonResponse(page, "**/api/v1/files/upload-url", PRESIGNED);
+ // Aborting is what xhr.onerror is for; the hook turns it into the common upload.networkError key.
+ await page.route(STORAGE_URL, (route) => route.abort());
+
+ await pickAvatar(page);
+
+ await expect(page.getByText("Erro de rede durante o envio.")).toBeVisible();
+ });
+
+ test("a storage PUT the bucket rejects reports the status it came back with", async ({ page }) => {
+ await mockJsonResponse(page, "**/api/v1/files/upload-url", PRESIGNED);
+ // The interpolated {{status}} is the half a key-only assertion would miss: the catalog entry
+ // renders fine with the placeholder empty, and the operator loses the only diagnostic.
+ await page.route(STORAGE_URL, (route) => route.fulfill({ status: 403, body: "" }));
+
+ await pickAvatar(page);
+
+ await expect(page.getByText(/^Envio recusado pelo armazenamento \(403/)).toBeVisible();
+ });
+
+ test("a presign request that never reaches the API falls back to the catalog, not to the platform", async ({
+ page,
+ }) => {
+ // apiFetch does not wrap fetch, so this surfaces as TypeError("Failed to fetch"). Returning
+ // e.message put that English string on screen, ahead of the localized fallback the caller had
+ // already passed in; the catalog string is what belongs there.
+ await page.route("**/api/v1/files/upload-url", (route) => route.abort());
+
+ await pickAvatar(page);
+
+ await expect(page.getByText("Falha no envio")).toBeVisible();
+ await expect(page.getByText(/Failed to fetch/)).toHaveCount(0);
+ });
+});
diff --git a/clients/admin/tests/impersonation/handoff-locale.spec.ts b/clients/admin/tests/impersonation/handoff-locale.spec.ts
new file mode 100644
index 0000000000..ba5da26fff
--- /dev/null
+++ b/clients/admin/tests/impersonation/handoff-locale.spec.ts
@@ -0,0 +1,126 @@
+import { expect, test } from "@playwright/test";
+import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
+import { installAdminShellMocks, ADMIN_PERMS } from "../helpers/shell-mocks";
+import { mockJsonResponse } from "../helpers/api-mocks";
+
+// The PRODUCER half of the cross-app impersonation handoff.
+//
+// The dashboard side is pinned by clients/dashboard/tests/impersonation/handoff-locale.spec.ts,
+// but nothing asserted that this app actually PUTS the operator's locale in the URL —
+// dropping `params.set("locale", …)` left every suite green. The dashboard cannot recover
+// the operator's language on its own: the server strips the target's `locale` claim, and
+// the two apps normally sit on different origins, and each persists its language under its own
+// key (`fsh.admin.lng` / `fsh.dashboard.lng`), so neither can read the other's.
+//
+// window.open is stubbed rather than allowed to open a tab: the handoff URL is the thing
+// under test, and the real dashboard origin is not served in this suite.
+
+/** An Active grant whose actor IS the seeded operator — required for Re-open to render. */
+const OWN_ACTIVE_GRANT = {
+ id: "g-active-1",
+ jti: "jti-active-1",
+ actorUserId: TEST_USER.sub,
+ actorUserName: "rootadmin",
+ actorTenantId: "root",
+ impersonatedUserId: "u-target",
+ impersonatedUserName: "alice@acme.com",
+ impersonatedTenantId: "acme",
+ reason: "Investigating a support ticket",
+ startedAtUtc: "2026-05-23T10:00:00Z",
+ expiresAtUtc: "2026-05-23T11:00:00Z",
+ status: "Active",
+};
+
+declare global {
+ interface Window {
+ __openedUrls?: string[];
+ }
+}
+
+test.beforeEach(async ({ page }) => {
+ await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] });
+ await installAdminShellMocks(page);
+ await mockJsonResponse(page, "**/api/v1/identity/impersonation/grants*", [OWN_ACTIVE_GRANT]);
+
+ await page.route("**/api/v1/identity/impersonation/start", async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ accessToken: "header.payload.sig",
+ accessTokenExpiresAt: "2026-05-23T11:00:00Z",
+ }),
+ });
+ });
+
+ await page.addInitScript(() => {
+ window.__openedUrls = [];
+ window.open = (url?: string | URL) => {
+ window.__openedUrls!.push(String(url ?? ""));
+ return null;
+ };
+ });
+});
+
+/**
+ * Drives Re-open → reason → start. Re-open pre-fills the user so the picker step is
+ * skipped, which keeps this focused on the handoff instead of the search flow.
+ */
+async function startImpersonationViaReopen(
+ page: import("@playwright/test").Page,
+ labels: { reopen: string; reason: string; start: string },
+) {
+ const main = page.getByRole("main");
+ await expect(main.getByText("alice@acme.com", { exact: true })).toBeVisible({ timeout: 10_000 });
+
+ await main.getByRole("button", { name: labels.reopen }).first().click();
+
+ const dialog = page.getByRole("dialog");
+ await dialog.getByLabel(labels.reason).fill("Customer ticket 4821");
+ await dialog.getByRole("button", { name: labels.start }).click();
+}
+
+function handoffParams(url: string): URLSearchParams {
+ const marker = "#impersonate?";
+ const at = url.indexOf(marker);
+ expect(at, `handoff URL is not an #impersonate hash handoff: ${url}`).toBeGreaterThan(-1);
+ return new URLSearchParams(url.slice(at + marker.length));
+}
+
+test.describe("impersonation handoff carries the operator locale", () => {
+ test("sends the operator's selected language, not the deployment default", async ({ page }) => {
+ // `?culture=` is first in the detection order (lookupQuerystring: "culture"), so this
+ // boots the app in Portuguese without touching localStorage.
+ await page.goto("/impersonation?culture=pt-BR");
+
+ await startImpersonationViaReopen(page, {
+ reopen: "Reabrir",
+ reason: "Motivo",
+ start: "Iniciar personificação de 15 min",
+ });
+
+ await expect.poll(() => page.evaluate(() => window.__openedUrls?.length ?? 0)).toBe(1);
+ const opened = (await page.evaluate(() => window.__openedUrls![0]))!;
+ const params = handoffParams(opened);
+
+ expect(params.get("locale")).toBe("pt-BR");
+ // The pre-existing contract must survive the added parameter.
+ expect(params.get("token")).toBe("header.payload.sig");
+ expect(params.get("tenant")).toBe("acme");
+ expect(params.get("expiresAt")).toBe("2026-05-23T11:00:00Z");
+ });
+
+ test("sends en-US when the operator is reading in English", async ({ page }) => {
+ await page.goto("/impersonation?culture=en-US");
+
+ await startImpersonationViaReopen(page, {
+ reopen: "Re-open",
+ reason: "Reason",
+ start: "Start 15-min impersonation",
+ });
+
+ await expect.poll(() => page.evaluate(() => window.__openedUrls?.length ?? 0)).toBe(1);
+ const opened = (await page.evaluate(() => window.__openedUrls![0]))!;
+ expect(handoffParams(opened).get("locale")).toBe("en-US");
+ });
+});
diff --git a/clients/admin/tsconfig.json b/clients/admin/tsconfig.json
index 1ffef600d9..33df56c7e5 100644
--- a/clients/admin/tsconfig.json
+++ b/clients/admin/tsconfig.json
@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.tests.json" }
]
}
diff --git a/clients/admin/tsconfig.tests.json b/clients/admin/tsconfig.tests.json
new file mode 100644
index 0000000000..d5f3741d2e
--- /dev/null
+++ b/clients/admin/tsconfig.tests.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "erasableSyntaxOnly": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "types": ["node"]
+ },
+ "include": ["tests", "playwright.config.ts"]
+}
diff --git a/deploy/terraform/apps/starter/app_stack/main.tf b/deploy/terraform/apps/starter/app_stack/main.tf
index 65c8fd28a2..73ad13287d 100644
--- a/deploy/terraform/apps/starter/app_stack/main.tf
+++ b/deploy/terraform/apps/starter/app_stack/main.tf
@@ -244,7 +244,8 @@ module "admin_site" {
apiBase = local.api_origin
defaultTenant = var.frontend_default_tenant
# The admin app links to the tenant dashboard for the impersonation handoff.
- dashboardUrl = local.dashboard_url
+ dashboardUrl = local.dashboard_url
+ defaultLanguage = var.frontend_default_language
}
tags = local.common_tags
diff --git a/deploy/terraform/apps/starter/app_stack/variables.tf b/deploy/terraform/apps/starter/app_stack/variables.tf
index 788a23e18d..5b87bcbe2c 100644
--- a/deploy/terraform/apps/starter/app_stack/variables.tf
+++ b/deploy/terraform/apps/starter/app_stack/variables.tf
@@ -720,6 +720,12 @@ variable "frontend_default_tenant" {
default = "root"
}
+variable "frontend_default_language" {
+ type = string
+ description = "Default UI language (BCP 47) baked into the SPA runtime config.json. The i18n fallback when the user has chosen nothing and the browser advertises no supported language."
+ default = "en-US"
+}
+
variable "dashboard_demo_mode" {
type = bool
description = "Set the dashboard SPA into demo mode via its runtime config.json."