From fca1869c7a3ce9ac48650c17d5a96509fb755e37 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Tue, 3 Nov 2026 22:54:27 +0530 Subject: [PATCH] fix(clients): i18n follow-ups after the admin and dashboard slices - admin: the language switcher's profile save sends If-Match with the ETag of the read it merges from, retries once on 412, and no longer sends the email field the API dropped in #1454 - admin: apiFetch gains the onResponse hook the dashboard already has - dashboard: the container renders defaultLanguage from FSH_DEFAULT_LANGUAGE like the admin does; compose passes it to both apps, Terraform sets it on the dashboard site, and the container CI job asserts the en-US default - dashboard: drop the English-text fallback for the deactivated-tenant 403, now that the API always sends the Multitenancy.TenantDeactivated code - both: add the missing t dependency to the upload hook and the file preview effect, so error text follows a language switch - admin: remove the unused theme-toggle component --- .github/workflows/frontend.yml | 2 ++ clients/admin/src/api/users.ts | 31 ++++++++++++++++--- .../src/components/theme/theme-toggle.tsx | 26 ---------------- clients/admin/src/hooks/use-file-upload.ts | 2 +- clients/admin/src/lib/api-client.ts | 11 +++++-- clients/dashboard/docker/config.json.template | 3 +- clients/dashboard/docker/docker-entrypoint.sh | 4 ++- .../components/file/file-preview-dialog.tsx | 6 ++-- .../dashboard/src/hooks/use-file-upload.ts | 2 +- clients/dashboard/src/lib/api-client.ts | 12 +------ .../tests/system/tenant-deactivated.spec.ts | 15 --------- deploy/docker/.env.example | 4 +++ deploy/docker/docker-compose.yml | 2 ++ .../terraform/apps/starter/app_stack/main.tf | 7 +++-- 14 files changed, 58 insertions(+), 69 deletions(-) delete mode 100644 clients/admin/src/components/theme/theme-toggle.tsx diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 82d647ce3e..0bce9348d7 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -151,6 +151,8 @@ jobs: # Empty inactivity variables (what compose passes when .env leaves them unset) # fall back to the app's built-in timings. jq -e --argjson idle "$DEFAULT_IDLE_MS" '.inactivityIdleMs == $idle and .inactivityWarningMs == 60000' config.json + # Unset FSH_DEFAULT_LANGUAGE falls back to the bundle's own default. + jq -e '.defaultLanguage == "en-US"' config.json } check if curl -fsS http://localhost:8080/config.json.template | grep -qF '${FSH_API_URL}'; then diff --git a/clients/admin/src/api/users.ts b/clients/admin/src/api/users.ts index fefb6a750c..3b61f245ab 100644 --- a/clients/admin/src/api/users.ts +++ b/clients/admin/src/api/users.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "@/lib/api-client"; +import { ApiRequestError, apiFetch } from "@/lib/api-client"; import type { PagedResponse } from "@/lib/api-types"; export type UserDto = { @@ -92,20 +92,41 @@ export type UpdateMyProfileInput = { * unconditionally from the command, so a partial update (e.g. the language * switcher sending only `locale`) would wipe the others. Reads the current * profile from the server first and merges, so any field the caller omits - * keeps its persisted value instead of being nulled — never trust a possibly - * stale/undefined client-side snapshot for the echoed fields. + * keeps its persisted value. + * + * The PUT echoes the read's ETag in `If-Match`, so a profile edit that lands + * between the read and the write makes the server answer 412 instead of being + * silently overwritten. On 412 the read-merge-write runs once more against the + * fresh profile; the caller only sends the fields it means to change. */ export async function updateMyProfile(input: UpdateMyProfileInput): Promise { - const profile = await getMyProfile(); + try { + await putMyProfile(input); + } catch (err) { + if (!(err instanceof ApiRequestError) || err.status !== 412) throw err; + await putMyProfile(input); + } +} + +async function putMyProfile(input: UpdateMyProfileInput): Promise { + let etag: string | null = null; + const profile = await apiFetch(`${IDENTITY}/profile`, { + onResponse: (response) => { + // The endpoint emits a strong validator; a compressing proxy can downgrade it to W/, + // which the server's strong If-Match comparison would never match. + const header = response.headers.get("ETag"); + etag = header ? header.replace(/^W\//, "") : null; + }, + }); await apiFetch(`${IDENTITY}/profile`, { method: "PUT", + headers: etag ? { "If-Match": etag } : undefined, body: JSON.stringify({ id: profile.id, firstName: input.firstName ?? profile.firstName ?? null, lastName: input.lastName ?? profile.lastName ?? null, phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null, locale: input.locale ?? profile.locale ?? null, - email: profile.email, deleteCurrentImage: false, }), }); diff --git a/clients/admin/src/components/theme/theme-toggle.tsx b/clients/admin/src/components/theme/theme-toggle.tsx deleted file mode 100644 index e9a0ddc9e0..0000000000 --- a/clients/admin/src/components/theme/theme-toggle.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Moon, Sun } from "lucide-react"; -import { useTheme } from "@/components/theme/theme-provider"; -import { cn } from "@/lib/cn"; - -/** - * ThemeToggle — switches between light and dark. Renders the *destination* - * icon (i.e. if you're in dark, you see Sun, because clicking will send you - * to light) so it reads as a directional affordance rather than a status. - */ -export function ThemeToggle({ className }: { className?: string }) { - const { theme, toggle } = useTheme(); - const Icon = theme === "dark" ? Sun : Moon; - return ( - - ); -} diff --git a/clients/admin/src/hooks/use-file-upload.ts b/clients/admin/src/hooks/use-file-upload.ts index d8738ec683..d5d2e8f1b5 100644 --- a/clients/admin/src/hooks/use-file-upload.ts +++ b/clients/admin/src/hooks/use-file-upload.ts @@ -228,7 +228,7 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult { xhrRef.current = null; return dto; }, - [options], + [options, t], ); return { diff --git a/clients/admin/src/lib/api-client.ts b/clients/admin/src/lib/api-client.ts index 1de8c70653..9bb84e56e4 100644 --- a/clients/admin/src/lib/api-client.ts +++ b/clients/admin/src/lib/api-client.ts @@ -46,7 +46,12 @@ export function describeError(err: unknown): string { return err instanceof Error ? err.message : String(err); } -type RequestInitEx = RequestInit & { skipAuth?: boolean; timeoutMs?: number }; +type RequestInitEx = RequestInit & { + skipAuth?: boolean; + timeoutMs?: number; + /** Sees the final response (after any token refresh) before the body is read, e.g. to capture an ETag. */ + onResponse?: (response: Response) => void; +}; const DEFAULT_TIMEOUT_MS = 30_000; @@ -128,7 +133,7 @@ export async function apiFetch( path: string, init: RequestInitEx = {}, ): Promise { - const { skipAuth, headers, timeoutMs, signal, ...rest } = init; + const { skipAuth, headers, timeoutMs, signal, onResponse, ...rest } = init; const effectiveTimeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; const mergedHeaders = new Headers(headers); @@ -193,6 +198,8 @@ export async function apiFetch( }); } + onResponse?.(response); + if (!response.ok) { const problem = await parseError(response); throw new ApiRequestError( diff --git a/clients/dashboard/docker/config.json.template b/clients/dashboard/docker/config.json.template index bcd74f1124..9630258b5f 100644 --- a/clients/dashboard/docker/config.json.template +++ b/clients/dashboard/docker/config.json.template @@ -2,5 +2,6 @@ "apiBase": "${FSH_API_URL}", "defaultTenant": "${FSH_DEFAULT_TENANT}", "inactivityIdleMs": ${FSH_INACTIVITY_IDLE_MS}, - "inactivityWarningMs": ${FSH_INACTIVITY_WARNING_MS} + "inactivityWarningMs": ${FSH_INACTIVITY_WARNING_MS}, + "defaultLanguage": "${FSH_DEFAULT_LANGUAGE}" } diff --git a/clients/dashboard/docker/docker-entrypoint.sh b/clients/dashboard/docker/docker-entrypoint.sh index a4bf600082..a836565ef4 100644 --- a/clients/dashboard/docker/docker-entrypoint.sh +++ b/clients/dashboard/docker/docker-entrypoint.sh @@ -3,6 +3,8 @@ set -e : "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}" : "${FSH_DEFAULT_TENANT:=root}" +# Matches the bundle's own fallback, so an unset variable and an absent config.json agree. +: "${FSH_DEFAULT_LANGUAGE:=en-US}" # The inactivity timings are bare JSON numbers in the template, so each needs a value and it # must be a positive integer. The defaults mirror DEFAULT_INACTIVITY_* in src/env.ts. @@ -16,7 +18,7 @@ require_positive_ms() { require_positive_ms FSH_INACTIVITY_IDLE_MS "$FSH_INACTIVITY_IDLE_MS" require_positive_ms FSH_INACTIVITY_WARNING_MS "$FSH_INACTIVITY_WARNING_MS" -export FSH_API_URL FSH_DEFAULT_TENANT FSH_INACTIVITY_IDLE_MS FSH_INACTIVITY_WARNING_MS +export FSH_API_URL FSH_DEFAULT_TENANT FSH_INACTIVITY_IDLE_MS FSH_INACTIVITY_WARNING_MS FSH_DEFAULT_LANGUAGE envsubst < /etc/fsh/config.json.template \ > /usr/share/nginx/html/config.json diff --git a/clients/dashboard/src/components/file/file-preview-dialog.tsx b/clients/dashboard/src/components/file/file-preview-dialog.tsx index 444be76c39..8566a04583 100644 --- a/clients/dashboard/src/components/file/file-preview-dialog.tsx +++ b/clients/dashboard/src/components/file/file-preview-dialog.tsx @@ -359,8 +359,8 @@ function TextPreview({ url }: { url: string }) { try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const t = await res.text(); - if (!cancelled) setText(t.slice(0, 64 * 1024)); // cap at 64 KiB to keep the modal snappy + const body = await res.text(); + if (!cancelled) setText(body.slice(0, 64 * 1024)); // cap at 64 KiB to keep the modal snappy } catch (e) { if (!cancelled) setErr(e instanceof Error ? e.message : t("preview.fetchFailed")); } @@ -368,7 +368,7 @@ function TextPreview({ url }: { url: string }) { return () => { cancelled = true; }; - }, [url]); + }, [url, t]); if (err) { return ; diff --git a/clients/dashboard/src/hooks/use-file-upload.ts b/clients/dashboard/src/hooks/use-file-upload.ts index 0544432cb1..5388ddec7f 100644 --- a/clients/dashboard/src/hooks/use-file-upload.ts +++ b/clients/dashboard/src/hooks/use-file-upload.ts @@ -228,7 +228,7 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult { xhrRef.current = null; return dto; }, - [options], + [options, t], ); return { diff --git a/clients/dashboard/src/lib/api-client.ts b/clients/dashboard/src/lib/api-client.ts index 7c5df95e50..5a40c81ac2 100644 --- a/clients/dashboard/src/lib/api-client.ts +++ b/clients/dashboard/src/lib/api-client.ts @@ -34,15 +34,6 @@ export class ApiRequestError extends Error { /** The deactivated-tenant guard's error key (MultitenancyModule). */ const TENANT_DEACTIVATED_CODE = "Multitenancy.TenantDeactivated"; -/** - * The guard's message before the ProblemDetails `code` existed. Kept as a second - * match so this app works against an API that predates the localized-errors slice: - * until that ships, `code` is absent and the only signal is this exact English text. - * Drop this branch once the API always sends `code`. - */ -const TENANT_DEACTIVATED_LEGACY_DETAIL = - "This tenant has been deactivated. Contact your administrator."; - /** * True when an error is the API's deactivated-tenant 403. The guard * (MultitenancyModule) rejects *every* request once a tenant is switched off, so @@ -55,8 +46,7 @@ const TENANT_DEACTIVATED_LEGACY_DETAIL = */ export function isTenantDeactivatedError(error: unknown): boolean { if (!(error instanceof ApiRequestError) || error.status !== 403) return false; - if (error.problem?.code === TENANT_DEACTIVATED_CODE) return true; - return error.problem?.detail === TENANT_DEACTIVATED_LEGACY_DETAIL; + return error.problem?.code === TENANT_DEACTIVATED_CODE; } /** diff --git a/clients/dashboard/tests/system/tenant-deactivated.spec.ts b/clients/dashboard/tests/system/tenant-deactivated.spec.ts index 1567e39a0f..3cf1722766 100644 --- a/clients/dashboard/tests/system/tenant-deactivated.spec.ts +++ b/clients/dashboard/tests/system/tenant-deactivated.spec.ts @@ -72,19 +72,4 @@ test.describe("tenant deactivated mid-session", () => { await page.waitForLoadState("networkidle"); await expect(page).not.toHaveURL(/\/tenant-deactivated/); }); - - // Compatibility: against an API that predates the localized-errors slice the 403 - // carries no `code` at all. Routing must still happen, otherwise this app only - // works once that slice has shipped. - test("routes on an uncoded 403 carrying the legacy English detail", async ({ page }) => { - await mockProblemDetails(page, "**/api/v1/catalog/products**", 403, { - title: "Forbidden", - detail: "This tenant has been deactivated. Contact your administrator.", - }); - - await page.goto("/catalog/products"); - - await expect(page).toHaveURL(/\/tenant-deactivated$/); - await expect(page.getByRole("heading", { name: /tenant deactivated/i })).toBeVisible(); - }); }); diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 746bf83f7f..082428ef38 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -29,6 +29,10 @@ FSH_DEFAULT_TENANT=root # FSH_DASHBOARD_INACTIVITY_IDLE_MS=1200000 # FSH_INACTIVITY_WARNING_MS=60000 +# UI language for users who haven't picked one (both frontends). A BCP 47 +# tag the apps ship a catalog for: en-US (default) or pt-BR. +# FSH_DEFAULT_LANGUAGE=en-US + # ── Host ports for the external proxy to point at ─────────────────── # FSH services and the RustFS S3 API publish ports (browsers upload # straight to it). Postgres/Redis stay compose-internal by default diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 448a43c1ac..0c118256da 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -205,6 +205,7 @@ services: FSH_DEFAULT_TENANT: ${FSH_DEFAULT_TENANT:-root} FSH_INACTIVITY_IDLE_MS: ${FSH_ADMIN_INACTIVITY_IDLE_MS:-} FSH_INACTIVITY_WARNING_MS: ${FSH_INACTIVITY_WARNING_MS:-} + FSH_DEFAULT_LANGUAGE: ${FSH_DEFAULT_LANGUAGE:-} ports: - "${FSH_ADMIN_PORT:-8081}:80" @@ -221,6 +222,7 @@ services: FSH_DEFAULT_TENANT: ${FSH_DEFAULT_TENANT:-root} FSH_INACTIVITY_IDLE_MS: ${FSH_DASHBOARD_INACTIVITY_IDLE_MS:-} FSH_INACTIVITY_WARNING_MS: ${FSH_INACTIVITY_WARNING_MS:-} + FSH_DEFAULT_LANGUAGE: ${FSH_DEFAULT_LANGUAGE:-} ports: - "${FSH_DASHBOARD_PORT:-8082}:80" diff --git a/deploy/terraform/apps/starter/app_stack/main.tf b/deploy/terraform/apps/starter/app_stack/main.tf index 73ad13287d..41a4add97f 100644 --- a/deploy/terraform/apps/starter/app_stack/main.tf +++ b/deploy/terraform/apps/starter/app_stack/main.tf @@ -219,9 +219,10 @@ module "dashboard_site" { response_headers_policy_id = var.frontend_response_headers_policy_id runtime_config = { - apiBase = local.api_origin - defaultTenant = var.frontend_default_tenant - demoMode = var.dashboard_demo_mode + apiBase = local.api_origin + defaultTenant = var.frontend_default_tenant + demoMode = var.dashboard_demo_mode + defaultLanguage = var.frontend_default_language } tags = local.common_tags