- 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/format.spec.ts b/clients/admin/tests/i18n/format.spec.ts
new file mode 100644
index 0000000000..34355f48af
--- /dev/null
+++ b/clients/admin/tests/i18n/format.spec.ts
@@ -0,0 +1,64 @@
+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");
+ });
+});
diff --git a/clients/admin/tests/i18n/i18n.spec.ts b/clients/admin/tests/i18n/i18n.spec.ts
new file mode 100644
index 0000000000..3399b90fa3
--- /dev/null
+++ b/clients/admin/tests/i18n/i18n.spec.ts
@@ -0,0 +1,102 @@
+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 }) => {
+ let seenLang: string | null = null;
+
+ // 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;
+ }
+ seenLang = route.request().headers()["accept-language"] ?? null;
+ 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("/");
+ await profileReq;
+
+ expect(seenLang).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.
+ let seenLang: string | null = null;
+
+ // 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;
+ }
+ seenLang = route.request().headers()["accept-language"] ?? null;
+ 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");
+ await profileReq;
+
+ expect(seenLang).toBe("pt-BR");
+ });
+});
diff --git a/clients/admin/tests/i18n/parity.spec.ts b/clients/admin/tests/i18n/parity.spec.ts
new file mode 100644
index 0000000000..7f6ad47f1a
--- /dev/null
+++ b/clients/admin/tests/i18n/parity.spec.ts
@@ -0,0 +1,35 @@
+import { expect, test } from "@playwright/test";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+
+// A missing/extra key in one locale silently falls back to the key or the other
+// locale at runtime. Assert both catalogs expose the identical key set per
+// namespace so a half-translated string is caught at test time, not in the UI.
+// 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"));
+
+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 and can go out
+ // half-translated with the suite green. This is the completeness check that makes the
+ // hand-maintained list safe to keep.
+ test("the namespace list covers every catalog file on disk", () => {
+ const onDisk = readdirSync(path.resolve("src/locales", "en-US"))
+ .filter((f) => f.endsWith(".json"))
+ .map((f) => f.replace(/\.json$/, ""))
+ .sort();
+
+ expect(onDisk).toEqual([...namespaces].sort());
+ });
+
+ for (const ns of namespaces) {
+ test(`${ns}: en-US and pt-BR expose the same keys`, () => {
+ const en = Object.keys(readCatalog("en-US", ns)).sort();
+ const pt = Object.keys(readCatalog("pt-BR", ns)).sort();
+ expect(en).toEqual(pt);
+ });
+ }
+});
diff --git a/clients/admin/tests/i18n/switcher.spec.ts b/clients/admin/tests/i18n/switcher.spec.ts
new file mode 100644
index 0000000000..fcf7877e90
--- /dev/null
+++ b/clients/admin/tests/i18n/switcher.spec.ts
@@ -0,0 +1,199 @@
+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");
+ });
+});
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..cc54453f77
--- /dev/null
+++ b/clients/admin/tests/impersonation/handoff-locale.spec.ts
@@ -0,0 +1,125 @@
+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 so `i18nextLng` is not shared.
+//
+// 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/src/Directory.Packages.props b/src/Directory.Packages.props
index 0d38b28190..7674befa8f 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -143,5 +143,12 @@
AccessViolation). Transitive pinning is enabled, so this entry alone bumps it.
Remove once the SignalR backplane package depends on a patched version itself. -->
+
+
\ No newline at end of file
From 71dd6fb84e18902b13a29bb0eed5fab22a828072 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Wed, 16 Sep 2026 14:22:13 -0300
Subject: [PATCH 02/16] feat(admin): emit defaultLanguage in the Docker and
Terraform runtime config
env.ts reads defaultLanguage from config.json, but neither supported deployment
path emitted it: the Docker template and the Terraform runtime_config carried
only apiBase, defaultTenant and dashboardUrl. Every production deployment
therefore fell back to en-US and the advertised per-deployment default language
was configurable only in the Vite development file.
The entrypoint defaults the variable to en-US so an unset value and an absent
config.json land on the same language, and initI18n already drops an
unsupported tag back to en-US.
---
clients/admin/docker/config.json.template | 3 ++-
clients/admin/docker/docker-entrypoint.sh | 6 ++++--
deploy/terraform/apps/starter/app_stack/main.tf | 3 ++-
deploy/terraform/apps/starter/app_stack/variables.tf | 6 ++++++
4 files changed, 14 insertions(+), 4 deletions(-)
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 3908a3444a..f6dd2dab14 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 from the template, writing into nginx's web root.
envsubst < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json
diff --git a/deploy/terraform/apps/starter/app_stack/main.tf b/deploy/terraform/apps/starter/app_stack/main.tf
index ee4eece47d..14717b0a39 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 5d218b50d0..54bae36471 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."
From 764ff1b2fef0485620e48f9100e7f9e5de23ca1d Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Mon, 14 Sep 2026 14:44:42 -0300
Subject: [PATCH 03/16] build(deps): bump Testcontainers to 4.14.0 and
SourceLink past their advisories
`dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on
`main` and on every open PR alike. Advisory-database drift, not a regression from
any change: a commit green on 2026-08-10 is red today with no edits.
- `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903,
GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already
depends on the patched 2026.0.0, so the advisory clears with no transitive pin
to remember to remove later. Same fix as #1369, so the two do not conflict.
- `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902,
GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the
8.x line has no patched release, so a transitive pin cannot fix it; the package
itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401,
past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced
only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is
excluded from the template, so the scaffold never sees it.
Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and
`dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings
and 0 errors.
---
src/Directory.Packages.props | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 7674befa8f..89162470ad 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -9,7 +9,8 @@
-
+
+
@@ -122,9 +123,10 @@
-
-
-
+
+
+
+
From c6a72df1bcbc4875bd9f05d11e150cbd11a5d3e0 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Mon, 14 Sep 2026 14:45:29 -0300
Subject: [PATCH 04/16] fix(infra): pull MinIO from quay.io on a pinned tag,
not Docker Hub
MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers
`object not found` for the repository, and a pull fails with:
pull access denied for minio/minio, repository does not exist or may
require 'docker login'
That takes down every Testcontainers-backed integration test (the harness boots
a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at
container start), the Aspire AppHost, and the Docker Compose deployment. The
image is still published at `quay.io/minio/minio`:
- `Integration.Tests` and `Integration.Middleware.Tests` harnesses
- `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag`
- `deploy/docker/docker-compose.yml` and the image table in its README
The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay
has not moved `:latest` since 2025-09-07, so the two resolve to the same digest
today; pinning only removes the surprise of a silent move later, and keeps the
test harness off a floating tag. Whether to track a newer release, or a different
S3-compatible image, is a separate call.
While in the README's image table: `postgres` and `redis` rows had drifted from
what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`).
Verified: `docker pull minio/minio:latest` fails with the error above;
`docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds
(`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the
same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release`
passes against the pinned image, and the Aspire manifest renders the container
as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`.
---
deploy/docker/README.md | 6 +++---
deploy/docker/docker-compose.yml | 3 ++-
src/Host/FSH.Starter.AppHost/AppHost.cs | 3 +++
.../Infrastructure/MiddlewareWebApplicationFactory.cs | 3 ++-
.../Infrastructure/FshWebApplicationFactory.cs | 3 ++-
5 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/deploy/docker/README.md b/deploy/docker/README.md
index bcb1304593..0219164b7c 100644
--- a/deploy/docker/README.md
+++ b/deploy/docker/README.md
@@ -8,9 +8,9 @@ This brings up the full stack on a single host:
| `admin` | `fsh/admin:local` | `FSH_ADMIN_PORT` (default 8081) | Operator console (nginx + React) |
| `dashboard` | `fsh/dashboard:local` | `FSH_DASHBOARD_PORT` (default 8082) | Tenant dashboard (nginx + React) |
| `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user |
-| `postgres` | `postgres:17-alpine` | (internal) | Identity, tenant catalog, module schemas |
-| `redis` | `redis:7-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store |
-| `minio` | `minio/minio:latest` | (internal) | S3-compatible blob store for the Files module |
+| `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas |
+| `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store |
+| `minio` | `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` | (internal) | S3-compatible blob store for the Files module |
The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine.
diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml
index d43c744f5b..9457a61232 100644
--- a/deploy/docker/docker-compose.yml
+++ b/deploy/docker/docker-compose.yml
@@ -54,7 +54,8 @@ services:
# - "6379:6379"
minio:
- image: minio/minio:latest
+ # quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest.
+ image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: fsh-minio
restart: unless-stopped
command: ["server", "/data", "--console-address", ":9001"]
diff --git a/src/Host/FSH.Starter.AppHost/AppHost.cs b/src/Host/FSH.Starter.AppHost/AppHost.cs
index e7a70abd05..fb3506ab42 100644
--- a/src/Host/FSH.Starter.AppHost/AppHost.cs
+++ b/src/Host/FSH.Starter.AppHost/AppHost.cs
@@ -52,7 +52,10 @@
var minioUser = builder.AddParameter("minio-user", "minioadmin");
var minioPassword = builder.AddParameter("minio-password", "minioadmin", secret: true);
+// quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest.
var minio = builder.AddContainer("minio", "minio/minio")
+ .WithImageRegistry("quay.io")
+ .WithImageTag("RELEASE.2025-09-07T16-13-09Z")
.WithArgs("server", "/data", "--console-address", ":9001")
.WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api")
.WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console")
diff --git a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs
index 4c2939c454..e8b7898023 100644
--- a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs
+++ b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs
@@ -55,7 +55,8 @@ public sealed class MiddlewareWebApplicationFactory : WebApplicationFactory, I
.WithCleanUp(true)
.Build();
- private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest")
+ // quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest.
+ private readonly MinioContainer _minio = new MinioBuilder("quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z")
.WithUsername(MinioAccessKey)
.WithPassword(MinioSecretKey)
.WithAutoRemove(true)
From d837c3488f9d913af8395a2cb8d87be12a06ab33 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Thu, 17 Sep 2026 15:09:19 -0300
Subject: [PATCH 05/16] fix(admin): make the token refresh single-flight, not
per-caller
The server rotates the refresh token on every successful refresh, so two
refreshes started close together send the same token twice: the loser
gets a 401 and apiFetch's failure path calls tokenStore.clear(). The
operator is signed out mid-work, with no message, because the language
switcher swallows the error with .catch(() => undefined).
The single-flight existed but lived inside apiFetch's 401 retry, so it
only covered one of the three call sites. It moves into
refreshAccessToken itself, which is the only place that knows a refresh
is in flight; the other two callers (session bootstrap, the language
switcher) now share it for free.
Gate: the new spec switches language twice in a row against a refresh
that is held open, and counts the calls. Two on the previous code, one
after. tsc and lint clean, full admin Playwright suite 136 passed.
---
clients/admin/src/lib/api-client.ts | 24 ++++++--
clients/admin/tests/i18n/switcher.spec.ts | 75 +++++++++++++++++++++++
2 files changed, 93 insertions(+), 6 deletions(-)
diff --git a/clients/admin/src/lib/api-client.ts b/clients/admin/src/lib/api-client.ts
index 91c811b806..60cda2380d 100644
--- a/clients/admin/src/lib/api-client.ts
+++ b/clients/admin/src/lib/api-client.ts
@@ -52,7 +52,23 @@ const DEFAULT_TIMEOUT_MS = 30_000;
let refreshPromise: Promise | null = null;
-export async function refreshAccessToken() {
+/**
+ * Refresh the access token, at most one call in flight at a time.
+ *
+ * The single-flight is part of this function rather than of any one caller because the server
+ * rotates the refresh token on every successful call: a second refresh started while the first is
+ * still open sends a token the server has already spent, gets a 401, and `clear()`s the session out
+ * from under a signed-in user. Three call sites reach this (the 401 retry below, session bootstrap,
+ * the language switcher), and any two of them overlapping is enough.
+ */
+export function refreshAccessToken(): Promise {
+ refreshPromise ??= runRefresh().finally(() => {
+ refreshPromise = null;
+ });
+ return refreshPromise;
+}
+
+async function runRefresh(): Promise {
const refreshToken = tokenStore.getRefreshToken();
const accessToken = tokenStore.getAccessToken();
if (!refreshToken || !accessToken) {
@@ -155,12 +171,8 @@ export async function apiFetch(
});
if (response.status === 401 && !skipAuth && tokenStore.getRefreshToken()) {
- refreshPromise ??= refreshAccessToken().finally(() => {
- refreshPromise = null;
- });
-
try {
- await refreshPromise;
+ await refreshAccessToken();
} catch (e) {
throw e instanceof ApiRequestError
? e
diff --git a/clients/admin/tests/i18n/switcher.spec.ts b/clients/admin/tests/i18n/switcher.spec.ts
index fcf7877e90..e86799e500 100644
--- a/clients/admin/tests/i18n/switcher.spec.ts
+++ b/clients/admin/tests/i18n/switcher.spec.ts
@@ -196,4 +196,79 @@ test.describe("language switcher", () => {
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();
+ });
});
From e188d615eaff904ebc55f1ff24580391c1a4f4ae Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 03:45:59 -0300
Subject: [PATCH 06/16] fix(admin): keep the session when a speculative refresh
fails
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The single-flight added earlier stopped two *concurrent* refreshes from racing,
but one failed refresh was still enough on its own: `refreshAccessToken()`
cleared the token store on any non-ok response, and the language switcher fires
it speculatively (it only re-mints the JWT so the new `locale` claim is issued).
A refresh token that had been revoked, rotated in another tab or dropped by a
reseed therefore signed the operator out for choosing a language.
Ending the session now belongs to the callers that know the request needed auth:
the 401 retry in `apiFetch` and the boot probe in `AuthProvider`, which already
cleared. The switcher reports the failure instead of swallowing it.
A failed save is reported too. The language mutation had an `onSuccess` and no
`onError`, so a rejected `PUT /identity/profile` left the UI switched with
nothing on screen to say the choice was not stored — it silently reverts on the
next fresh mount, which reads as the app forgetting on its own.
Both paths are covered: a 401 refresh keeps the session and the language, and a
500 save surfaces the toast.
---
.../admin/src/components/layout/topbar.tsx | 18 +++-
clients/admin/src/lib/api-client.ts | 11 ++-
clients/admin/src/locales/en-US/common.json | 4 +-
clients/admin/src/locales/pt-BR/common.json | 4 +-
clients/admin/tests/i18n/switcher.spec.ts | 84 +++++++++++++++++++
5 files changed, 114 insertions(+), 7 deletions(-)
diff --git a/clients/admin/src/components/layout/topbar.tsx b/clients/admin/src/components/layout/topbar.tsx
index 7d85404d21..ad7124b0ef 100644
--- a/clients/admin/src/components/layout/topbar.tsx
+++ b/clients/admin/src/components/layout/topbar.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@@ -220,8 +221,21 @@ export function Topbar() {
// Re-mint the JWT so the fresh `locale` claim is issued (resolution-chain
// level 2). The UI already switched client-side; without this, backend-
// generated strings lag behind until the next natural token refresh.
- // Best-effort: a refresh failure must not undo the language switch.
- void refreshAccessToken().catch(() => undefined);
+ // Best-effort: the refresh no longer ends the session when it fails, so the
+ // switch survives and the failure is reported instead of swallowed.
+ void refreshAccessToken().catch((error: unknown) => {
+ console.warn(
+ "[i18n] locale saved, but re-minting the token failed — backend strings stay in the " +
+ "previous language until the next successful refresh.",
+ error,
+ );
+ });
+ },
+ // The UI switched on click, so a failed PUT leaves the app in a language the server
+ // does not know about, which reverts on the next fresh mount. Say so rather than let
+ // the choice disappear silently.
+ onError: () => {
+ toast.error(t("language.saveFailed"), { description: t("language.saveFailedDetail") });
},
});
diff --git a/clients/admin/src/lib/api-client.ts b/clients/admin/src/lib/api-client.ts
index 60cda2380d..15e676b705 100644
--- a/clients/admin/src/lib/api-client.ts
+++ b/clients/admin/src/lib/api-client.ts
@@ -57,8 +57,7 @@ let refreshPromise: Promise | null = null;
*
* The single-flight is part of this function rather than of any one caller because the server
* rotates the refresh token on every successful call: a second refresh started while the first is
- * still open sends a token the server has already spent, gets a 401, and `clear()`s the session out
- * from under a signed-in user. Three call sites reach this (the 401 retry below, session bootstrap,
+ * still open sends a token the server has already spent and gets a 401. Three call sites reach this (the 401 retry below, session bootstrap,
* the language switcher), and any two of them overlapping is enough.
*/
export function refreshAccessToken(): Promise {
@@ -90,7 +89,10 @@ async function runRefresh(): Promise {
});
if (!response.ok) {
- tokenStore.clear();
+ // Deliberately no tokenStore.clear() here: a refresh can be fired speculatively (the language
+ // switch re-mints the JWT for the new `locale` claim), and a background failure must not end a
+ // session the user is actively using. Ending it belongs to the callers that know the request
+ // needed auth — the 401 retry below and the boot probe in AuthProvider.
throw new ApiRequestError(response.status, "Refresh failed");
}
@@ -174,6 +176,9 @@ export async function apiFetch(
try {
await refreshAccessToken();
} catch (e) {
+ // This request needed auth and the refresh could not provide it: the session is over, so
+ // drop it and let routing fall through to /login.
+ tokenStore.clear();
throw e instanceof ApiRequestError
? e
: new ApiRequestError(401, "Session expired");
diff --git a/clients/admin/src/locales/en-US/common.json b/clients/admin/src/locales/en-US/common.json
index 575eccbe67..8f518ae99a 100644
--- a/clients/admin/src/locales/en-US/common.json
+++ b/clients/admin/src/locales/en-US/common.json
@@ -62,5 +62,7 @@
"imageInput.choose": "Choose image",
"imageInput.remove": "Remove",
"imageInput.formats": "JPG/PNG/WebP/GIF · up to {{size}}",
- "imageInput.directLink": "Direct link to an image you host elsewhere."
+ "imageInput.directLink": "Direct link to an image you host elsewhere.",
+ "language.saveFailed": "Language not saved",
+ "language.saveFailedDetail": "The app is now in the language you picked, but the choice could not be saved and will not survive a sign-out."
}
diff --git a/clients/admin/src/locales/pt-BR/common.json b/clients/admin/src/locales/pt-BR/common.json
index 8e9a1ebcf6..c688fdd9c6 100644
--- a/clients/admin/src/locales/pt-BR/common.json
+++ b/clients/admin/src/locales/pt-BR/common.json
@@ -62,5 +62,7 @@
"imageInput.choose": "Escolher imagem",
"imageInput.remove": "Remover",
"imageInput.formats": "JPG/PNG/WebP/GIF · até {{size}}",
- "imageInput.directLink": "Link direto para uma imagem hospedada em outro lugar."
+ "imageInput.directLink": "Link direto para uma imagem hospedada em outro lugar.",
+ "language.saveFailed": "Idioma não salvo",
+ "language.saveFailedDetail": "O app está no idioma escolhido, mas não foi possível salvar a escolha, que não vai persistir depois que você sair da conta."
}
diff --git a/clients/admin/tests/i18n/switcher.spec.ts b/clients/admin/tests/i18n/switcher.spec.ts
index e86799e500..e8c9700be5 100644
--- a/clients/admin/tests/i18n/switcher.spec.ts
+++ b/clients/admin/tests/i18n/switcher.spec.ts
@@ -271,4 +271,88 @@ test.describe("language switcher", () => {
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();
+ });
});
+
+// 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
From 2980007c90329179726028956e16c266b25ffc7b Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 03:46:00 -0300
Subject: [PATCH 07/16] test(admin): gate interpolation parity, not just keys
Matching key sets do not catch a translation that drops or renames {{var}}:
i18next renders the placeholder as literal text, or the value is silently lost,
and no key is missing. The gate compares the variable set per key across
locales, ignoring the formatter after the comma.
---
clients/admin/tests/i18n/parity.spec.ts | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/clients/admin/tests/i18n/parity.spec.ts b/clients/admin/tests/i18n/parity.spec.ts
index 7f6ad47f1a..ad7a35f073 100644
--- a/clients/admin/tests/i18n/parity.spec.ts
+++ b/clients/admin/tests/i18n/parity.spec.ts
@@ -32,4 +32,24 @@ test.describe("catalog parity", () => {
expect(en).toEqual(pt);
});
}
+
+ // Matching key sets are not enough: 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 = readCatalog("pt-BR", 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([]);
+ });
+ }
});
From e1b525db840aef962e32d4cfc9cf0cff67a6617c Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 04:06:48 -0300
Subject: [PATCH 08/16] fix(admin): stop rendering raw catalog keys for backend
status values
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Approving a top-up moves it to `Invoiced`, and the badge read "status.invoiced":
the label key is built from the value the API sends, and the catalog had neither
`status.invoiced` nor `status.cancelled`. It did have `status.approved`, which
the backend never emits. On main the badge printed the raw enum name, so this
slice turned something readable into a key.
The TS union and the filter list carried the same phantom, so both now mirror
`TopupRequestStatus` in Modules.Billing.Contracts (Pending, Invoiced, Completed,
Rejected, Cancelled) — filtering by "Approved" could only ever return nothing.
Two safety nets, because catalog parity cannot see keys that are built at run
time (both locales can be missing the same one and still match):
- `parseMissingKeyHandler` degrades a missing key to its last segment and warns
in development, so the worst case is the un-localized name rather than the key.
- `tests/i18n/status-keys.spec.ts` reads the members straight out of the backend
enums and asserts each one resolves in both catalogs. Verified by mutation:
removing `status.invoiced` from the pt-BR catalog turns it red.
Also removes the deprecated `NAV_ITEMS` / `filterNavItems` export, dead since the
nav moved to `sections`/`topNavTop` (no importer in src/ or tests/): it carried
hardcoded English labels that would have shipped untranslated the moment anyone
imported it.
`tests/i18n/i18n.spec.ts` read the Accept-Language header off a variable its own
route handler assigns, but `waitForRequest` resolves at dispatch, before the
handler runs. It reads the resolved request instead.
---
clients/admin/src/api/wallet.ts | 7 +-
.../admin/src/components/layout/nav-items.ts | 68 -----------------
clients/admin/src/i18n.ts | 11 +++
clients/admin/src/locales/en-US/billing.json | 15 +---
clients/admin/src/locales/pt-BR/billing.json | 5 +-
.../admin/src/pages/billing/topups-list.tsx | 10 ++-
clients/admin/tests/i18n/i18n.spec.ts | 16 ++--
clients/admin/tests/i18n/status-keys.spec.ts | 74 +++++++++++++++++++
8 files changed, 111 insertions(+), 95 deletions(-)
create mode 100644 clients/admin/tests/i18n/status-keys.spec.ts
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/components/layout/nav-items.ts b/clients/admin/src/components/layout/nav-items.ts
index 6280397283..a5feda9c5f 100644
--- a/clients/admin/src/components/layout/nav-items.ts
+++ b/clients/admin/src/components/layout/nav-items.ts
@@ -157,71 +157,3 @@ export function filterNavSpec(items: NavSpec[], granted: readonly string[]): Nav
return item.perms.every((p) => granted.includes(p));
});
}
-
-// ── Legacy flat export (used by sidebar-content & permission gating elsewhere) ──
-
-/** @deprecated Use sections / topNavTop / topNavBottom instead. */
-export type NavItem = NavSpec & { matchPrefix?: string };
-
-/** @deprecated Flat list kept only for call-sites still importing NAV_ITEMS. */
-export const NAV_ITEMS: NavItem[] = [
- { to: "/", label: "Overview", icon: LayoutDashboard },
- {
- to: "/tenants",
- label: "Tenants",
- icon: Building2,
- matchPrefix: "/tenants",
- perms: [MultitenancyPermissions.Tenants.View],
- },
- {
- to: "/users",
- label: "Users",
- icon: UsersRound,
- matchPrefix: "/users",
- perms: [IdentityPermissions.Users.View],
- },
- {
- to: "/roles",
- label: "Roles",
- icon: ShieldCheck,
- matchPrefix: "/roles",
- perms: [IdentityPermissions.Roles.View],
- },
- {
- to: "/billing",
- label: "Billing",
- icon: Receipt,
- matchPrefix: "/billing",
- perms: [BillingPermissions.View],
- },
- {
- to: "/impersonation",
- label: "Impersonation",
- icon: UserCog,
- matchPrefix: "/impersonation",
- perms: [IdentityPermissions.Impersonation.View],
- },
- {
- to: "/audits",
- label: "Audits",
- icon: ScrollText,
- matchPrefix: "/audits",
- perms: [AuditingPermissions.AuditTrails.View],
- },
- {
- to: "/webhooks",
- label: "Webhooks",
- icon: Webhook,
- matchPrefix: "/webhooks",
- perms: [WebhooksPermissions.Subscriptions.View],
- },
- { to: "/health", label: "Health", icon: Activity, matchPrefix: "/health" },
-];
-
-/** @deprecated Use filterNavSpec instead. */
-export function filterNavItems(items: NavItem[], grantedPermissions: readonly string[]): NavItem[] {
- return items.filter((item) => {
- if (!item.perms || item.perms.length === 0) return true;
- return item.perms.every((p) => grantedPermissions.includes(p));
- });
-}
diff --git a/clients/admin/src/i18n.ts b/clients/admin/src/i18n.ts
index dbe98dbaf3..bd211135e1 100644
--- a/clients/admin/src/i18n.ts
+++ b/clients/admin/src/i18n.ts
@@ -100,6 +100,17 @@ export function initI18n(deploymentDefault: string) {
supportedLngs: [...SUPPORTED],
defaultNS: "common",
interpolation: { escapeValue: false },
+ // Several keys are built from a server value (`status.${x}`), so a value the catalog has not
+ // caught up with would render the key itself on screen ("status.invoiced"). Degrade to the
+ // last segment instead, which is the readable name the UI showed before it was localized,
+ // and make the gap loud in development.
+ parseMissingKeyHandler: (key: string) => {
+ if (import.meta.env.DEV) {
+ console.warn(`[i18n] missing key: ${key}`);
+ }
+ const segment = key.split(/[.:]/).pop() ?? key;
+ return segment.charAt(0).toUpperCase() + segment.slice(1);
+ },
detection: {
// NO cookie — localStorage only (the library default; key i18nextLng).
order: ["querystring", "localStorage", "navigator"],
diff --git a/clients/admin/src/locales/en-US/billing.json b/clients/admin/src/locales/en-US/billing.json
index 3686d4cee7..291a029c87 100644
--- a/clients/admin/src/locales/en-US/billing.json
+++ b/clients/admin/src/locales/en-US/billing.json
@@ -5,19 +5,16 @@
"layout.tab.plans": "Plans",
"layout.tab.invoices": "Invoices",
"layout.tab.topups": "Top-ups",
-
"status.draft": "Draft",
"status.issued": "Issued",
"status.paid": "Paid",
"status.void": "Void",
"status.pending": "Pending",
- "status.approved": "Approved",
"status.rejected": "Rejected",
"status.completed": "Completed",
"status.all": "All",
"status.active": "Active",
"status.inactive": "Inactive",
-
"interval.monthly": "Monthly",
"interval.yearly": "Yearly",
"purpose.subscription": "Subscription",
@@ -25,10 +22,8 @@
"kind.baseFee": "BaseFee",
"kind.overage": "Overage",
"kind.metered": "Metered",
-
"perYear": "per year",
"perMonth": "per month",
-
"label.tenant": "tenant",
"label.period": "period",
"label.created": "created",
@@ -41,11 +36,9 @@
"label.currency": "currency",
"label.overage": "overage",
"label.decided": "decided",
-
"pagination.previous": "Previous",
"pagination.next": "Next",
"pagination.pageOf": "Page {{page}} / {{total}}",
-
"plans.stat.plans": "Plans",
"plans.stat.activeCount": "{{count}} active",
"plans.stat.active": "Active",
@@ -60,7 +53,6 @@
"plans.meta": "currency {{currency}} · overage {{overage}}",
"plans.editAria": "Edit {{name}}",
"plans.loadError": "Failed to load plans.",
-
"invoices.kpi.pageInvoices": "Page invoices",
"invoices.kpi.totalCount": "{{count}} total",
"invoices.kpi.loading": "loading…",
@@ -84,7 +76,6 @@
"invoices.list.loading": "Loading…",
"invoices.list.empty": "No invoices match the current filters.",
"invoices.loadError": "Failed to load invoices.",
-
"invoiceDetail.back": "All invoices",
"invoiceDetail.download": "Download PDF",
"invoiceDetail.preparing": "Preparing…",
@@ -127,7 +118,6 @@
"invoiceDetail.void.submit": "Void invoice",
"invoiceDetail.void.submitting": "Voiding…",
"invoiceDetail.notes": "Notes",
-
"topups.kpi.pageRequests": "Page requests",
"topups.kpi.totalCount": "{{count}} total",
"topups.kpi.loading": "loading…",
@@ -165,7 +155,6 @@
"topups.confirm.optional": "(optional)",
"topups.confirm.rejectPlaceholder": "duplicate · invalid · …",
"topups.confirm.notePlaceholder": "internal note",
-
"planForm.editTitle": "Edit plan",
"planForm.newTitle": "New plan",
"planForm.editDescription": "Update name, pricing, interval, or overage rates. Key and currency are immutable.",
@@ -204,5 +193,7 @@
"planForm.toast.createFailedDesc": "Could not create plan.",
"planForm.toast.updated": "Plan \"{{name}}\" updated",
"planForm.toast.updateFailed": "Update failed",
- "planForm.toast.updateFailedDesc": "Could not update plan."
+ "planForm.toast.updateFailedDesc": "Could not update plan.",
+ "status.invoiced": "Invoiced",
+ "status.cancelled": "Cancelled"
}
diff --git a/clients/admin/src/locales/pt-BR/billing.json b/clients/admin/src/locales/pt-BR/billing.json
index 73877e96ca..876724d8a1 100644
--- a/clients/admin/src/locales/pt-BR/billing.json
+++ b/clients/admin/src/locales/pt-BR/billing.json
@@ -10,7 +10,6 @@
"status.paid": "Paga",
"status.void": "Anulada",
"status.pending": "Pendente",
- "status.approved": "Aprovada",
"status.rejected": "Rejeitada",
"status.completed": "Concluída",
"status.all": "Todas",
@@ -194,5 +193,7 @@
"planForm.toast.createFailedDesc": "Não foi possível criar o plano.",
"planForm.toast.updated": "Plano \"{{name}}\" atualizado",
"planForm.toast.updateFailed": "Falha ao atualizar",
- "planForm.toast.updateFailedDesc": "Não foi possível atualizar o plano."
+ "planForm.toast.updateFailedDesc": "Não foi possível atualizar o plano.",
+ "status.cancelled": "Cancelada",
+ "status.invoiced": "Faturada"
}
diff --git a/clients/admin/src/pages/billing/topups-list.tsx b/clients/admin/src/pages/billing/topups-list.tsx
index 4a51265d7b..c1953ec872 100644
--- a/clients/admin/src/pages/billing/topups-list.tsx
+++ b/clients/admin/src/pages/billing/topups-list.tsx
@@ -46,7 +46,7 @@ import { BillingPermissions } from "@/lib/permissions";
const PAGE_SIZE = 20;
-const STATUSES: TopupRequestStatus[] = ["Pending", "Approved", "Rejected", "Completed"];
+const STATUSES: TopupRequestStatus[] = ["Pending", "Invoiced", "Completed", "Rejected", "Cancelled"];
// ─── helpers ─────────────────────────────────────────────────────────
@@ -54,8 +54,10 @@ function statusVariant(status: TopupRequestStatus): React.ComponentProps
- t(`status.${status.charAt(0).toLowerCase()}${status.slice(1)}`);
+ t(`status.${status.charAt(0).toLowerCase()}${status.slice(1)}`, { defaultValue: status });
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
diff --git a/clients/admin/tests/i18n/i18n.spec.ts b/clients/admin/tests/i18n/i18n.spec.ts
index 3399b90fa3..cbc2c0b32f 100644
--- a/clients/admin/tests/i18n/i18n.spec.ts
+++ b/clients/admin/tests/i18n/i18n.spec.ts
@@ -31,7 +31,6 @@ test.describe("i18n", () => {
});
test("apiFetch sends Accept-Language matching the active locale", async ({ page }) => {
- let seenLang: string | null = null;
// Registered AFTER installAdminShellMocks so this handler wins (LIFO) and
// can inspect the request header. Return a profile whose locale matches the
@@ -41,7 +40,6 @@ test.describe("i18n", () => {
await route.fallback();
return;
}
- seenLang = route.request().headers()["accept-language"] ?? null;
await route.fulfill({
status: 200,
headers: { "Content-Type": "application/json" },
@@ -59,16 +57,17 @@ test.describe("i18n", () => {
{ timeout: 10_000 },
);
await page.goto("/");
- await profileReq;
+ // 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(seenLang).toBe("en-US");
+ 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.
- let seenLang: string | null = null;
// Return a profile whose locale already matches pt-BR so the topbar's sync effect does not
// switch the language back.
@@ -77,7 +76,6 @@ test.describe("i18n", () => {
await route.fallback();
return;
}
- seenLang = route.request().headers()["accept-language"] ?? null;
await route.fulfill({
status: 200,
headers: { "Content-Type": "application/json" },
@@ -95,8 +93,10 @@ test.describe("i18n", () => {
{ timeout: 10_000 },
);
await page.goto("/?culture=pt-BR");
- await profileReq;
+ // 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(seenLang).toBe("pt-BR");
+ expect(headers["accept-language"]).toBe("pt-BR");
});
});
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..d85cd22f48
--- /dev/null
+++ b/clients/admin/tests/i18n/status-keys.spec.ts
@@ -0,0 +1,74 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+
+// 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.
+
+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 call sites apply: first letter lowered, rest kept. */
+const camel = (member: string) => `status.${member.charAt(0).toLowerCase()}${member.slice(1)}`;
+
+/** The impersonation list lowercases the whole value instead. */
+const lower = (member: string) => `status.${member.toLowerCase()}`;
+
+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: camel,
+ },
+ {
+ label: "impersonation grants",
+ ns: "impersonation",
+ file: "src/Modules/Identity/Modules.Identity.Contracts/v1/Impersonation/ImpersonationGrantDto.cs",
+ enumName: "ImpersonationGrantStatus",
+ key: lower,
+ },
+];
+
+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([]);
+ });
+ }
+});
From 45a7796f151e6f47eda125beb63548bd619aa236 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 04:28:39 -0300
Subject: [PATCH 09/16] fix(infra): pull minio/mc from quay.io too, not just
minio/minio
The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is
gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers
404), and it is what `minio-init` runs: without it `dotnet run --project
src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull,
and the `fsh` bucket is never created, so the first upload fails with
NoSuchBucket.
Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical
to it and can be dropped once that lands.
---
deploy/docker/docker-compose.yml | 3 ++-
src/Host/FSH.Starter.AppHost/AppHost.cs | 2 ++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml
index 9457a61232..dea9a817ff 100644
--- a/deploy/docker/docker-compose.yml
+++ b/deploy/docker/docker-compose.yml
@@ -80,7 +80,8 @@ services:
# policy is set — objects are served via the API / presigned URLs, not a
# public bucket.
minio-init:
- image: minio/mc:latest
+ # quay.io: minio/mc is gone from Docker Hub too. Tag pinned; quay stopped moving :latest.
+ image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z
container_name: fsh-minio-init
restart: "no"
depends_on:
diff --git a/src/Host/FSH.Starter.AppHost/AppHost.cs b/src/Host/FSH.Starter.AppHost/AppHost.cs
index fb3506ab42..4fc689599d 100644
--- a/src/Host/FSH.Starter.AppHost/AppHost.cs
+++ b/src/Host/FSH.Starter.AppHost/AppHost.cs
@@ -76,6 +76,8 @@
""").ReplaceLineEndings("\n");
var minioInit = builder.AddContainer("minio-init", "minio/mc")
+ .WithImageRegistry("quay.io")
+ .WithImageTag("RELEASE.2025-08-13T08-35-41Z")
.WithEntrypoint("/bin/sh")
.WithArgs("-c", minioInitScript)
.WithEnvironment("MC_USER", minioUser)
From f51bcabe4fa51ffcdba2b488026127d857ba41f2 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 04:34:46 -0300
Subject: [PATCH 10/16] build(deps): drop the dead SSH.NET pin
The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on
2025.1.0, so bumping Testcontainers does not help", but the branch also bumps
Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two
statements cannot both be true, and the bump is the one that is: with the pin
removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903
and exits 0. It was carrying a transitive pin that no longer pins anything.
The MessagePack pin above it stays: that one is still load-bearing (removing it
brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe).
---
src/Directory.Packages.props | 7 -------
1 file changed, 7 deletions(-)
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 89162470ad..854deb9530 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -145,12 +145,5 @@
AccessViolation). Transitive pinning is enabled, so this entry alone bumps it.
Remove once the SignalR backplane package depends on a patched version itself. -->
-
-
\ No newline at end of file
From acca242aa228d3b158ec6fa9f44a725a92a3d6c8 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 05:12:58 -0300
Subject: [PATCH 11/16] feat(admin): localize the permission matrix, and stop
dates following the browser
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The role detail screen renders eight group headings, eight blurbs and thirty-four
permission rows straight out of `PERMISSION_CATALOG`, all of them English
literals. A pt-BR operator opening a role read a fully translated shell wrapped
around fifty English strings — the largest untranslated surface left in the app.
Each group now carries a stable key, the English text stays in the file as the
fallback, and the screen resolves through the `roles` catalog.
`tests/i18n/status-keys.spec.ts` gates it: it resolves the permission constants
the same way the app does (the catalog holds the permission *value* at run time,
not the identifier it is written with) and asserts every group, blurb and entry
has an entry in both locales.
Seven call sites formatted dates with `toLocaleString()` and friends, which use
the browser's locale, not the app's — so a browser in en-US showed
`5/23/2026, 10:00:00 AM` next to Portuguese labels. `format.ts` gains
`formatDateTime`/`formatTime` and the call sites use them. The impersonation
card's "started … · expires …" was hardcoded English prose; it is a key now.
Three tests that could not fail, all of them named in review:
- `format.spec.ts` passed an explicit locale in all nine assertions, so
`resolveLocale` — the branch every production call takes — was untested.
- `html[lang]` had no assertion at all, while the PR claims screen readers and
browser translation now see the real language.
- `i18n.spec.ts` read the `Accept-Language` header off a variable its own route
handler assigns, and `waitForRequest` resolves at dispatch, before the handler
runs. It reads the resolved request now.
---
.../impersonation/active-grants-card.tsx | 7 ++-
.../impersonation/revoke-grant-dialog.tsx | 3 +-
.../sessions/user-sessions-card.tsx | 3 +-
clients/admin/src/lib/format.ts | 22 ++++++++
clients/admin/src/lib/permissions.ts | 11 ++++
.../src/locales/en-US/impersonation.json | 6 +--
clients/admin/src/locales/en-US/roles.json | 54 +++++++++++++++++--
.../src/locales/pt-BR/impersonation.json | 3 +-
clients/admin/src/locales/pt-BR/roles.json | 52 +++++++++++++++++-
.../admin/src/pages/impersonation/list.tsx | 5 +-
clients/admin/src/pages/roles/detail.tsx | 6 +--
clients/admin/src/pages/settings/sessions.tsx | 2 +-
clients/admin/tests/i18n/format.spec.ts | 22 ++++++++
clients/admin/tests/i18n/i18n.spec.ts | 13 +++++
clients/admin/tests/i18n/status-keys.spec.ts | 42 +++++++++++++++
15 files changed, 232 insertions(+), 19 deletions(-)
diff --git a/clients/admin/src/components/impersonation/active-grants-card.tsx b/clients/admin/src/components/impersonation/active-grants-card.tsx
index be5a4b0424..45e911ce54 100644
--- a/clients/admin/src/components/impersonation/active-grants-card.tsx
+++ b/clients/admin/src/components/impersonation/active-grants-card.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { formatTime } from "@/lib/format";
import { useQuery } from "@tanstack/react-query";
import { ShieldOff, UserCog } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -150,8 +151,10 @@ function GrantRow({
- {entry.description}
+ {t(`perm.entry.${entry.name}`, { defaultValue: entry.description })}
{entry.root && (
diff --git a/clients/admin/src/pages/settings/sessions.tsx b/clients/admin/src/pages/settings/sessions.tsx
index 2cf12b1b90..c4431ee65e 100644
--- a/clients/admin/src/pages/settings/sessions.tsx
+++ b/clients/admin/src/pages/settings/sessions.tsx
@@ -228,7 +228,7 @@ function formatRelative(value: string | null | undefined, t: TFn): string {
if (hr < 24) return t("relative.hoursAgo", { n: hr });
const day = Math.round(hr / 24);
if (day < 14) return t("relative.daysAgo", { n: day });
- return d.toLocaleDateString();
+ return formatDate(d.toISOString());
}
function describe(err: unknown): string {
diff --git a/clients/admin/tests/i18n/format.spec.ts b/clients/admin/tests/i18n/format.spec.ts
index 34355f48af..aad7f28648 100644
--- a/clients/admin/tests/i18n/format.spec.ts
+++ b/clients/admin/tests/i18n/format.spec.ts
@@ -61,4 +61,26 @@ test.describe("format.ts", () => {
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/);
+ });
});
diff --git a/clients/admin/tests/i18n/i18n.spec.ts b/clients/admin/tests/i18n/i18n.spec.ts
index cbc2c0b32f..d470101a93 100644
--- a/clients/admin/tests/i18n/i18n.spec.ts
+++ b/clients/admin/tests/i18n/i18n.spec.ts
@@ -100,3 +100,16 @@ test.describe("i18n", () => {
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/status-keys.spec.ts b/clients/admin/tests/i18n/status-keys.spec.ts
index d85cd22f48..71f75c18c1 100644
--- a/clients/admin/tests/i18n/status-keys.spec.ts
+++ b/clients/admin/tests/i18n/status-keys.spec.ts
@@ -72,3 +72,45 @@ test.describe("status keys built from backend enums", () => {
});
}
});
+
+// 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([]);
+ });
+});
From 67e19c875e6c0e380d79e7be3cac957f05ea3d52 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 05:28:37 -0300
Subject: [PATCH 12/16] fix(i18n): stop the missing-key handler from eating
every defaultValue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`parseMissingKeyHandler` took only the key and returned the capitalized last
segment. i18next calls it for a missing key whether or not the call site passed
a `defaultValue`, and the handler's return value is what renders — so every
`t(key, { defaultValue })` in the app was silently degraded to a truncation of
its own key. The permission matrix was the visible case: an entry the catalog
had not caught up with rendered "Create" where the fallback says "Create users".
Confirmed against the installed i18next (26.3.6) before the fix:
`t("perm.entry.Permissions.Users.Create", { defaultValue: "Create users" })`
returned `"Create"`, and the handler's second argument arrived as the
defaultValue (`null` when there is none, not `undefined`).
The two rules — a caller's fallback wins, otherwise degrade to the last segment
— move to `lib/i18n-fallback.ts`, and `tests/i18n/missing-key.spec.ts` drives
them through a real i18next instance rather than re-implementing the contract.
Reverting the guard turns the first of the three red.
---
clients/admin/src/i18n.ts | 13 +++----
clients/admin/src/lib/i18n-fallback.ts | 20 ++++++++++
clients/admin/tests/i18n/missing-key.spec.ts | 41 ++++++++++++++++++++
3 files changed, 67 insertions(+), 7 deletions(-)
create mode 100644 clients/admin/src/lib/i18n-fallback.ts
create mode 100644 clients/admin/tests/i18n/missing-key.spec.ts
diff --git a/clients/admin/src/i18n.ts b/clients/admin/src/i18n.ts
index bd211135e1..b3d9dd781b 100644
--- a/clients/admin/src/i18n.ts
+++ b/clients/admin/src/i18n.ts
@@ -1,6 +1,7 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
+import { missingKeyFallback } from "@/lib/i18n-fallback";
import enCommon from "@/locales/en-US/common.json";
import ptCommon from "@/locales/pt-BR/common.json";
import enNav from "@/locales/en-US/nav.json";
@@ -100,16 +101,14 @@ export function initI18n(deploymentDefault: string) {
supportedLngs: [...SUPPORTED],
defaultNS: "common",
interpolation: { escapeValue: false },
- // Several keys are built from a server value (`status.${x}`), so a value the catalog has not
- // caught up with would render the key itself on screen ("status.invoiced"). Degrade to the
- // last segment instead, which is the readable name the UI showed before it was localized,
- // and make the gap loud in development.
- parseMissingKeyHandler: (key: string) => {
+ // A key the catalog has not caught up with must not render as itself ("status.invoiced") or
+ // swallow the caller's English fallback. Both rules live in missingKeyFallback, which the
+ // suite exercises directly; this only adds the development warning.
+ parseMissingKeyHandler: (key: string, defaultValue?: string | null) => {
if (import.meta.env.DEV) {
console.warn(`[i18n] missing key: ${key}`);
}
- const segment = key.split(/[.:]/).pop() ?? key;
- return segment.charAt(0).toUpperCase() + segment.slice(1);
+ return missingKeyFallback(key, defaultValue);
},
detection: {
// NO cookie — localStorage only (the library default; key i18nextLng).
diff --git a/clients/admin/src/lib/i18n-fallback.ts b/clients/admin/src/lib/i18n-fallback.ts
new file mode 100644
index 0000000000..007e3d140e
--- /dev/null
+++ b/clients/admin/src/lib/i18n-fallback.ts
@@ -0,0 +1,20 @@
+/**
+ * What renders when a key is not in the catalog.
+ *
+ * Two kinds of caller end up here. One builds the key from a server value (`status.${x}`) and has
+ * nothing better to show than the value itself, so it degrades to the last segment — the readable
+ * name the UI showed before that surface was localized. The other passes an English `defaultValue`
+ * and expects it back.
+ *
+ * i18next hands `parseMissingKeyHandler` both the key and the `defaultValue`, and whatever the
+ * handler returns is what renders — a handler that only looks at the key silently replaces every
+ * fallback in the app with a truncation of its own key ("Create users" becomes "Create").
+ */
+export function missingKeyFallback(key: string, defaultValue?: string | null): string {
+ if (typeof defaultValue === "string") {
+ return defaultValue;
+ }
+
+ const segment = key.split(/[.:]/).pop() ?? key;
+ return segment.charAt(0).toUpperCase() + segment.slice(1);
+}
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á");
+ });
+});
From 429c9a834158f3eb92b2f3a1e57be1ab79697857 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:46:05 -0300
Subject: [PATCH 13/16] fix(admin): number formatting, plurals and the upload
errors review found
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three things a PR claiming i18n coverage should not have left.
**Counts were interpolated raw.** `{{count}}` and the page/total placeholders put
the number in with no formatter, so a Portuguese UI read "1234" next to currency
and dates that were correctly grouped. i18next's own `number` formatter runs Intl
with the active language, so the fix is per catalog entry: every `count` (it is
the plural selector, so it is always numeric) plus the named ones checked one at a
time. Two are deliberately left alone — the files dropzone interpolates already
formatted byte sizes, and the activity page pre-formats its own count.
**The list header pluralized in English.** `EntityPageHeader` rendered
`${unit}s`, and four pages passed the unit as an English literal. "organização" +
"s" is not a word. It takes the `unit.*` plural keys now, the way the dashboard's
header already does, with the token typed as a union so an already translated word
cannot be handed to it and silently render as a missing key.
**Upload failures reached the user in English.** Cancel, transport failure, a
rejected PUT, a blocked extension and an oversize file were built as English
prose inside the hook and in module-scope XHR handlers. They now raise an
`UploadError` carrying a catalog key (namespaced, since the resolver runs with
whatever `t` the display site is bound to), and one exported resolver turns it
into text at every place that shows it.
The new `format.spec.ts` case asserts both halves of the count chip on a real
list under pt-BR: reverting either the formatter or the plural keys turns it red.
---
.../admin/src/components/file/image-input.tsx | 13 ++---
.../components/list/entity-page-header.tsx | 20 ++++++-
clients/admin/src/hooks/use-file-upload.ts | 57 +++++++++++++++----
clients/admin/src/locales/en-US/audits.json | 6 +-
clients/admin/src/locales/en-US/billing.json | 20 +++----
clients/admin/src/locales/en-US/common.json | 27 ++++++++-
.../admin/src/locales/en-US/dashboard.json | 4 +-
clients/admin/src/locales/en-US/health.json | 2 +-
.../src/locales/en-US/notifications.json | 10 ++--
clients/admin/src/locales/en-US/roles.json | 12 ++--
clients/admin/src/locales/en-US/sessions.json | 16 +++---
clients/admin/src/locales/en-US/tenants.json | 10 ++--
clients/admin/src/locales/en-US/users.json | 19 +++----
clients/admin/src/locales/en-US/webhooks.json | 15 ++---
clients/admin/src/locales/pt-BR/audits.json | 5 +-
clients/admin/src/locales/pt-BR/billing.json | 20 +++----
clients/admin/src/locales/pt-BR/common.json | 27 ++++++++-
.../admin/src/locales/pt-BR/dashboard.json | 4 +-
clients/admin/src/locales/pt-BR/health.json | 2 +-
.../src/locales/pt-BR/notifications.json | 10 ++--
clients/admin/src/locales/pt-BR/roles.json | 12 ++--
clients/admin/src/locales/pt-BR/sessions.json | 16 +++---
clients/admin/src/locales/pt-BR/tenants.json | 10 ++--
clients/admin/src/locales/pt-BR/users.json | 17 +++---
clients/admin/src/locales/pt-BR/webhooks.json | 15 ++---
clients/admin/src/pages/audits/list.tsx | 2 +-
.../admin/src/pages/notifications/inbox.tsx | 2 +-
clients/admin/src/pages/users/detail.tsx | 2 +-
clients/admin/src/pages/webhooks/list.tsx | 2 +-
clients/admin/tests/i18n/format.spec.ts | 14 +++++
30 files changed, 241 insertions(+), 150 deletions(-)
diff --git a/clients/admin/src/components/file/image-input.tsx b/clients/admin/src/components/file/image-input.tsx
index a36c43106f..476b41edd8 100644
--- a/clients/admin/src/components/file/image-input.tsx
+++ b/clients/admin/src/components/file/image-input.tsx
@@ -6,9 +6,8 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/cn";
-import { useFileUpload, formatBytes } from "@/hooks/use-file-upload";
+import { useFileUpload, formatBytes, describeUploadError } from "@/hooks/use-file-upload";
import { getFileMetadata, Visibility } from "@/api/files";
-import { ApiRequestError } from "@/lib/api-client";
type Props = {
/** Current image URL (or empty). The component is fully controlled. */
@@ -83,13 +82,9 @@ export function ImageInput({
// Clear progress so the dropzone re-arms for another upload.
setTimeout(reset, 1500);
} catch (e) {
- const message =
- e instanceof ApiRequestError
- ? (e.problem?.detail ?? e.problem?.title ?? e.message)
- : e instanceof Error
- ? e.message
- : t("imageInput.uploadFailed");
- toast.error(message);
+ // describeUploadError resolves the catalog key an UploadError carries; anything else is
+ // prose the API already localized.
+ toast.error(describeUploadError(e, t, t("imageInput.uploadFailed")));
}
};
input.click();
diff --git a/clients/admin/src/components/list/entity-page-header.tsx b/clients/admin/src/components/list/entity-page-header.tsx
index a6aecee23f..e2c6c577a3 100644
--- a/clients/admin/src/components/list/entity-page-header.tsx
+++ b/clients/admin/src/components/list/entity-page-header.tsx
@@ -1,5 +1,6 @@
import * as React from "react";
import type { LucideIcon } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { ToneIconTile, type ToneIconTileTone } from "./tone-icon-tile";
// ───────────────────────────────────────────────────────────────────────
@@ -9,6 +10,20 @@ import { ToneIconTile, type ToneIconTileTone } from "./tone-icon-tile";
// unified header rhythm used across the dashboard app.
// ───────────────────────────────────────────────────────────────────────
+/** The `unit.*` plural keys common.json declares. A union rather than `string` because the chip
+ * translates the token itself: handing it an already translated word builds a key that exists in
+ * no catalogue and the header renders the raw key. The count cannot be pluralized here either —
+ * the previous `${unit}s` is an English rule, and "organização" + "s" is not a word. */
+export type EntityUnit =
+ | "account"
+ | "event"
+ | "grant"
+ | "item"
+ | "notification"
+ | "role"
+ | "subscription"
+ | "tenant";
+
export function EntityPageHeader({
icon,
title,
@@ -25,11 +40,12 @@ export function EntityPageHeader({
* fights the page's own accent. */
tone?: ToneIconTileTone;
total?: number | null;
- unit?: string;
+ unit?: EntityUnit;
description?: React.ReactNode;
/** Action buttons rendered on the right (stack full-width on mobile). */
children?: React.ReactNode;
}) {
+ const { t } = useTranslation();
return (
@@ -41,7 +57,7 @@ export function EntityPageHeader({
{total !== undefined && total !== null && (
- {total} {total === 1 ? unit : `${unit}s`}
+ {t(`unit.${unit}`, { count: total })}
)}
diff --git a/clients/admin/src/hooks/use-file-upload.ts b/clients/admin/src/hooks/use-file-upload.ts
index 4ed92c4c56..757192a802 100644
--- a/clients/admin/src/hooks/use-file-upload.ts
+++ b/clients/admin/src/hooks/use-file-upload.ts
@@ -1,4 +1,6 @@
import { useCallback, useRef, useState } from "react";
+import type { TFunction } from "i18next";
+import { useTranslation } from "react-i18next";
import {
finalizeUpload,
requestUploadUrl,
@@ -74,7 +76,26 @@ const DEFAULT_OPTIONS = {
* Progress is reported via the in-state `progress` snapshot — XMLHttpRequest is used (instead of fetch)
* because the Streams API for `fetch` upload progress is still gated behind flags on most browsers.
*/
+/**
+ * An upload failure the client itself produced (cancel, transport, a non-2xx PUT). It carries the
+ * catalog key instead of prose: these are raised in module-scope callbacks and in an XHR handler,
+ * where no `t` exists, and they are shown to the user rather than logged.
+ */
+export class UploadError extends Error {
+ readonly messageKey: string;
+
+ readonly params?: Record;
+
+ constructor(messageKey: string, params?: Record) {
+ super(messageKey);
+ this.name = "UploadError";
+ this.messageKey = messageKey;
+ this.params = params;
+ }
+}
+
export function useFileUpload(options: UploadOptions): UseFileUploadResult {
+ const { t } = useTranslation("common");
const [progress, setProgress] = useState(null);
const xhrRef = useRef(null);
const cancelledRef = useRef(false);
@@ -100,7 +121,7 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult {
const dot = file.name.lastIndexOf(".");
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
if (!opts.allowedExtensions.includes(ext)) {
- const message = `Extension ${ext || "(none)"} is not allowed.`;
+ const message = t("common:upload.extensionNotAllowed", { extension: ext || t("common:upload.noExtension") });
setProgress({
fileName: file.name,
totalBytes: file.size,
@@ -113,7 +134,10 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult {
}
}
if (opts.maxBytes !== undefined && file.size > opts.maxBytes) {
- const message = `File is ${formatBytes(file.size)}; limit is ${formatBytes(opts.maxBytes)}.`;
+ const message = t("common:upload.tooLarge", {
+ size: formatBytes(file.size),
+ limit: formatBytes(opts.maxBytes),
+ });
setProgress({
fileName: file.name,
totalBytes: file.size,
@@ -150,13 +174,13 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult {
try {
presigned = await requestUploadUrl(requestInput);
} catch (e) {
- const message = describeError(e);
+ const message = describeUploadError(e, t);
setProgress((p) =>
p ? { ...p, status: "error", error: message } : null,
);
throw e;
}
- if (cancelledRef.current) throw new Error("Upload cancelled.");
+ if (cancelledRef.current) throw new UploadError("common:upload.cancelled");
setProgress((p) =>
p ? { ...p, status: "uploading", fileAssetId: presigned.fileAssetId } : null,
@@ -180,11 +204,11 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult {
xhrRef.current = xhr;
});
} catch (e) {
- const message = describeError(e);
+ const message = describeUploadError(e, t);
setProgress((p) => (p ? { ...p, status: "error", error: message } : null));
throw e instanceof Error ? e : new Error(message);
}
- if (cancelledRef.current) throw new Error("Upload cancelled.");
+ if (cancelledRef.current) throw new UploadError("common:upload.cancelled");
setProgress((p) => (p ? { ...p, status: "finalizing", percent: 99 } : null));
@@ -193,7 +217,7 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult {
try {
dto = await finalizeUpload(presigned.fileAssetId);
} catch (e) {
- const message = describeError(e);
+ const message = describeUploadError(e, t);
setProgress((p) => (p ? { ...p, status: "error", error: message } : null));
throw e;
}
@@ -237,20 +261,29 @@ function xhrPut(
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
- else reject(new Error(`PUT failed: ${xhr.status} ${xhr.statusText || ""}`.trim()));
+ else reject(new UploadError("common:upload.putFailed", { status: `${xhr.status} ${xhr.statusText || ""}`.trim() }));
};
- xhr.onerror = () => reject(new Error("Network error during upload."));
- xhr.onabort = () => reject(new Error("Upload cancelled."));
+ xhr.onerror = () => reject(new UploadError("common:upload.networkError"));
+ xhr.onabort = () => reject(new UploadError("common:upload.cancelled"));
xhr.send(body);
});
}
-function describeError(e: unknown): string {
+/**
+ * Turns whatever came back into something worth showing. An UploadError carries a catalog key
+ * because it is raised where no translator is in scope; a server error already carries prose the
+ * API localized for this caller, so it is passed through as-is.
+ */
+export function describeUploadError(e: unknown, t: TFunction, fallback?: string): string {
+ if (e instanceof UploadError) {
+ return t(e.messageKey, { ...e.params, defaultValue: e.messageKey });
+ }
+
if (e instanceof ApiRequestError) {
return e.problem?.detail ?? e.problem?.title ?? e.message;
}
if (e instanceof Error) return e.message;
- return "Unknown error";
+ return fallback ?? t("common:upload.unknownError");
}
export function formatBytes(bytes: number): string {
diff --git a/clients/admin/src/locales/en-US/audits.json b/clients/admin/src/locales/en-US/audits.json
index 93f08d8425..bc5c4c30cc 100644
--- a/clients/admin/src/locales/en-US/audits.json
+++ b/clients/admin/src/locales/en-US/audits.json
@@ -1,6 +1,5 @@
{
"list.title": "Audit trail",
- "list.unit": "event",
"list.description": "Every security action, entity change, and exception captured by the auditing pipeline. Filter by event type, severity, or correlation id to follow a request end-to-end.",
"list.refresh": "Refresh",
"list.stat.total": "Total events",
@@ -11,7 +10,7 @@
"list.stat.securityHint": "logins, role grants, tokens",
"list.stat.exceptions": "Exceptions",
"list.stat.exceptionsHint": "unhandled / classified",
- "list.clearAll": "Clear all ({{count}})",
+ "list.clearAll": "Clear all ({{count, number}})",
"list.searchPlaceholder": "Search user, source, correlation…",
"list.searchAria": "Search audit trail",
"list.allEventTypes": "All event types",
@@ -29,7 +28,6 @@
"list.empty.clearFilters": "Clear filters",
"list.noun": "events",
"row.corr": "corr · {{id}}",
-
"detail.eventTitle": "{{type}} event",
"detail.eventFallback": "Audit event",
"detail.loadingTs": "Loading…",
@@ -54,7 +52,7 @@
"detail.tile.source": "Source",
"detail.tile.tags": "Tags",
"detail.payload": "Payload",
- "detail.payloadLines": "· {{count}} lines",
+ "detail.payloadLines": "· {{count, number}} lines",
"detail.copied": "Copied",
"detail.copyShort": "Copy"
}
diff --git a/clients/admin/src/locales/en-US/billing.json b/clients/admin/src/locales/en-US/billing.json
index 291a029c87..14c1b10f1e 100644
--- a/clients/admin/src/locales/en-US/billing.json
+++ b/clients/admin/src/locales/en-US/billing.json
@@ -38,11 +38,11 @@
"label.decided": "decided",
"pagination.previous": "Previous",
"pagination.next": "Next",
- "pagination.pageOf": "Page {{page}} / {{total}}",
+ "pagination.pageOf": "Page {{page}} / {{total, number}}",
"plans.stat.plans": "Plans",
- "plans.stat.activeCount": "{{count}} active",
+ "plans.stat.activeCount": "{{count, number}} active",
"plans.stat.active": "Active",
- "plans.stat.inactiveCount": "{{count}} inactive",
+ "plans.stat.inactiveCount": "{{count, number}} inactive",
"plans.stat.allActive": "all active",
"plans.stat.averageBase": "Average base",
"plans.stat.averageHint": "monthly subscription fee",
@@ -54,15 +54,15 @@
"plans.editAria": "Edit {{name}}",
"plans.loadError": "Failed to load plans.",
"invoices.kpi.pageInvoices": "Page invoices",
- "invoices.kpi.totalCount": "{{count}} total",
+ "invoices.kpi.totalCount": "{{count, number}} total",
"invoices.kpi.loading": "loading…",
"invoices.kpi.billed": "Billed",
"invoices.kpi.thisPage": "this page",
"invoices.kpi.outstanding": "Outstanding",
"invoices.kpi.outstandingHint": "issued, awaiting payment",
"invoices.kpi.paid": "Paid",
- "invoices.kpi.paidCount_one": "{{count}} invoice",
- "invoices.kpi.paidCount_other": "{{count}} invoices",
+ "invoices.kpi.paidCount_one": "{{count, number}} invoice",
+ "invoices.kpi.paidCount_other": "{{count, number}} invoices",
"invoices.filters.title": "Filters",
"invoices.filters.description": "All filters are AND-combined. Period is matched exactly (year + month).",
"invoices.filters.clear": "Clear",
@@ -72,7 +72,7 @@
"invoices.filters.year": "Year",
"invoices.filters.month": "Month",
"invoices.list.title": "Invoices",
- "invoices.list.summary": "Page {{page}} of {{total}} · {{count}} total",
+ "invoices.list.summary": "Page {{page}} of {{total, number}} · {{count, number}} total",
"invoices.list.loading": "Loading…",
"invoices.list.empty": "No invoices match the current filters.",
"invoices.loadError": "Failed to load invoices.",
@@ -94,8 +94,8 @@
"invoiceDetail.toast.voidFailedDesc": "Could not void invoice.",
"invoiceDetail.loadError": "Failed to load invoice.",
"invoiceDetail.lineItems.title": "Line items",
- "invoiceDetail.lineItems.count_one": "{{count}} line",
- "invoiceDetail.lineItems.count_other": "{{count}} lines",
+ "invoiceDetail.lineItems.count_one": "{{count, number}} line",
+ "invoiceDetail.lineItems.count_other": "{{count, number}} lines",
"invoiceDetail.lineItems.unavailable": "Unavailable",
"invoiceDetail.lineItems.loading": "Loading…",
"invoiceDetail.lineItems.loadError": "Failed to load line items.",
@@ -119,7 +119,7 @@
"invoiceDetail.void.submitting": "Voiding…",
"invoiceDetail.notes": "Notes",
"topups.kpi.pageRequests": "Page requests",
- "topups.kpi.totalCount": "{{count}} total",
+ "topups.kpi.totalCount": "{{count, number}} total",
"topups.kpi.loading": "loading…",
"topups.kpi.pending": "Pending",
"topups.kpi.pendingHint": "awaiting decision (this page)",
diff --git a/clients/admin/src/locales/en-US/common.json b/clients/admin/src/locales/en-US/common.json
index 8f518ae99a..d9254e986f 100644
--- a/clients/admin/src/locales/en-US/common.json
+++ b/clients/admin/src/locales/en-US/common.json
@@ -36,7 +36,7 @@
"dialog.close": "Close",
"confirm.confirm": "Confirm",
"confirm.working": "Working…",
- "pagination.showing": "Showing {{shown}} of {{total}} {{noun}} · folio {{page}} / {{pages}}",
+ "pagination.showing": "Showing {{shown, number}} of {{total, number}} {{noun}} · folio {{page}} / {{pages}}",
"pagination.previous": "Previous",
"pagination.next": "Next",
"pagination.items": "items",
@@ -64,5 +64,28 @@
"imageInput.formats": "JPG/PNG/WebP/GIF · up to {{size}}",
"imageInput.directLink": "Direct link to an image you host elsewhere.",
"language.saveFailed": "Language not saved",
- "language.saveFailedDetail": "The app is now in the language you picked, but the choice could not be saved and will not survive a sign-out."
+ "language.saveFailedDetail": "The app is now in the language you picked, but the choice could not be saved and will not survive a sign-out.",
+ "upload.cancelled": "Upload cancelled.",
+ "upload.networkError": "Network error during upload.",
+ "upload.putFailed": "Upload rejected by storage ({{status}}).",
+ "upload.unknownError": "Unknown error.",
+ "upload.extensionNotAllowed": "Extension {{extension}} is not allowed.",
+ "upload.tooLarge": "File is {{size}}; limit is {{limit}}.",
+ "upload.noExtension": "(none)",
+ "unit.account_one": "{{count, number}} account",
+ "unit.account_other": "{{count, number}} accounts",
+ "unit.event_one": "{{count, number}} event",
+ "unit.event_other": "{{count, number}} events",
+ "unit.grant_one": "{{count, number}} grant",
+ "unit.grant_other": "{{count, number}} grants",
+ "unit.item_one": "{{count, number}} item",
+ "unit.item_other": "{{count, number}} items",
+ "unit.notification_one": "{{count, number}} notification",
+ "unit.notification_other": "{{count, number}} notifications",
+ "unit.role_one": "{{count, number}} role",
+ "unit.role_other": "{{count, number}} roles",
+ "unit.subscription_one": "{{count, number}} subscription",
+ "unit.subscription_other": "{{count, number}} subscriptions",
+ "unit.tenant_one": "{{count, number}} tenant",
+ "unit.tenant_other": "{{count, number}} tenants"
}
diff --git a/clients/admin/src/locales/en-US/dashboard.json b/clients/admin/src/locales/en-US/dashboard.json
index cb86d95c2e..eda6906f7f 100644
--- a/clients/admin/src/locales/en-US/dashboard.json
+++ b/clients/admin/src/locales/en-US/dashboard.json
@@ -5,9 +5,9 @@
"stat.tenants": "Tenants",
"stat.tenantsHint": "registered on this instance",
"stat.plans": "Plans",
- "stat.plansActive": "{{count}} active",
+ "stat.plansActive": "{{count, number}} active",
"stat.invoices": "Invoices",
- "stat.invoicesHint": "{{count}} total ledger",
+ "stat.invoicesHint": "{{count, number}} total ledger",
"stat.invoicesLoading": "loading…",
"stat.outstanding": "Outstanding",
"stat.outstandingHint": "issued, awaiting payment",
diff --git a/clients/admin/src/locales/en-US/health.json b/clients/admin/src/locales/en-US/health.json
index 9666c06efc..b33d108c9f 100644
--- a/clients/admin/src/locales/en-US/health.json
+++ b/clients/admin/src/locales/en-US/health.json
@@ -11,7 +11,7 @@
"stat.readiness": "Readiness",
"stat.readinessHint": "Dependencies reachable",
"stat.checksHealthy": "Checks healthy",
- "stat.checksHealthyHint": "of {{total}} registered",
+ "stat.checksHealthyHint": "of {{total, number}} registered",
"stat.checksFailing": "Checks failing",
"stat.checksFailingHint": "{{failing}} unhealthy",
"stat.checksFailingHintDegraded": "{{degraded}} degraded · {{failing}} unhealthy",
diff --git a/clients/admin/src/locales/en-US/notifications.json b/clients/admin/src/locales/en-US/notifications.json
index 077a7c8795..8a06377dbf 100644
--- a/clients/admin/src/locales/en-US/notifications.json
+++ b/clients/admin/src/locales/en-US/notifications.json
@@ -1,6 +1,5 @@
{
"inbox.title": "Notifications",
- "inbox.unit": "item",
"inbox.description": "Events the system has surfaced for you. Live-updates as new notifications arrive — no refresh needed.",
"inbox.refresh": "Refresh",
"inbox.marking": "Marking…",
@@ -18,11 +17,10 @@
"row.markRead": "Mark read",
"toast.markReadFailed": "Mark read failed",
"toast.markAllFailed": "Mark all failed",
- "toast.marked_one": "{{count}} notification marked read",
- "toast.marked_other": "{{count}} notifications marked read",
-
- "bell.ariaUnread_one": "{{count}} unread notification",
- "bell.ariaUnread_other": "{{count}} unread notifications",
+ "toast.marked_one": "{{count, number}} notification marked read",
+ "toast.marked_other": "{{count, number}} notifications marked read",
+ "bell.ariaUnread_one": "{{count, number}} unread notification",
+ "bell.ariaUnread_other": "{{count, number}} unread notifications",
"bell.ariaDefault": "Notifications",
"bell.heading": "// Notifications",
"bell.markAllRead": "Mark all read",
diff --git a/clients/admin/src/locales/en-US/roles.json b/clients/admin/src/locales/en-US/roles.json
index 5f48e55547..ff4a7ef753 100644
--- a/clients/admin/src/locales/en-US/roles.json
+++ b/clients/admin/src/locales/en-US/roles.json
@@ -12,15 +12,15 @@
"empty.kicker": "// no roles",
"empty.title": "No roles defined yet.",
"empty.description": "Create your first role to start bundling permissions.",
- "found_one": "{{count}} role found",
- "found_other": "{{count}} roles found",
+ "found_one": "{{count, number}} role found",
+ "found_other": "{{count, number}} roles found",
"col.name": "Name",
"col.description": "Description",
"col.permissions": "Permissions",
"system": "System",
"noDescription": "No description on file.",
- "permCount_one": "{{count}} permission",
- "permCount_other": "{{count}} permissions",
+ "permCount_one": "{{count, number}} permission",
+ "permCount_other": "{{count, number}} permissions",
"openRole": "Open role {{name}}",
"detail.titleFallback": "Role",
"detail.descriptionFallback": "Inspect and edit this role's profile and permission grants.",
@@ -42,8 +42,8 @@
"detail.validation.min2": "At least 2 characters.",
"detail.perm.title": "Permissions",
"detail.perm.description": "Pick what holders of this role can do. Root-only permissions take effect only on roles assigned in the root tenant.",
- "detail.perm.unsaved": "Unsaved changes · {{granted}} of {{total}} granted",
- "detail.perm.allSaved": "All changes saved · {{granted}} of {{total}} granted",
+ "detail.perm.unsaved": "Unsaved changes · {{granted, number}} of {{total, number}} granted",
+ "detail.perm.allSaved": "All changes saved · {{granted, number}} of {{total, number}} granted",
"detail.perm.discard": "Discard",
"detail.perm.saving": "Saving…",
"detail.perm.save": "Save permissions",
diff --git a/clients/admin/src/locales/en-US/sessions.json b/clients/admin/src/locales/en-US/sessions.json
index d45f5ab000..dd3ded915a 100644
--- a/clients/admin/src/locales/en-US/sessions.json
+++ b/clients/admin/src/locales/en-US/sessions.json
@@ -4,14 +4,14 @@
"revoked": "Session revoked",
"revokeFailed": "Revoke failed",
"revokeAllFailed": "Revoke all failed",
- "revokedOther_one": "Revoked {{count}} other session",
- "revokedOther_other": "Revoked {{count}} other sessions",
- "revokedCount_one": "Revoked {{count}} session",
- "revokedCount_other": "Revoked {{count}} sessions",
+ "revokedOther_one": "Revoked {{count, number}} other session",
+ "revokedOther_other": "Revoked {{count, number}} other sessions",
+ "revokedCount_one": "Revoked {{count, number}} session",
+ "revokedCount_other": "Revoked {{count, number}} sessions",
"active.title": "Active sessions",
"active.description": "Every browser or device currently signed into your account. Revoking a session signs that device out within ~10 seconds.",
- "otherActiveWarn_one": "{{count}} other session is active. Sign them all out at once if you suspect an account compromise.",
- "otherActiveWarn_other": "{{count}} other sessions are active. Sign them all out at once if you suspect an account compromise.",
+ "otherActiveWarn_one": "{{count, number}} other session is active. Sign them all out at once if you suspect an account compromise.",
+ "otherActiveWarn_other": "{{count, number}} other sessions are active. Sign them all out at once if you suspect an account compromise.",
"signingOut": "Signing out…",
"signOutEverywhere": "Sign out everywhere else",
"noneFound": "No active sessions found. (Including this one? That would be a bug — please refresh.)",
@@ -35,8 +35,8 @@
"card.title": "Sessions",
"card.description": "Active browser/device sessions for this user. Revoking signs the device out within ~10 seconds.",
"card.noneOnRecord": "No sessions on record for this user.",
- "card.activeCount_one": "{{count}} active session",
- "card.activeCount_other": "{{count}} active sessions",
+ "card.activeCount_one": "{{count, number}} active session",
+ "card.activeCount_other": "{{count, number}} active sessions",
"card.revokeAll": "Revoke all sessions",
"card.loadingLabel": "Loading"
}
diff --git a/clients/admin/src/locales/en-US/tenants.json b/clients/admin/src/locales/en-US/tenants.json
index 2a0aff572c..f8824429c7 100644
--- a/clients/admin/src/locales/en-US/tenants.json
+++ b/clients/admin/src/locales/en-US/tenants.json
@@ -1,19 +1,19 @@
{
"list.header.title": "Registry",
- "list.header.count_one": "{{count}} tenant registered on this instance.",
- "list.header.count_other": "{{count}} tenants registered on this instance.",
+ "list.header.count_one": "{{count, number}} tenant registered on this instance.",
+ "list.header.count_other": "{{count, number}} tenants registered on this instance.",
"list.header.loadingRegistry": "Loading the registry…",
"list.newTenant": "New tenant",
"list.loadError": "Failed to load tenants.",
"list.loading": "Loading…",
"list.empty.title": "No tenants yet.",
"list.empty.description": "Provision the first tenant to get started.",
- "list.found_one": "{{count}} tenant registered",
- "list.found_other": "{{count}} tenants registered",
+ "list.found_one": "{{count, number}} tenant registered",
+ "list.found_other": "{{count, number}} tenants registered",
"list.col.tenant": "Tenant",
"list.col.adminEmail": "Admin email",
"list.col.status": "Status",
- "list.page": "Page {{page}} of {{total}}",
+ "list.page": "Page {{page}} of {{total, number}}",
"list.previous": "Previous",
"list.next": "Next",
"list.status.active": "Active",
diff --git a/clients/admin/src/locales/en-US/users.json b/clients/admin/src/locales/en-US/users.json
index 5bc6fb0d1d..0c77a7ea9e 100644
--- a/clients/admin/src/locales/en-US/users.json
+++ b/clients/admin/src/locales/en-US/users.json
@@ -1,7 +1,7 @@
{
"header.title": "Directory",
- "header.count_one": "{{count}} account on this tenant.",
- "header.count_other": "{{count}} accounts on this tenant.",
+ "header.count_one": "{{count, number}} account on this tenant.",
+ "header.count_other": "{{count, number}} accounts on this tenant.",
"header.loadingRoster": "Loading the roster…",
"newUser": "New user",
"search.placeholder": "Search name, username, email…",
@@ -21,9 +21,9 @@
"empty.withFilters": "Adjust filters or invite a new user.",
"empty.noData": "Register the first member to seed this tenant.",
"clearFilters": "Clear filters",
- "found_one": "{{count}} user found",
- "found_other": "{{count}} users found",
- "page": "Page {{page}} of {{total}}",
+ "found_one": "{{count, number}} user found",
+ "found_other": "{{count, number}} users found",
+ "page": "Page {{page}} of {{total, number}}",
"previous": "Previous",
"next": "Next",
"col.name": "Name",
@@ -39,7 +39,6 @@
"badge.emailPending": "Email pending",
"badge.confirmed": "Confirmed",
"badge.pending": "Pending",
-
"detail.back": "Directory",
"detail.loading": "Loading account",
"detail.badge.active": "Active",
@@ -65,8 +64,8 @@
"detail.status.yes": "Yes",
"detail.status.pendingConfirmation": "Pending confirmation",
"detail.roles.title": "Role assignment",
- "detail.roles.pending_one": "{{count}} pending change — review and save when ready.",
- "detail.roles.pending_other": "{{count}} pending changes — review and save when ready.",
+ "detail.roles.pending_one": "{{count, number}} pending change — review and save when ready.",
+ "detail.roles.pending_other": "{{count, number}} pending changes — review and save when ready.",
"detail.roles.hint": "Tap any role to toggle. Changes are batched — review and save when ready.",
"detail.roles.saving": "Saving…",
"detail.roles.saveChanges": "Save changes",
@@ -79,7 +78,6 @@
"detail.roles.off": "Off",
"detail.roles.updated": "Roles updated",
"detail.roles.updateFailed": "Role update failed",
-
"create.title": "New account",
"create.description": "The new user is created in the current tenant and emailed a confirmation link. Roles can be assigned from the detail page after creation.",
"create.section.identity.title": "Identity",
@@ -104,5 +102,6 @@
"create.validation.username": "3–32 chars. Letters, digits, dot, dash, underscore. Start with a letter.",
"create.validation.email": "Enter a valid email.",
"create.validation.min8": "At least 8 characters.",
- "create.validation.mismatch": "Passwords don't match."
+ "create.validation.mismatch": "Passwords don't match.",
+ "detail.roles.modified": "modified"
}
diff --git a/clients/admin/src/locales/en-US/webhooks.json b/clients/admin/src/locales/en-US/webhooks.json
index 49b2f218c9..3ca1ee6a40 100644
--- a/clients/admin/src/locales/en-US/webhooks.json
+++ b/clients/admin/src/locales/en-US/webhooks.json
@@ -1,6 +1,5 @@
{
"list.title": "Webhooks",
- "list.unit": "subscription",
"list.description": "Subscribe HTTP endpoints to domain events. Payloads are signed with HMAC-SHA256 using the secret you provide — verify the X-FSH-Signature header on your side before trusting the body.",
"list.refresh": "Refresh",
"list.newSubscription": "New subscription",
@@ -11,7 +10,7 @@
"list.empty.description": "Add an endpoint and pick which events should fire. We'll retry failed deliveries automatically.",
"list.confirmDelete": "Delete subscription to {{url}}?",
"list.deleteAria": "Delete subscription to {{url}}",
- "list.moreEvents": "+{{count}} more",
+ "list.moreEvents": "+{{count, number}} more",
"list.since": "· since {{date}}",
"list.test": "Test",
"list.noun": "subscriptions",
@@ -26,13 +25,12 @@
"toast.deleted": "Subscription deleted",
"toast.deleteFailed": "Delete failed",
"toast.testRejected": "Endpoint rejected the test event",
-
"detail.crumb": "\\ Webhooks",
"detail.subscriptionFallback": "Subscription",
"detail.trailingActive": "ACTIVE",
"detail.trailingInactive": "INACTIVE",
- "detail.subscribed_one": "Subscribed to {{count}} event.",
- "detail.subscribed_other": "Subscribed to {{count}} events.",
+ "detail.subscribed_one": "Subscribed to {{count, number}} event.",
+ "detail.subscribed_other": "Subscribed to {{count, number}} events.",
"detail.loading": "Loading subscription…",
"detail.back": "Subscriptions",
"detail.loadError": "Failed to load subscription.",
@@ -53,7 +51,7 @@
"detail.events.none": "— no events; subscription would never fire",
"detail.deliveries.title": "Deliveries",
"detail.deliveries.description": "Recent attempts to POST events to this endpoint. Auto-refreshes every 10s.",
- "detail.deliveries.attempts": "{{count}} attempts",
+ "detail.deliveries.attempts": "{{count, number}} attempts",
"detail.deliveries.refresh": "Refresh",
"detail.deliveries.loadingRow": "Loading deliveries",
"detail.deliveries.empty": "No deliveries yet. Try the test button above, or wait for matching events to fire.",
@@ -61,15 +59,14 @@
"delivery.ok": "OK",
"delivery.failed": "Failed",
"delivery.http": "HTTP {{code}}",
- "delivery.try": "try {{count}}",
-
+ "delivery.try": "try {{count, number}}",
"create.title": "New webhook subscription",
"create.description": "Your endpoint receives a JSON payload with the event details. We sign each request with HMAC-SHA256 in the {{header}} header using the secret below — store it on your side and verify before trusting the body.",
"create.url": "Endpoint URL",
"create.secret": "Signing secret",
"create.secretHint": "Optional but recommended. At least 32 random characters. Used to compute the HMAC.",
"create.secretPlaceholder": "Leave blank to skip signing",
- "create.events": "Events ({{count}})",
+ "create.events": "Events ({{count, number}})",
"create.eventPlaceholderEmpty": "type an event name then Enter…",
"create.eventPlaceholderMore": "add another…",
"create.suggested": "suggested",
diff --git a/clients/admin/src/locales/pt-BR/audits.json b/clients/admin/src/locales/pt-BR/audits.json
index 22f518049e..d26d6b0a3f 100644
--- a/clients/admin/src/locales/pt-BR/audits.json
+++ b/clients/admin/src/locales/pt-BR/audits.json
@@ -1,6 +1,5 @@
{
"list.title": "Trilha de auditoria",
- "list.unit": "evento",
"list.description": "Toda ação de segurança, alteração de entidade e exceção capturada pela pipeline de auditoria. Filtre por tipo de evento, severidade ou id de correlação para acompanhar uma requisição de ponta a ponta.",
"list.refresh": "Atualizar",
"list.stat.total": "Total de eventos",
@@ -11,7 +10,7 @@
"list.stat.securityHint": "logins, concessões de papel, tokens",
"list.stat.exceptions": "Exceções",
"list.stat.exceptionsHint": "não tratadas / classificadas",
- "list.clearAll": "Limpar tudo ({{count}})",
+ "list.clearAll": "Limpar tudo ({{count, number}})",
"list.searchPlaceholder": "Buscar usuário, origem, correlação…",
"list.searchAria": "Buscar na trilha de auditoria",
"list.allEventTypes": "Todos os tipos de evento",
@@ -53,7 +52,7 @@
"detail.tile.source": "Origem",
"detail.tile.tags": "Tags",
"detail.payload": "Payload",
- "detail.payloadLines": "· {{count}} linhas",
+ "detail.payloadLines": "· {{count, number}} linhas",
"detail.copied": "Copiado",
"detail.copyShort": "Copiar"
}
diff --git a/clients/admin/src/locales/pt-BR/billing.json b/clients/admin/src/locales/pt-BR/billing.json
index 876724d8a1..a9518d01c5 100644
--- a/clients/admin/src/locales/pt-BR/billing.json
+++ b/clients/admin/src/locales/pt-BR/billing.json
@@ -38,11 +38,11 @@
"label.decided": "decidida",
"pagination.previous": "Anterior",
"pagination.next": "Próxima",
- "pagination.pageOf": "Página {{page}} / {{total}}",
+ "pagination.pageOf": "Página {{page}} / {{total, number}}",
"plans.stat.plans": "Planos",
- "plans.stat.activeCount": "{{count}} ativos",
+ "plans.stat.activeCount": "{{count, number}} ativos",
"plans.stat.active": "Ativos",
- "plans.stat.inactiveCount": "{{count}} inativos",
+ "plans.stat.inactiveCount": "{{count, number}} inativos",
"plans.stat.allActive": "todos ativos",
"plans.stat.averageBase": "Base média",
"plans.stat.averageHint": "mensalidade da assinatura",
@@ -54,15 +54,15 @@
"plans.editAria": "Editar {{name}}",
"plans.loadError": "Falha ao carregar os planos.",
"invoices.kpi.pageInvoices": "Faturas da página",
- "invoices.kpi.totalCount": "{{count}} no total",
+ "invoices.kpi.totalCount": "{{count, number}} no total",
"invoices.kpi.loading": "carregando…",
"invoices.kpi.billed": "Faturado",
"invoices.kpi.thisPage": "nesta página",
"invoices.kpi.outstanding": "Em aberto",
"invoices.kpi.outstandingHint": "emitidas, aguardando pagamento",
"invoices.kpi.paid": "Pagas",
- "invoices.kpi.paidCount_one": "{{count}} fatura",
- "invoices.kpi.paidCount_other": "{{count}} faturas",
+ "invoices.kpi.paidCount_one": "{{count, number}} fatura",
+ "invoices.kpi.paidCount_other": "{{count, number}} faturas",
"invoices.filters.title": "Filtros",
"invoices.filters.description": "Todos os filtros são combinados com E. O período é comparado exatamente (ano + mês).",
"invoices.filters.clear": "Limpar",
@@ -72,7 +72,7 @@
"invoices.filters.year": "Ano",
"invoices.filters.month": "Mês",
"invoices.list.title": "Faturas",
- "invoices.list.summary": "Página {{page}} de {{total}} · {{count}} no total",
+ "invoices.list.summary": "Página {{page}} de {{total, number}} · {{count, number}} no total",
"invoices.list.loading": "Carregando…",
"invoices.list.empty": "Nenhuma fatura corresponde aos filtros atuais.",
"invoices.loadError": "Falha ao carregar as faturas.",
@@ -94,8 +94,8 @@
"invoiceDetail.toast.voidFailedDesc": "Não foi possível anular a fatura.",
"invoiceDetail.loadError": "Falha ao carregar a fatura.",
"invoiceDetail.lineItems.title": "Itens",
- "invoiceDetail.lineItems.count_one": "{{count}} item",
- "invoiceDetail.lineItems.count_other": "{{count}} itens",
+ "invoiceDetail.lineItems.count_one": "{{count, number}} item",
+ "invoiceDetail.lineItems.count_other": "{{count, number}} itens",
"invoiceDetail.lineItems.unavailable": "Indisponível",
"invoiceDetail.lineItems.loading": "Carregando…",
"invoiceDetail.lineItems.loadError": "Falha ao carregar os itens.",
@@ -119,7 +119,7 @@
"invoiceDetail.void.submitting": "Anulando…",
"invoiceDetail.notes": "Observações",
"topups.kpi.pageRequests": "Solicitações da página",
- "topups.kpi.totalCount": "{{count}} no total",
+ "topups.kpi.totalCount": "{{count, number}} no total",
"topups.kpi.loading": "carregando…",
"topups.kpi.pending": "Pendentes",
"topups.kpi.pendingHint": "aguardando decisão (nesta página)",
diff --git a/clients/admin/src/locales/pt-BR/common.json b/clients/admin/src/locales/pt-BR/common.json
index c688fdd9c6..03a662551b 100644
--- a/clients/admin/src/locales/pt-BR/common.json
+++ b/clients/admin/src/locales/pt-BR/common.json
@@ -36,7 +36,7 @@
"dialog.close": "Fechar",
"confirm.confirm": "Confirmar",
"confirm.working": "Processando…",
- "pagination.showing": "Exibindo {{shown}} de {{total}} {{noun}} · fólio {{page}} / {{pages}}",
+ "pagination.showing": "Exibindo {{shown, number}} de {{total, number}} {{noun}} · fólio {{page}} / {{pages}}",
"pagination.previous": "Anterior",
"pagination.next": "Próxima",
"pagination.items": "itens",
@@ -64,5 +64,28 @@
"imageInput.formats": "JPG/PNG/WebP/GIF · até {{size}}",
"imageInput.directLink": "Link direto para uma imagem hospedada em outro lugar.",
"language.saveFailed": "Idioma não salvo",
- "language.saveFailedDetail": "O app está no idioma escolhido, mas não foi possível salvar a escolha, que não vai persistir depois que você sair da conta."
+ "language.saveFailedDetail": "O app está no idioma escolhido, mas não foi possível salvar a escolha, que não vai persistir depois que você sair da conta.",
+ "upload.cancelled": "Envio cancelado.",
+ "upload.networkError": "Erro de rede durante o envio.",
+ "upload.putFailed": "Envio recusado pelo armazenamento ({{status}}).",
+ "upload.unknownError": "Erro desconhecido.",
+ "upload.extensionNotAllowed": "A extensão {{extension}} não é permitida.",
+ "upload.tooLarge": "O arquivo tem {{size}}; o limite é {{limit}}.",
+ "upload.noExtension": "(nenhuma)",
+ "unit.account_one": "{{count, number}} conta",
+ "unit.account_other": "{{count, number}} contas",
+ "unit.event_one": "{{count, number}} evento",
+ "unit.event_other": "{{count, number}} eventos",
+ "unit.grant_one": "{{count, number}} permissão",
+ "unit.grant_other": "{{count, number}} permissões",
+ "unit.item_one": "{{count, number}} item",
+ "unit.item_other": "{{count, number}} itens",
+ "unit.notification_one": "{{count, number}} notificação",
+ "unit.notification_other": "{{count, number}} notificações",
+ "unit.role_one": "{{count, number}} papel",
+ "unit.role_other": "{{count, number}} papéis",
+ "unit.subscription_one": "{{count, number}} assinatura",
+ "unit.subscription_other": "{{count, number}} assinaturas",
+ "unit.tenant_one": "{{count, number}} organização",
+ "unit.tenant_other": "{{count, number}} organizações"
}
diff --git a/clients/admin/src/locales/pt-BR/dashboard.json b/clients/admin/src/locales/pt-BR/dashboard.json
index 7db04c526d..1e6817817c 100644
--- a/clients/admin/src/locales/pt-BR/dashboard.json
+++ b/clients/admin/src/locales/pt-BR/dashboard.json
@@ -5,9 +5,9 @@
"stat.tenants": "Organizações",
"stat.tenantsHint": "registrados nesta instância",
"stat.plans": "Planos",
- "stat.plansActive": "{{count}} ativos",
+ "stat.plansActive": "{{count, number}} ativos",
"stat.invoices": "Faturas",
- "stat.invoicesHint": "{{count}} no razão total",
+ "stat.invoicesHint": "{{count, number}} no razão total",
"stat.invoicesLoading": "carregando…",
"stat.outstanding": "Em aberto",
"stat.outstandingHint": "emitidas, aguardando pagamento",
diff --git a/clients/admin/src/locales/pt-BR/health.json b/clients/admin/src/locales/pt-BR/health.json
index b22ffba376..3ea7690d00 100644
--- a/clients/admin/src/locales/pt-BR/health.json
+++ b/clients/admin/src/locales/pt-BR/health.json
@@ -11,7 +11,7 @@
"stat.readiness": "Readiness",
"stat.readinessHint": "dependências acessíveis",
"stat.checksHealthy": "Verificações saudáveis",
- "stat.checksHealthyHint": "de {{total}} registradas",
+ "stat.checksHealthyHint": "de {{total, number}} registradas",
"stat.checksFailing": "Verificações falhando",
"stat.checksFailingHint": "{{failing}} não saudáveis",
"stat.checksFailingHintDegraded": "{{degraded}} degradadas · {{failing}} não saudáveis",
diff --git a/clients/admin/src/locales/pt-BR/notifications.json b/clients/admin/src/locales/pt-BR/notifications.json
index ef2ea60a3c..1794e58d3c 100644
--- a/clients/admin/src/locales/pt-BR/notifications.json
+++ b/clients/admin/src/locales/pt-BR/notifications.json
@@ -1,6 +1,5 @@
{
"inbox.title": "Notificações",
- "inbox.unit": "item",
"inbox.description": "Eventos que o sistema destacou para você. Atualiza ao vivo conforme novas notificações chegam — sem precisar recarregar.",
"inbox.refresh": "Atualizar",
"inbox.marking": "Marcando…",
@@ -18,11 +17,10 @@
"row.markRead": "Marcar como lida",
"toast.markReadFailed": "Falha ao marcar como lida",
"toast.markAllFailed": "Falha ao marcar todas",
- "toast.marked_one": "{{count}} notificação marcada como lida",
- "toast.marked_other": "{{count}} notificações marcadas como lidas",
-
- "bell.ariaUnread_one": "{{count}} notificação não lida",
- "bell.ariaUnread_other": "{{count}} notificações não lidas",
+ "toast.marked_one": "{{count, number}} notificação marcada como lida",
+ "toast.marked_other": "{{count, number}} notificações marcadas como lidas",
+ "bell.ariaUnread_one": "{{count, number}} notificação não lida",
+ "bell.ariaUnread_other": "{{count, number}} notificações não lidas",
"bell.ariaDefault": "Notificações",
"bell.heading": "// Notificações",
"bell.markAllRead": "Marcar todas como lidas",
diff --git a/clients/admin/src/locales/pt-BR/roles.json b/clients/admin/src/locales/pt-BR/roles.json
index 57c6e9935b..99b870d163 100644
--- a/clients/admin/src/locales/pt-BR/roles.json
+++ b/clients/admin/src/locales/pt-BR/roles.json
@@ -12,15 +12,15 @@
"empty.kicker": "// sem papéis",
"empty.title": "Nenhum papel definido ainda.",
"empty.description": "Crie seu primeiro papel para começar a agrupar permissões.",
- "found_one": "{{count}} papel encontrado",
- "found_other": "{{count}} papéis encontrados",
+ "found_one": "{{count, number}} papel encontrado",
+ "found_other": "{{count, number}} papéis encontrados",
"col.name": "Nome",
"col.description": "Descrição",
"col.permissions": "Permissões",
"system": "Sistema",
"noDescription": "Sem descrição cadastrada.",
- "permCount_one": "{{count}} permissão",
- "permCount_other": "{{count}} permissões",
+ "permCount_one": "{{count, number}} permissão",
+ "permCount_other": "{{count, number}} permissões",
"openRole": "Abrir papel {{name}}",
"detail.titleFallback": "Papel",
"detail.descriptionFallback": "Inspecione e edite o perfil deste papel e as permissões concedidas.",
@@ -42,8 +42,8 @@
"detail.validation.min2": "No mínimo 2 caracteres.",
"detail.perm.title": "Permissões",
"detail.perm.description": "Escolha o que os detentores deste papel podem fazer. Permissões exclusivas de root só têm efeito em papéis atribuídos na organização root.",
- "detail.perm.unsaved": "Alterações não salvas · {{granted}} de {{total}} concedidas",
- "detail.perm.allSaved": "Todas as alterações salvas · {{granted}} de {{total}} concedidas",
+ "detail.perm.unsaved": "Alterações não salvas · {{granted, number}} de {{total, number}} concedidas",
+ "detail.perm.allSaved": "Todas as alterações salvas · {{granted, number}} de {{total, number}} concedidas",
"detail.perm.discard": "Descartar",
"detail.perm.saving": "Salvando…",
"detail.perm.save": "Salvar permissões",
diff --git a/clients/admin/src/locales/pt-BR/sessions.json b/clients/admin/src/locales/pt-BR/sessions.json
index 68e3c2dcc9..a3e6b7652c 100644
--- a/clients/admin/src/locales/pt-BR/sessions.json
+++ b/clients/admin/src/locales/pt-BR/sessions.json
@@ -4,14 +4,14 @@
"revoked": "Sessão revogada",
"revokeFailed": "Falha ao revogar",
"revokeAllFailed": "Falha ao revogar todas",
- "revokedOther_one": "{{count}} outra sessão revogada",
- "revokedOther_other": "{{count}} outras sessões revogadas",
- "revokedCount_one": "{{count}} sessão revogada",
- "revokedCount_other": "{{count}} sessões revogadas",
+ "revokedOther_one": "{{count, number}} outra sessão revogada",
+ "revokedOther_other": "{{count, number}} outras sessões revogadas",
+ "revokedCount_one": "{{count, number}} sessão revogada",
+ "revokedCount_other": "{{count, number}} sessões revogadas",
"active.title": "Sessões ativas",
"active.description": "Todo navegador ou dispositivo conectado à sua conta neste momento. Revogar uma sessão desconecta aquele dispositivo em cerca de 10 segundos.",
- "otherActiveWarn_one": "{{count}} outra sessão está ativa. Desconecte todas de uma vez se suspeitar de comprometimento da conta.",
- "otherActiveWarn_other": "{{count}} outras sessões estão ativas. Desconecte todas de uma vez se suspeitar de comprometimento da conta.",
+ "otherActiveWarn_one": "{{count, number}} outra sessão está ativa. Desconecte todas de uma vez se suspeitar de comprometimento da conta.",
+ "otherActiveWarn_other": "{{count, number}} outras sessões estão ativas. Desconecte todas de uma vez se suspeitar de comprometimento da conta.",
"signingOut": "Desconectando…",
"signOutEverywhere": "Sair de todos os outros",
"noneFound": "Nenhuma sessão ativa encontrada. (Incluindo esta? Isso seria um bug — atualize a página.)",
@@ -35,8 +35,8 @@
"card.title": "Sessões",
"card.description": "Sessões ativas de navegador/dispositivo deste usuário. Revogar desconecta o dispositivo em cerca de 10 segundos.",
"card.noneOnRecord": "Nenhuma sessão registrada para este usuário.",
- "card.activeCount_one": "{{count}} sessão ativa",
- "card.activeCount_other": "{{count}} sessões ativas",
+ "card.activeCount_one": "{{count, number}} sessão ativa",
+ "card.activeCount_other": "{{count, number}} sessões ativas",
"card.revokeAll": "Revogar todas as sessões",
"card.loadingLabel": "Carregando"
}
diff --git a/clients/admin/src/locales/pt-BR/tenants.json b/clients/admin/src/locales/pt-BR/tenants.json
index dd8d9dd72b..9522ae41f9 100644
--- a/clients/admin/src/locales/pt-BR/tenants.json
+++ b/clients/admin/src/locales/pt-BR/tenants.json
@@ -1,19 +1,19 @@
{
"list.header.title": "Registro",
- "list.header.count_one": "{{count}} organização registrada nesta instância.",
- "list.header.count_other": "{{count}} organizações registradas nesta instância.",
+ "list.header.count_one": "{{count, number}} organização registrada nesta instância.",
+ "list.header.count_other": "{{count, number}} organizações registradas nesta instância.",
"list.header.loadingRegistry": "Carregando o registro…",
"list.newTenant": "Nova organização",
"list.loadError": "Falha ao carregar as organizações.",
"list.loading": "Carregando…",
"list.empty.title": "Nenhuma organização ainda.",
"list.empty.description": "Provisione a primeira organização para começar.",
- "list.found_one": "{{count}} organização registrada",
- "list.found_other": "{{count}} organizações registradas",
+ "list.found_one": "{{count, number}} organização registrada",
+ "list.found_other": "{{count, number}} organizações registradas",
"list.col.tenant": "Organização",
"list.col.adminEmail": "Email do admin",
"list.col.status": "Status",
- "list.page": "Página {{page}} de {{total}}",
+ "list.page": "Página {{page}} de {{total, number}}",
"list.previous": "Anterior",
"list.next": "Próxima",
"list.status.active": "Ativo",
diff --git a/clients/admin/src/locales/pt-BR/users.json b/clients/admin/src/locales/pt-BR/users.json
index f7dffbdde3..a000e1ce64 100644
--- a/clients/admin/src/locales/pt-BR/users.json
+++ b/clients/admin/src/locales/pt-BR/users.json
@@ -1,7 +1,7 @@
{
"header.title": "Diretório",
- "header.count_one": "{{count}} conta nesta organização.",
- "header.count_other": "{{count}} contas nesta organização.",
+ "header.count_one": "{{count, number}} conta nesta organização.",
+ "header.count_other": "{{count, number}} contas nesta organização.",
"header.loadingRoster": "Carregando a lista…",
"newUser": "Novo usuário",
"search.placeholder": "Buscar nome, usuário, email…",
@@ -21,9 +21,9 @@
"empty.withFilters": "Ajuste os filtros ou convide um novo usuário.",
"empty.noData": "Cadastre o primeiro membro para popular esta organização.",
"clearFilters": "Limpar filtros",
- "found_one": "{{count}} usuário encontrado",
- "found_other": "{{count}} usuários encontrados",
- "page": "Página {{page}} de {{total}}",
+ "found_one": "{{count, number}} usuário encontrado",
+ "found_other": "{{count, number}} usuários encontrados",
+ "page": "Página {{page}} de {{total, number}}",
"previous": "Anterior",
"next": "Próxima",
"col.name": "Nome",
@@ -64,8 +64,8 @@
"detail.status.yes": "Sim",
"detail.status.pendingConfirmation": "Confirmação pendente",
"detail.roles.title": "Atribuição de papéis",
- "detail.roles.pending_one": "{{count}} alteração pendente — revise e salve quando estiver pronto.",
- "detail.roles.pending_other": "{{count}} alterações pendentes — revise e salve quando estiver pronto.",
+ "detail.roles.pending_one": "{{count, number}} alteração pendente — revise e salve quando estiver pronto.",
+ "detail.roles.pending_other": "{{count, number}} alterações pendentes — revise e salve quando estiver pronto.",
"detail.roles.hint": "Toque em qualquer papel para alternar. As alterações são agrupadas — revise e salve quando estiver pronto.",
"detail.roles.saving": "Salvando…",
"detail.roles.saveChanges": "Salvar alterações",
@@ -102,5 +102,6 @@
"create.validation.username": "3–32 caracteres. Letras, dígitos, ponto, traço, sublinhado. Comece com uma letra.",
"create.validation.email": "Digite um email válido.",
"create.validation.min8": "No mínimo 8 caracteres.",
- "create.validation.mismatch": "As senhas não conferem."
+ "create.validation.mismatch": "As senhas não conferem.",
+ "detail.roles.modified": "modificado"
}
diff --git a/clients/admin/src/locales/pt-BR/webhooks.json b/clients/admin/src/locales/pt-BR/webhooks.json
index ec8f3c74eb..7e46561502 100644
--- a/clients/admin/src/locales/pt-BR/webhooks.json
+++ b/clients/admin/src/locales/pt-BR/webhooks.json
@@ -1,6 +1,5 @@
{
"list.title": "Webhooks",
- "list.unit": "assinatura",
"list.description": "Inscreva endpoints HTTP em eventos de domínio. Os payloads são assinados com HMAC-SHA256 usando o segredo que você fornece — verifique o cabeçalho X-FSH-Signature do seu lado antes de confiar no corpo.",
"list.refresh": "Atualizar",
"list.newSubscription": "Nova assinatura",
@@ -11,7 +10,7 @@
"list.empty.description": "Adicione um endpoint e escolha quais eventos devem disparar. Reenviaremos as entregas com falha automaticamente.",
"list.confirmDelete": "Excluir a assinatura para {{url}}?",
"list.deleteAria": "Excluir a assinatura para {{url}}",
- "list.moreEvents": "+{{count}} mais",
+ "list.moreEvents": "+{{count, number}} mais",
"list.since": "· desde {{date}}",
"list.test": "Testar",
"list.noun": "assinaturas",
@@ -26,13 +25,12 @@
"toast.deleted": "Assinatura excluída",
"toast.deleteFailed": "Falha ao excluir",
"toast.testRejected": "O endpoint rejeitou o evento de teste",
-
"detail.crumb": "\\ Webhooks",
"detail.subscriptionFallback": "Assinatura",
"detail.trailingActive": "ATIVA",
"detail.trailingInactive": "INATIVA",
- "detail.subscribed_one": "Inscrita em {{count}} evento.",
- "detail.subscribed_other": "Inscrita em {{count}} eventos.",
+ "detail.subscribed_one": "Inscrita em {{count, number}} evento.",
+ "detail.subscribed_other": "Inscrita em {{count, number}} eventos.",
"detail.loading": "Carregando assinatura…",
"detail.back": "Assinaturas",
"detail.loadError": "Falha ao carregar a assinatura.",
@@ -53,7 +51,7 @@
"detail.events.none": "— nenhum evento; a assinatura nunca dispararia",
"detail.deliveries.title": "Entregas",
"detail.deliveries.description": "Tentativas recentes de enviar (POST) eventos para este endpoint. Atualiza automaticamente a cada 10s.",
- "detail.deliveries.attempts": "{{count}} tentativas",
+ "detail.deliveries.attempts": "{{count, number}} tentativas",
"detail.deliveries.refresh": "Atualizar",
"detail.deliveries.loadingRow": "Carregando entregas",
"detail.deliveries.empty": "Nenhuma entrega ainda. Tente o botão de teste acima ou aguarde eventos correspondentes dispararem.",
@@ -61,15 +59,14 @@
"delivery.ok": "OK",
"delivery.failed": "Falhou",
"delivery.http": "HTTP {{code}}",
- "delivery.try": "tent. {{count}}",
-
+ "delivery.try": "tent. {{count, number}}",
"create.title": "Nova assinatura de webhook",
"create.description": "Seu endpoint recebe um payload JSON com os detalhes do evento. Assinamos cada requisição com HMAC-SHA256 no cabeçalho {{header}} usando o segredo abaixo — guarde-o do seu lado e verifique antes de confiar no corpo.",
"create.url": "URL do endpoint",
"create.secret": "Segredo de assinatura",
"create.secretHint": "Opcional, mas recomendado. Pelo menos 32 caracteres aleatórios. Usado para calcular o HMAC.",
"create.secretPlaceholder": "Deixe em branco para não assinar",
- "create.events": "Eventos ({{count}})",
+ "create.events": "Eventos ({{count, number}})",
"create.eventPlaceholderEmpty": "digite o nome de um evento e pressione Enter…",
"create.eventPlaceholderMore": "adicionar outro…",
"create.suggested": "sugeridos",
diff --git a/clients/admin/src/pages/audits/list.tsx b/clients/admin/src/pages/audits/list.tsx
index 7f559504b3..78e7dbf360 100644
--- a/clients/admin/src/pages/audits/list.tsx
+++ b/clients/admin/src/pages/audits/list.tsx
@@ -133,7 +133,7 @@ export function AuditsListPage() {
icon={ScrollText}
title={t("list.title")}
total={data?.totalCount ?? null}
- unit={t("list.unit")}
+ unit="event"
description={t("list.description")}
>
)}
diff --git a/clients/admin/src/pages/webhooks/list.tsx b/clients/admin/src/pages/webhooks/list.tsx
index d72e6bd9e4..263ddcb6bb 100644
--- a/clients/admin/src/pages/webhooks/list.tsx
+++ b/clients/admin/src/pages/webhooks/list.tsx
@@ -90,7 +90,7 @@ export function WebhooksListPage() {
icon={Webhook}
title={t("list.title")}
total={data?.totalCount ?? null}
- unit={t("list.unit")}
+ unit="subscription"
description={t("list.description")}
>
{
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();
+ });
});
From c37fc59fccd3e03e89b249b6c4850168ca27c141 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:54:34 -0300
Subject: [PATCH 14/16] fix(admin): let a lost upload key fall through to the
segment fallback
`defaultValue: e.messageKey` would have rendered "common:upload.cancelled" on
screen if the catalog ever lost the entry. Without it the missing-key handler
degrades to "Cancelled", which is the readable floor it exists to provide.
---
clients/admin/src/hooks/use-file-upload.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/clients/admin/src/hooks/use-file-upload.ts b/clients/admin/src/hooks/use-file-upload.ts
index 757192a802..fa9e35b4a2 100644
--- a/clients/admin/src/hooks/use-file-upload.ts
+++ b/clients/admin/src/hooks/use-file-upload.ts
@@ -276,7 +276,9 @@ function xhrPut(
*/
export function describeUploadError(e: unknown, t: TFunction, fallback?: string): string {
if (e instanceof UploadError) {
- return t(e.messageKey, { ...e.params, defaultValue: e.messageKey });
+ // No defaultValue: it would render the key itself ("common:upload.cancelled") if the catalog
+ // ever lost the entry. Falling through to the missing-key handler gives "Cancelled" instead.
+ return t(e.messageKey, { ...e.params });
}
if (e instanceof ApiRequestError) {
From 43842d1e2d456cd7824652fe4cb2de0d617e6ea9 Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:51:18 -0300
Subject: [PATCH 15/16] fix(i18n): namespace the language key, localize the
last upload path, gate both
Three loose ends the review left open on this branch.
**Storage key.** The detector kept i18next's default `i18nextLng`. Every other
value this app persists is namespaced (`fsh.admin.accessToken`,
`fsh.admin.theme`, `fsh.admin.sidebar.collapsed`), and the bare key is claimed
by both apps on a shared origin and by any other i18next app deployed beside
them. Now `fsh.admin.lng`. Migration cost is one session: a returning user's
old value is not read, so the first paint after deploy falls to the browser
locale or the deployment default, and the profile hydrate then restores
`User.Locale`. The prose that named the old key follows it.
**Upload failures.** `describeUploadError` returned `e.message` for any plain
`Error`, and `apiFetch` does not wrap `fetch`, so a presign step that never
reaches the API surfaced as the browser's own `TypeError("Failed to fetch")` -
in English, under a Portuguese UI, ahead of the localized fallback the caller
had already passed in. That branch now logs the original for diagnosis and
returns the catalog string.
**Gates.** `tests/i18n/upload-errors.spec.ts` drives the real avatar picker on
/settings/profile under `?culture=pt-BR` through three failures: a storage PUT
that never connects, one the bucket rejects with a 403 (the interpolated
`{{status}}` is asserted, not just the key), and a presign that never leaves
the browser. `format.spec.ts` gains the reactivity case: a date already on
screen has to reformat when the switcher changes the language under it.
`resolveLocale` reads `i18n.language` at call time and nothing in format.ts
subscribes to `languageChanged`; what makes it work is that every component
that formats also calls `useTranslation`, which a refactor can drop silently.
Verified: `playwright test tests/i18n/upload-errors.spec.ts` 3/3 and
`tests/i18n/format.spec.ts` 4/4. Mutations: returning `e.message` from the
fallback branch fails the presign case; freezing `resolveLocale` to the
language captured at module load fails the reactivity case. `tsc -b` and
`eslint .` both exit 0.
---
.../impersonation/impersonate-dialog.tsx | 2 +-
clients/admin/src/hooks/use-file-upload.ts | 11 +-
clients/admin/src/i18n.ts | 6 +-
clients/admin/tests/i18n/format.spec.ts | 104 ++++++++++++++++++
.../admin/tests/i18n/upload-errors.spec.ts | 95 ++++++++++++++++
.../impersonation/handoff-locale.spec.ts | 3 +-
6 files changed, 217 insertions(+), 4 deletions(-)
create mode 100644 clients/admin/tests/i18n/upload-errors.spec.ts
diff --git a/clients/admin/src/components/impersonation/impersonate-dialog.tsx b/clients/admin/src/components/impersonation/impersonate-dialog.tsx
index ed648b5f9e..7b39e4007a 100644
--- a/clients/admin/src/components/impersonation/impersonate-dialog.tsx
+++ b/clients/admin/src/components/impersonation/impersonate-dialog.tsx
@@ -453,7 +453,7 @@ function SelectedUserCard({
* server deliberately strips the target's `locale` claim so the operator keeps
* reading in their own language (StartImpersonationCommandHandler), but the
* dashboard usually runs on a different origin and therefore cannot read this
- * app's persisted `i18nextLng`. Without this parameter the API culture falls
+ * app's persisted language (`fsh.admin.lng`). Without this parameter the API culture falls
* through to the dashboard's own detected locale, so the shell would be in the
* operator's language while API errors came back in another.
*/
diff --git a/clients/admin/src/hooks/use-file-upload.ts b/clients/admin/src/hooks/use-file-upload.ts
index fa9e35b4a2..c4fff73a9a 100644
--- a/clients/admin/src/hooks/use-file-upload.ts
+++ b/clients/admin/src/hooks/use-file-upload.ts
@@ -282,9 +282,18 @@ export function describeUploadError(e: unknown, t: TFunction, fallback?: string)
}
if (e instanceof ApiRequestError) {
+ // ProblemDetails comes back in the caller's language: apiFetch sends Accept-Language from
+ // i18n.language and the API negotiates on it, so this text is already localized.
return e.problem?.detail ?? e.problem?.title ?? e.message;
}
- if (e instanceof Error) return e.message;
+
+ // Anything else is a runtime Error whose message the platform wrote in English -- a presign step
+ // that never reached the server throws TypeError("Failed to fetch"), and returning e.message put
+ // that on screen under a Portuguese UI. The message still reaches the console for diagnosis; the
+ // user gets the catalog string.
+ if (e instanceof Error) {
+ console.error("[upload] unhandled failure", e);
+ }
return fallback ?? t("common:upload.unknownError");
}
diff --git a/clients/admin/src/i18n.ts b/clients/admin/src/i18n.ts
index b3d9dd781b..16d7c16c2f 100644
--- a/clients/admin/src/i18n.ts
+++ b/clients/admin/src/i18n.ts
@@ -111,9 +111,13 @@ export function initI18n(deploymentDefault: string) {
return missingKeyFallback(key, defaultValue);
},
detection: {
- // NO cookie — localStorage only (the library default; key i18nextLng).
+ // NO cookie — localStorage only, under this app's own key. The detector's default is the
+ // bare "i18nextLng", which both apps would claim on a shared origin and which collides with
+ // any other i18next app deployed beside them; every other persisted value here is already
+ // namespaced the same way (fsh..*).
order: ["querystring", "localStorage", "navigator"],
caches: ["localStorage"],
+ lookupLocalStorage: "fsh.admin.lng",
lookupQuerystring: "culture",
convertDetectedLanguage: toCanonical, // pt/pt-PT->pt-BR, en/en-GB->en-US
},
diff --git a/clients/admin/tests/i18n/format.spec.ts b/clients/admin/tests/i18n/format.spec.ts
index 884a79ab32..44baa50c93 100644
--- a/clients/admin/tests/i18n/format.spec.ts
+++ b/clients/admin/tests/i18n/format.spec.ts
@@ -97,4 +97,108 @@ test.describe("format.ts", () => {
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/upload-errors.spec.ts b/clients/admin/tests/i18n/upload-errors.spec.ts
new file mode 100644
index 0000000000..394b048544
--- /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 common:upload.networkError.
+ 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
index cc54453f77..ba5da26fff 100644
--- a/clients/admin/tests/impersonation/handoff-locale.spec.ts
+++ b/clients/admin/tests/impersonation/handoff-locale.spec.ts
@@ -9,7 +9,8 @@ import { mockJsonResponse } from "../helpers/api-mocks";
// 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 so `i18nextLng` is not shared.
+// 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.
From 328dbb4871d31d36486823746758e09b80f4676b Mon Sep 17 00:00:00 2001
From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 18 Sep 2026 12:02:30 -0300
Subject: [PATCH 16/16] test(i18n): pin the phone number the language switch
echoes back
Review flagged that a language switch could clear `PhoneNumberConfirmed`. It
does not today, and this is what says so rather than an assurance.
`UserProfileService` compares the incoming phone to the stored one with a plain
string compare and calls `SetPhoneNumberAsync` on any difference, which clears
the confirmation flag. The switcher sends a whole profile, so what protects the
flag is that `updateMyProfile` re-reads the profile and echoes `phoneNumber`
back byte for byte: `""` and `null` are not interchangeable to that comparison,
and neither is a trimmed variant.
Nothing asserted it, which made the safe version and the damaging version look
identical - adding a `.trim() || null` on this path reads like tidying and
would un-confirm a verified phone number for choosing a language, with no error
and no log. Three cases now pin the echo (`null`, `""`, a real number), and
that exact normalization is what turns the `""` case red.
Verified: `playwright test tests/i18n/switcher.spec.ts` 8/8 in each app;
normalizing the echo fails 1/8 in each.
---
clients/admin/tests/i18n/switcher.spec.ts | 46 +++++++++++++++++++++++
1 file changed, 46 insertions(+)
diff --git a/clients/admin/tests/i18n/switcher.spec.ts b/clients/admin/tests/i18n/switcher.spec.ts
index e8c9700be5..604cebc5c4 100644
--- a/clients/admin/tests/i18n/switcher.spec.ts
+++ b/clients/admin/tests/i18n/switcher.spec.ts
@@ -318,6 +318,52 @@ test.describe("language switcher", () => {
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