diff --git a/clients/admin/package-lock.json b/clients/admin/package-lock.json
index da4eb2be37..221b76c7f4 100644
--- a/clients/admin/package-lock.json
+++ b/clients/admin/package-lock.json
@@ -18,11 +18,14 @@
"@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "i18next": "^26.3.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
+ "react-i18next": "^17.0.10",
"react-router-dom": "^7.1.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
@@ -284,6 +287,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -4523,6 +4535,52 @@
"node": ">= 0.4"
}
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
+ "node_modules/i18next": {
+ "version": "26.3.6",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
+ "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5911,6 +5969,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.10",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
+ "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -6722,7 +6807,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -6884,6 +6969,15 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
@@ -6959,6 +7053,15 @@
}
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
diff --git a/clients/admin/package.json b/clients/admin/package.json
index 80b8a4570e..99b46aaa54 100644
--- a/clients/admin/package.json
+++ b/clients/admin/package.json
@@ -22,11 +22,14 @@
"@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "i18next": "^26.3.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
+ "react-i18next": "^17.0.10",
"react-router-dom": "^7.1.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
diff --git a/clients/admin/playwright.config.ts b/clients/admin/playwright.config.ts
index 31ecc38abc..4e4cee173b 100644
--- a/clients/admin/playwright.config.ts
+++ b/clients/admin/playwright.config.ts
@@ -18,6 +18,11 @@ export default defineConfig({
actionTimeout: 10_000,
navigationTimeout: 15_000,
},
+ // Assertions get the same budget as actions. Left at the 5s default they were
+ // the tightest deadline in the suite — every test ends in a toBeVisible, and
+ // under CPU contention the first paint of a lazy route lands past 5s while
+ // staying well inside the action and navigation budgets.
+ expect: { timeout: 10_000 },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "npm run dev",
diff --git a/clients/admin/public/config.json b/clients/admin/public/config.json
index 015dc0c1a2..c760e9d765 100644
--- a/clients/admin/public/config.json
+++ b/clients/admin/public/config.json
@@ -3,5 +3,6 @@
"defaultTenant": "root",
"dashboardUrl": "http://localhost:5174",
"inactivityIdleMs": 600000,
- "inactivityWarningMs": 60000
+ "inactivityWarningMs": 60000,
+ "defaultLanguage": "en-US"
}
diff --git a/clients/admin/src/App.tsx b/clients/admin/src/App.tsx
index f8b80fc335..bb6f4f875c 100644
--- a/clients/admin/src/App.tsx
+++ b/clients/admin/src/App.tsx
@@ -2,6 +2,7 @@ import { Suspense } from "react";
import { RouterProvider } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
+import { useTranslation } from "react-i18next";
import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2 } from "lucide-react";
import { queryClient } from "@/lib/query-client";
import { AuthProvider } from "@/auth/auth-context";
@@ -10,6 +11,7 @@ import { ThemeProvider, useTheme } from "@/components/theme/theme-provider";
import { router } from "@/routes";
export function App() {
+ const { t } = useTranslation("common");
return (
@@ -22,7 +24,7 @@ export function App() {
fallback={
}
diff --git a/clients/admin/src/api/users.ts b/clients/admin/src/api/users.ts
index e082822225..fefb6a750c 100644
--- a/clients/admin/src/api/users.ts
+++ b/clients/admin/src/api/users.ts
@@ -12,6 +12,8 @@ export type UserDto = {
phoneNumber?: string | null;
imageUrl?: string | null;
twoFactorEnabled?: boolean;
+ /** Persisted BCP 47 UI language tag (e.g. "pt-BR"); null when the user never chose. */
+ locale?: string | null;
};
export type UserRoleDto = {
@@ -76,6 +78,39 @@ export async function setProfileImage(imageUrl: string | null): Promise {
});
}
+export type UpdateMyProfileInput = {
+ firstName?: string | null;
+ lastName?: string | null;
+ phoneNumber?: string | null;
+ /** BCP 47 UI language tag persisted on the user (drives the JWT locale claim). */
+ locale?: string | null;
+};
+
+/**
+ * Self-update of the authenticated user's profile (PUT /identity/profile,
+ * server forces the id to the caller). The backend sets FirstName/LastName
+ * unconditionally from the command, so a partial update (e.g. the language
+ * switcher sending only `locale`) would wipe the others. Reads the current
+ * profile from the server first and merges, so any field the caller omits
+ * keeps its persisted value instead of being nulled — never trust a possibly
+ * stale/undefined client-side snapshot for the echoed fields.
+ */
+export async function updateMyProfile(input: UpdateMyProfileInput): Promise {
+ const profile = await getMyProfile();
+ await apiFetch(`${IDENTITY}/profile`, {
+ method: "PUT",
+ body: JSON.stringify({
+ id: profile.id,
+ firstName: input.firstName ?? profile.firstName ?? null,
+ lastName: input.lastName ?? profile.lastName ?? null,
+ phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null,
+ locale: input.locale ?? profile.locale ?? null,
+ email: profile.email,
+ deleteCurrentImage: false,
+ }),
+ });
+}
+
export async function changePassword(input: {
password: string;
newPassword: string;
diff --git a/clients/admin/src/auth/protected-route.tsx b/clients/admin/src/auth/protected-route.tsx
index 1ebe746496..2cf962b78e 100644
--- a/clients/admin/src/auth/protected-route.tsx
+++ b/clients/admin/src/auth/protected-route.tsx
@@ -1,4 +1,5 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { useAuth } from "@/auth/use-auth";
import { ForbiddenView } from "@/components/forbidden-view";
@@ -14,6 +15,7 @@ type ProtectedRouteProps = {
export function ProtectedRoute({ permissions = [] }: ProtectedRouteProps) {
const { isAuthenticated, isInitializing, user } = useAuth();
const location = useLocation();
+ const { t } = useTranslation("common");
// Resolving a stored session (silent token refresh) — hold rendering so we
// neither flash a protected surface with a stale/expired token nor bounce to
@@ -25,7 +27,7 @@ export function ProtectedRoute({ permissions = [] }: ProtectedRouteProps) {
role="status"
aria-busy="true"
>
- Restoring your session…
+ {t("protectedRoute.restoring")}
- Resolving permissions
+ {t("routeGuard.resolving")}
);
diff --git a/clients/admin/src/components/auth/auth-shell.tsx b/clients/admin/src/components/auth/auth-shell.tsx
index 68981ec30c..814d139616 100644
--- a/clients/admin/src/components/auth/auth-shell.tsx
+++ b/clients/admin/src/components/auth/auth-shell.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
import { BrandMarkXL } from "@/components/brand-mark";
import { cn } from "@/lib/cn";
@@ -44,6 +45,7 @@ export function AuthShell({
/** Form area below the blurb. */
children: ReactNode;
}) {
+ const { t } = useTranslation("common");
return (
{/* ─── Left pane — brand stage ───────────────────────────────── */}
@@ -65,11 +67,11 @@ export function AuthShell({
-
authorized personnel
+
{t("authShell.authorizedPersonnel")}
- Account recovery is rate-limited and audited.
+ {t("authShell.recoveryNotice")}
- Reset links expire 30 minutes after issue.
+ {t("authShell.recoveryExpiry")}
diff --git a/clients/admin/src/components/auth/demo-accounts-dialog.tsx b/clients/admin/src/components/auth/demo-accounts-dialog.tsx
index e0f8b25c84..fe16a086ee 100644
--- a/clients/admin/src/components/auth/demo-accounts-dialog.tsx
+++ b/clients/admin/src/components/auth/demo-accounts-dialog.tsx
@@ -1,4 +1,5 @@
import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
import { ArrowUpRight, ShieldCheck } from "lucide-react";
import {
Dialog,
@@ -26,6 +27,7 @@ interface DemoAccountsDialogProps {
}
export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsDialogProps) {
+ const { t } = useTranslation("auth");
// Nothing to reset on re-open (single account list, no tenant rail).
useEffect(() => {}, [open]);
@@ -37,9 +39,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
return (
- Demo accounts
+ {t("demo.dialogTitle")}
- Pick a demo account to sign in to the admin console.
+ {t("demo.dialogDescription")}
{/* Atmospheric gradient wash */}
@@ -60,14 +62,14 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
- Dev · demo
+ {t("demo.badge")}
- Sign in as operator.
+ {t("demo.title")}
- Tap an account below — we'll fill the credentials and sign you in instantly.
+ {t("demo.subtitle")}
@@ -75,11 +77,11 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
- Operator accounts
+ {t("demo.operatorAccounts")}
- tap to sign in
+ {t("demo.tapToSignIn")}
@@ -100,9 +102,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
- dev only
+ {t("demo.devOnly")}
·
- Not visible in production.
+ {t("demo.notVisibleInProduction")}
diff --git a/clients/admin/src/components/auth/inactivity-dialog.tsx b/clients/admin/src/components/auth/inactivity-dialog.tsx
index fbee548cb4..cde62e8dcf 100644
--- a/clients/admin/src/components/auth/inactivity-dialog.tsx
+++ b/clients/admin/src/components/auth/inactivity-dialog.tsx
@@ -1,4 +1,5 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { useTranslation } from "react-i18next";
import { ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cn";
@@ -34,6 +35,7 @@ export function InactivityDialog({
onStay: () => void;
onSignOut: () => void;
}) {
+ const { t } = useTranslation("auth");
const fraction =
totalSeconds > 0 ? Math.max(0, Math.min(1, secondsLeft / totalSeconds)) : 0;
const dashOffset = RING_CIRCUMFERENCE * (1 - fraction);
@@ -96,7 +98,7 @@ export function InactivityDialog({
{Math.max(0, secondsLeft)}
- seconds
+ {t("inactivity.seconds")}
@@ -105,23 +107,22 @@ export function InactivityDialog({
- Still there?
+ {t("inactivity.title")}
- You've been inactive for a while. We'll sign you out shortly to keep your
- account secure.
+ {t("inactivity.description")}
- Sign out now
+ {t("inactivity.signOutNow")}
- I'm here
+ {t("inactivity.stay")}
diff --git a/clients/admin/src/components/billing/plan-form-dialog.tsx b/clients/admin/src/components/billing/plan-form-dialog.tsx
index 08c603731c..a98b1fe90f 100644
--- a/clients/admin/src/components/billing/plan-form-dialog.tsx
+++ b/clients/admin/src/components/billing/plan-form-dialog.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { CreditCard, Gauge } from "lucide-react";
@@ -28,14 +29,16 @@ const PLAN_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
// A money/rate field is a free-text decimal string. These refinements run
// client-side so a negative price is rejected before any network call (the
-// server also rejects it, but we don't rely on that).
-const NON_NEGATIVE_MSG = "Must be a non-negative number.";
+// server also rejects it, but we don't rely on that). Messages are i18n keys,
+// resolved to text at display time via t().
+const NON_NEGATIVE_MSG = "planForm.validation.nonNegative";
+const REQUIRED_MSG = "planForm.validation.required";
/** Required non-negative decimal (e.g. monthly base price). */
const requiredNonNegative = z
.string()
.trim()
- .min(1, "Required.")
+ .min(1, REQUIRED_MSG)
.refine((v) => Number.isFinite(Number(v)) && Number(v) >= 0, NON_NEGATIVE_MSG);
/** Optional non-negative decimal (blank allowed → omitted). */
@@ -44,16 +47,11 @@ const optionalNonNegative = z
.trim()
.refine((v) => v === "" || (Number.isFinite(Number(v)) && Number(v) >= 0), NON_NEGATIVE_MSG);
-const INTERVAL_OPTIONS: SelectOption
[] = [
- { value: "Monthly", label: "Monthly", hint: "billed every month" },
- { value: "Yearly", label: "Yearly", hint: "billed every 12 months" },
-];
-
-const OVERAGE_RESOURCES: { key: QuotaResource; label: string; placeholder: string }[] = [
- { key: "ApiCalls", label: "API calls", placeholder: "0.0010" },
- { key: "StorageBytes", label: "Storage bytes", placeholder: "0.00000001" },
- { key: "Users", label: "Users", placeholder: "5.00" },
- { key: "ActiveFeatureFlags", label: "Feature flags", placeholder: "1.00" },
+const OVERAGE_RESOURCES: { key: QuotaResource; labelKey: string; placeholder: string }[] = [
+ { key: "ApiCalls", labelKey: "planForm.overage.apiCalls", placeholder: "0.0010" },
+ { key: "StorageBytes", labelKey: "planForm.overage.storageBytes", placeholder: "0.00000001" },
+ { key: "Users", labelKey: "planForm.overage.users", placeholder: "5.00" },
+ { key: "ActiveFeatureFlags", labelKey: "planForm.overage.featureFlags", placeholder: "1.00" },
];
type OverageState = Record;
@@ -124,9 +122,15 @@ export function PlanFormDialog({
onOpenChange: (open: boolean) => void;
plan?: BillingPlanDto;
}) {
+ const { t } = useTranslation("billing");
const queryClient = useQueryClient();
const isEdit = !!plan;
+ const INTERVAL_OPTIONS: SelectOption[] = [
+ { value: "Monthly", label: t("planForm.interval.monthly"), hint: t("planForm.interval.monthlyHint") },
+ { value: "Yearly", label: t("planForm.interval.yearly"), hint: t("planForm.interval.yearlyHint") },
+ ];
+
const [key, setKey] = useState("");
const [name, setName] = useState("");
const [currency, setCurrency] = useState("USD");
@@ -180,21 +184,21 @@ export function PlanFormDialog({
const createMutation = useMutation({
mutationFn: createPlan,
onSuccess: () => {
- toast.success(`Plan "${name}" created`);
+ toast.success(t("planForm.toast.created", { name }));
queryClient.invalidateQueries({ queryKey: ["billing", "plans"] });
onClose();
},
- onError: (err) => toast.error("Create failed", { description: describe(err, "Could not create plan.") }),
+ onError: (err) => toast.error(t("planForm.toast.createFailed"), { description: describe(err, t("planForm.toast.createFailedDesc")) }),
});
const updateMutation = useMutation({
mutationFn: updatePlan,
onSuccess: () => {
- toast.success(`Plan "${name}" updated`);
+ toast.success(t("planForm.toast.updated", { name }));
queryClient.invalidateQueries({ queryKey: ["billing", "plans"] });
onClose();
},
- onError: (err) => toast.error("Update failed", { description: describe(err, "Could not update plan.") }),
+ onError: (err) => toast.error(t("planForm.toast.updateFailed"), { description: describe(err, t("planForm.toast.updateFailedDesc")) }),
});
const pending = createMutation.isPending || updateMutation.isPending;
@@ -240,12 +244,10 @@ export function PlanFormDialog({
>
- {isEdit ? "Edit plan" : "New plan"}
+ {isEdit ? t("planForm.editTitle") : t("planForm.newTitle")}
- {isEdit
- ? "Update name, pricing, interval, or overage rates. Key and currency are immutable."
- : "Plan keys are canonical slugs used by tenant subscriptions and quota configuration."}
+ {isEdit ? t("planForm.editDescription") : t("planForm.newDescription")}
@@ -255,17 +257,17 @@ export function PlanFormDialog({
-
+
setName(e.target.value)} placeholder="Pro" />
-
+
-
+
id="pf-interval"
value={interval}
@@ -317,9 +319,9 @@ export function PlanFormDialog({
{interval === "Yearly" && (
@@ -346,8 +348,8 @@ export function PlanFormDialog({
- Cancel
+ {t("planForm.cancel")}
- {pending ? "Saving…" : isEdit ? "Save changes" : "Create plan"}
+ {pending ? t("planForm.saving") : isEdit ? t("planForm.saveChanges") : t("planForm.createPlan")}
diff --git a/clients/admin/src/components/brand-mark.tsx b/clients/admin/src/components/brand-mark.tsx
index 5883828c0e..ed5458c491 100644
--- a/clients/admin/src/components/brand-mark.tsx
+++ b/clients/admin/src/components/brand-mark.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
/**
@@ -41,6 +42,7 @@ export function BrandMark({ className }: { className?: string }) {
* system blurb.
*/
export function BrandMarkXL({ className }: { className?: string }) {
+ const { t } = useTranslation("common");
return (
@@ -60,8 +62,7 @@ export function BrandMarkXL({ className }: { className?: string }) {
Admin
.
- Operate every tenant on this instance — identity, multitenancy, billing,
- and the rest of the system surface, from one place.
+ {t("brandMark.tagline")}
);
diff --git a/clients/admin/src/components/file/image-input.tsx b/clients/admin/src/components/file/image-input.tsx
index 7a9c9c1f74..a36c43106f 100644
--- a/clients/admin/src/components/file/image-input.tsx
+++ b/clients/admin/src/components/file/image-input.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Image as ImageIcon, Loader2, Upload, X, Link as LinkIcon } from "lucide-react";
import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/cn";
@@ -45,6 +46,7 @@ export function ImageInput({
shape = "square",
className,
}: Props) {
+ const { t } = useTranslation("common");
const [mode, setMode] = useState<"upload" | "url">("upload");
const { upload, progress, isUploading, reset } = useFileUpload({
ownerType,
@@ -77,7 +79,7 @@ export function ImageInput({
const asset = await upload(file);
const url = await resolveUrl.mutateAsync(asset.id);
onChange(url);
- toast.success("Image uploaded");
+ toast.success(t("imageInput.uploaded"));
// Clear progress so the dropzone re-arms for another upload.
setTimeout(reset, 1500);
} catch (e) {
@@ -86,7 +88,7 @@ export function ImageInput({
? (e.problem?.detail ?? e.problem?.title ?? e.message)
: e instanceof Error
? e.message
- : "Upload failed";
+ : t("imageInput.uploadFailed");
toast.error(message);
}
};
@@ -102,10 +104,10 @@ export function ImageInput({
{/* Mode toggle */}
setMode("upload")} icon={ }>
- Upload
+ {t("imageInput.upload")}
setMode("url")} icon={ }>
- Paste URL
+ {t("imageInput.pasteUrl")}
@@ -133,12 +135,12 @@ export function ImageInput({
{isWorking
?
:
}
- {hasImage ? "Replace image" : "Choose image"}
+ {hasImage ? t("imageInput.replace") : t("imageInput.choose")}
{hasImage && !isWorking && (
onChange("")}>
- Remove
+ {t("imageInput.remove")}
)}
{isUploading && progress && (
@@ -159,8 +161,8 @@ export function ImageInput({
{mode === "upload"
- ? `JPG/PNG/WebP/GIF · up to ${formatBytes(maxBytes)}`
- : "Direct link to an image you host elsewhere."}
+ ? t("imageInput.formats", { size: formatBytes(maxBytes) })
+ : t("imageInput.directLink")}
diff --git a/clients/admin/src/components/forbidden-view.tsx b/clients/admin/src/components/forbidden-view.tsx
index 782a7f69c7..7a9c571041 100644
--- a/clients/admin/src/components/forbidden-view.tsx
+++ b/clients/admin/src/components/forbidden-view.tsx
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { ShieldOff } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
type ForbiddenViewProps = {
@@ -13,6 +14,7 @@ type ForbiddenViewProps = {
* as code-chips so the operator can paste them into a permission grant.
*/
export function ForbiddenView({ missing }: ForbiddenViewProps) {
+ const { t } = useTranslation("common");
return (
@@ -21,20 +23,20 @@ export function ForbiddenView({ missing }: ForbiddenViewProps) {
- // 403 · access denied
+ {t("forbidden.kicker")}
- You don't hold the permissions to view this surface.
+ {t("forbidden.title")}
- Ask a root-tenant operator to grant the permissions below to a role you hold.
+ {t("forbidden.description")}
{missing && missing.length > 0 && (
-
missing
+
{t("forbidden.missing")}
diff --git a/clients/admin/src/components/impersonation/active-grants-card.tsx b/clients/admin/src/components/impersonation/active-grants-card.tsx
index b938e09298..be5a4b0424 100644
--- a/clients/admin/src/components/impersonation/active-grants-card.tsx
+++ b/clients/admin/src/components/impersonation/active-grants-card.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ShieldOff, UserCog } from "lucide-react";
+import { useTranslation } from "react-i18next";
import {
listImpersonationGrants,
type ImpersonationGrantDto,
@@ -22,6 +23,7 @@ const REFRESH_INTERVAL_MS = 5_000;
* if the caller can't see impersonation grants (perm-gated upstream).
*/
export function ActiveGrantsCard({ tenantId }: { tenantId: string }) {
+ const { t } = useTranslation("impersonation");
const { user } = useAuth();
const canView = (user?.permissions ?? []).includes(IdentityPermissions.Impersonation.View);
const canRevoke = (user?.permissions ?? []).includes(IdentityPermissions.Impersonation.Revoke);
@@ -66,9 +68,9 @@ export function ActiveGrantsCard({ tenantId }: { tenantId: string }) {
return (
<>
{items.map((g) => (
@@ -121,13 +123,14 @@ function GrantRow({
onRevoke: () => void;
onReopen: () => void;
}) {
+ const { t } = useTranslation("impersonation");
return (
{/* Live-session pulse dot */}
{/* Session detail */}
@@ -143,7 +146,7 @@ function GrantRow({
- Active
+ {t("status.active")}
@@ -178,10 +181,11 @@ function RowActions({
onRevoke: () => void;
onReopen: () => void;
}) {
+ const { t } = useTranslation("impersonation");
if (!canRevoke && !canReopen) {
return (
- view-only
+ {t("card.viewOnly")}
);
}
@@ -192,14 +196,14 @@ function RowActions({
variant="outline"
size="sm"
onClick={onReopen}
- title="Issue a fresh impersonation token — use when you lost the original dashboard tab."
+ title={t("card.reopenTooltip")}
>
- Re-open
+ {t("reopen")}
)}
{canRevoke && (
- Revoke
+ {t("revoke")}
)}
diff --git a/clients/admin/src/components/impersonation/impersonate-dialog.tsx b/clients/admin/src/components/impersonation/impersonate-dialog.tsx
index 0cbe613033..ed648b5f9e 100644
--- a/clients/admin/src/components/impersonation/impersonate-dialog.tsx
+++ b/clients/admin/src/components/impersonation/impersonate-dialog.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation, useQuery, keepPreviousData } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Check, Search, ShieldAlert, UserCog } from "lucide-react";
import { toast } from "sonner";
@@ -19,8 +20,11 @@ import {
import { Monogram } from "@/components/monogram";
import { ApiRequestError } from "@/lib/api-client";
import { env } from "@/env";
+import i18n from "@/i18n";
import { cn } from "@/lib/cn";
+type TFn = (key: string, opts?: Record) => string;
+
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -54,6 +58,7 @@ export function ImpersonateDialog({
tenantName,
prefillUser,
}: Props) {
+ const { t } = useTranslation("impersonation");
const [step, setStep] = useState<"pick" | "configure">(prefillUser ? "configure" : "pick");
const [selected, setSelected] = useState(prefillUser ?? null);
@@ -76,14 +81,14 @@ export function ImpersonateDialog({
>
- Impersonate user
+ {t("dialog.title")}
- Tenant{" "}
+ {t("dialog.tenant")}{" "}
{tenantName ?? tenantId} ·{" "}
{step === "pick"
- ? "pick a user to impersonate."
- : "session details. Token will be issued and opened in the dashboard."}
+ ? t("dialog.descPick")
+ : t("dialog.descConfigure")}
@@ -130,6 +135,7 @@ function PickStep({
onPick: (user: UserDto) => void;
onCancel: () => void;
}) {
+ const { t } = useTranslation("impersonation");
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
@@ -164,8 +170,8 @@ function PickStep({
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
- placeholder="Search by name, email, or username…"
- aria-label="Search users to impersonate"
+ placeholder={t("dialog.pick.searchPlaceholder")}
+ aria-label={t("dialog.pick.searchAria")}
className="pl-9"
/>
@@ -175,20 +181,20 @@ function PickStep({
{query.error instanceof ApiRequestError
? query.error.problem?.detail ?? query.error.message
- : "Failed to load users."}
+ : t("dialog.pick.loadError")}
)}
{query.isLoading && (
- Loading
+ {t("dialog.pick.loading")}
)}
{!query.isLoading && users.length === 0 && (
- No users match{debounced ? ` “${debounced}”` : ""}.
+ {t("dialog.pick.noMatch")}{debounced ? ` “${debounced}”` : ""}.
)}
@@ -213,7 +219,7 @@ function PickStep({
{[user.firstName, user.lastName].filter(Boolean).join(" ") ||
user.userName ||
user.email ||
- "Unnamed"}
+ t("dialog.pick.unnamed")}
{user.userName && (
@@ -235,7 +241,7 @@ function PickStep({
- Cancel
+ {t("dialog.cancel")}
>
@@ -257,6 +263,7 @@ function ConfigureStep({
onBack?: () => void;
onDone: () => void;
}) {
+ const { t } = useTranslation("impersonation");
const [reason, setReason] = useState("");
const [minutes, setMinutes] = useState(15);
@@ -273,8 +280,8 @@ function ConfigureStep({
}),
onSuccess: (response) => {
handoffToDashboard(response, tenantId);
- toast.success(`Impersonation started · ${minutes} min`, {
- description: `Opened the dashboard as ${labelFor(user)}. End impersonation from inside the dashboard tab.`,
+ toast.success(t("dialog.toast.started", { minutes }), {
+ description: t("dialog.toast.startedDesc", { name: labelFor(user, t) }),
});
onDone();
},
@@ -283,7 +290,7 @@ function ConfigureStep({
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: err.message;
- toast.error("Impersonation failed", { description: detail });
+ toast.error(t("dialog.toast.failed"), { description: detail });
},
});
@@ -293,7 +300,7 @@ function ConfigureStep({
- // Duration
+ {t("dialog.configure.duration")}
{DURATION_OPTIONS.map((opt) => {
const active = minutes === opt.minutes;
@@ -313,7 +320,7 @@ function ConfigureStep({
{opt.minutes}
-
minutes
+
{t("dialog.configure.minutes")}
);
})}
@@ -325,7 +332,7 @@ function ConfigureStep({
htmlFor="impersonation-reason"
className="meta flex items-center gap-1.5 text-[var(--color-muted-foreground)]"
>
- Reason
+ {t("dialog.configure.reason")}
·
@@ -334,7 +341,7 @@ function ConfigureStep({
id="impersonation-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
- placeholder="e.g. Customer ticket #4821 — verifying ledger discrepancy"
+ placeholder={t("dialog.configure.reasonPlaceholder")}
rows={3}
maxLength={500}
className={cn(
@@ -346,8 +353,7 @@ function ConfigureStep({
/>
- Recorded in the security audit trail — be specific enough that a reviewer can
- reconstruct the case later.
+ {t("dialog.configure.reasonHelp")}
- Everything you do is attributed to your account.
- {" "}The session token carries actor claims; the audit trail will show
- both the user being impersonated and you as the actor. End from the dashboard
- tab when done.
+ {t("dialog.configure.warningStrong")}
+ {t("dialog.configure.warningRest")}
@@ -374,7 +378,7 @@ function ConfigureStep({
{onBack && (
- Choose another user
+ {t("dialog.configure.chooseAnother")}
)}
mutation.mutate()}
>
{mutation.isPending ? (
- "Issuing token…"
+ t("dialog.configure.issuing")
) : (
<>
- Start {minutes}-min impersonation
+ {t("dialog.configure.start", { minutes })}
>
)}
@@ -404,6 +408,7 @@ function SelectedUserCard({
tenantId: string;
tenantName?: string;
}) {
+ const { t } = useTranslation("impersonation");
return (
- {labelFor(user)}
+ {labelFor(user, t)}
{user.userName && (
@{user.userName}
@@ -443,12 +448,21 @@ function SelectedUserCard({
* history.replaceState() to strip the hash before any render.
*
* `expiresAt` is included so the dashboard can show a countdown.
+ *
+ * `locale` carries the OPERATOR's language across the origin boundary. The
+ * 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
+ * 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.
*/
function handoffToDashboard(response: ImpersonationResponse, tenantId: string) {
const params = new URLSearchParams();
params.set("token", response.accessToken);
params.set("tenant", tenantId);
params.set("expiresAt", response.accessTokenExpiresAt);
+ params.set("locale", i18n.language);
const url = `${env.dashboardUrl}/#impersonate?${params.toString()}`;
// noopener+noreferrer so the opened tab can't navigate this one and the
// referrer header is suppressed entirely (defense in depth — the hash
@@ -456,11 +470,11 @@ function handoffToDashboard(response: ImpersonationResponse, tenantId: string) {
window.open(url, "_blank", "noopener,noreferrer");
}
-function labelFor(user: UserDto): string {
+function labelFor(user: UserDto, t: TFn): string {
return (
[user.firstName, user.lastName].filter(Boolean).join(" ").trim() ||
user.userName ||
user.email ||
- "Unnamed user"
+ t("dialog.unnamedUser")
);
}
diff --git a/clients/admin/src/components/impersonation/revoke-grant-dialog.tsx b/clients/admin/src/components/impersonation/revoke-grant-dialog.tsx
index 9b78466549..ef8339d82b 100644
--- a/clients/admin/src/components/impersonation/revoke-grant-dialog.tsx
+++ b/clients/admin/src/components/impersonation/revoke-grant-dialog.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ShieldOff } from "lucide-react";
import { toast } from "sonner";
@@ -34,6 +35,7 @@ type Props = {
* operator knows exactly what they're killing.
*/
export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) {
+ const { t } = useTranslation("impersonation");
const queryClient = useQueryClient();
const [reason, setReason] = useState("");
const open = grant !== null;
@@ -46,8 +48,10 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) {
const mutation = useMutation({
mutationFn: () => revokeImpersonationGrant(grant!.id, reason.trim() || undefined),
onSuccess: (updated) => {
- toast.success("Impersonation revoked", {
- description: `Token for ${updated.impersonatedUserName ?? updated.impersonatedUserId} will be rejected on the next request.`,
+ toast.success(t("revokeDialog.toast.revoked"), {
+ description: t("revokeDialog.toast.revokedDesc", {
+ name: updated.impersonatedUserName ?? updated.impersonatedUserId,
+ }),
});
queryClient.invalidateQueries({ queryKey: ["impersonation-grants"] });
onRevoked?.(updated);
@@ -58,7 +62,7 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) {
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: err.message;
- toast.error("Revoke failed", { description: detail });
+ toast.error(t("revokeDialog.toast.revokeFailed"), { description: detail });
},
});
@@ -73,11 +77,10 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) {
>
- Revoke impersonation grant
+ {t("revokeDialog.title")}
- The issued token will be rejected on the next authenticated request (within ~1
- second of cache TTL). The session in the dashboard tab is killed without warning.
+ {t("revokeDialog.description")}
@@ -89,13 +92,13 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) {
htmlFor="revoke-reason"
className="meta text-[var(--color-muted-foreground)]"
>
- Reason (optional)
+ {t("revokeDialog.reasonLabel")}
onOpenChange(false)} disabled={mutation.isPending}>
- Cancel
+ {t("revokeDialog.cancel")}
- {mutation.isPending ? "Revoking…" : "Revoke now"}
+ {mutation.isPending ? t("revokeDialog.revoking") : t("revokeDialog.revokeNow")}
@@ -130,9 +133,10 @@ export function RevokeGrantDialog({ grant, onOpenChange, onRevoked }: Props) {
}
function GrantSummary({ grant }: { grant: ImpersonationGrantDto }) {
+ const { t } = useTranslation("impersonation");
return (
-
+
{grant.impersonatedUserName ?? grant.impersonatedUserId}
{" "}
@@ -140,16 +144,16 @@ function GrantSummary({ grant }: { grant: ImpersonationGrantDto }) {
{grant.impersonatedTenantId}
-
+
{grant.actorUserName ?? grant.actorUserId} {" "}
{grant.actorTenantId}
-
+
{grant.reason || "—"}
-
+
{new Date(grant.expiresAtUtc).toLocaleString()}
diff --git a/clients/admin/src/components/layout/app-shell.tsx b/clients/admin/src/components/layout/app-shell.tsx
index 62733a3013..4f3a65fee1 100644
--- a/clients/admin/src/components/layout/app-shell.tsx
+++ b/clients/admin/src/components/layout/app-shell.tsx
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { Outlet } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
import {
@@ -22,6 +23,7 @@ import { InactivityGuard } from "@/components/auth/inactivity-guard";
* add a banner component here that reads from admin's AuthContext.
*/
export function AppShell() {
+ const { t } = useTranslation("common");
return (
{/* Skip-to-content link — first focusable element. */}
@@ -29,7 +31,7 @@ export function AppShell() {
href="#main-content"
className="sr-only z-[100] rounded-md bg-[var(--color-foreground)] px-4 py-2 text-sm font-medium text-[var(--color-background)] focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:outline-none focus:ring-2 focus:ring-[var(--color-ring)]"
>
- Skip to content
+ {t("shell.skipToContent")}
@@ -50,7 +52,7 @@ export function AppShell() {
role="status"
className="flex min-h-[40vh] items-center justify-center text-sm text-[var(--color-muted-foreground)]"
>
- Loading…
+ {t("shell.loading")}
}
>
diff --git a/clients/admin/src/components/layout/mobile-nav.tsx b/clients/admin/src/components/layout/mobile-nav.tsx
index c055286731..4c50ea2ae7 100644
--- a/clients/admin/src/components/layout/mobile-nav.tsx
+++ b/clients/admin/src/components/layout/mobile-nav.tsx
@@ -8,6 +8,7 @@ import {
type ReactNode,
} from "react";
import { useLocation } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { Menu } from "lucide-react";
import {
Dialog as Sheet,
@@ -64,6 +65,7 @@ export function MobileNavRoot() {
const { open, setOpen } = useMobileNav();
const location = useLocation();
const { user, permissionsHydrated } = useAuth();
+ const { t } = useTranslation("common");
const granted = permissionsHydrated ? (user?.permissions ?? []) : [];
const visibleSections: NavSection[] = useMemo(
@@ -93,9 +95,9 @@ export function MobileNavRoot() {
{/* Radix Dialog requires a Title for the accessible tree. */}
- Primary navigation
+ {t("shell.primaryNav")}
- Admin sections and account links.
+ {t("shell.mobileNavDescription")}
{/* Brand row — matches Topbar height */}
@@ -115,7 +117,7 @@ export function MobileNavRoot() {
fullstackhero
- Admin
+ {t("shell.appSubtitle")}
@@ -143,11 +145,12 @@ export function MobileNavRoot() {
*/
export function MobileNavTrigger({ className }: { className?: string }) {
const { setOpen } = useMobileNav();
+ const { t } = useTranslation("common");
const onClick = useCallback(() => setOpen(true), [setOpen]);
return (
hero
- Admin
+ {t("shell.appSubtitle")}
)}
@@ -107,9 +109,9 @@ export function Sidebar() {
void;
}) {
+ const { t } = useTranslation("common");
return (
{/* Top-level singletons: Overview */}
@@ -274,6 +277,7 @@ function AccordionSection({
onToggle: () => void;
onNavigate?: () => void;
}) {
+ const { t } = useTranslation("nav");
const SectionIcon = section.icon;
return (
-
{section.caption}
+
{t(section.caption)}
void;
}) {
+ const { t } = useTranslation("nav");
const Icon = item.icon;
+ const label = t(item.label);
return (
cn(
@@ -399,7 +405,7 @@ function NavItemLink({
- {!collapsed && {item.label} }
+ {!collapsed && {label} }
{/* Tooltip in collapsed mode */}
{collapsed && (
@@ -413,7 +419,7 @@ function NavItemLink({
"group-hover/nav:opacity-100 group-focus-visible/nav:opacity-100",
)}
>
- {item.label}
+ {label}
)}
>
diff --git a/clients/admin/src/components/layout/topbar.tsx b/clients/admin/src/components/layout/topbar.tsx
index 575d980d84..7d85404d21 100644
--- a/clients/admin/src/components/layout/topbar.tsx
+++ b/clients/admin/src/components/layout/topbar.tsx
@@ -1,9 +1,11 @@
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import {
Check,
ChevronsUpDown,
+ Languages,
LogOut,
Moon,
Settings as SettingsIcon,
@@ -32,7 +34,9 @@ import {
} from "@/components/ui/dropdown-menu";
import { Avatar } from "@/components/ui/avatar";
import { useAuth } from "@/auth/use-auth";
-import { getMyProfile } from "@/api/users";
+import i18n, { SUPPORTED } from "@/i18n";
+import { getMyProfile, updateMyProfile } from "@/api/users";
+import { refreshAccessToken } from "@/lib/api-client";
import { useTheme } from "@/components/theme/theme-provider";
import { cn } from "@/lib/cn";
@@ -118,6 +122,36 @@ function ThemeMenuItem({
);
}
+function LanguageMenuItem({
+ label,
+ active,
+ onSelect,
+}: {
+ label: string;
+ active: boolean;
+ onSelect: () => void;
+}) {
+ return (
+ {
+ // Keep the menu open on select so the switch reflects in place (the
+ // section label localizes immediately) instead of the menu closing.
+ e.preventDefault();
+ onSelect();
+ }}
+ className="!my-0 flex cursor-pointer items-center gap-2.5 rounded-md !px-2.5 !py-1.5"
+ >
+
+
+ {label}
+
+ {active && (
+
+ )}
+
+ );
+}
+
function SimpleMenuItem({
icon: Icon,
label,
@@ -147,6 +181,9 @@ function SimpleMenuItem({
export function Topbar() {
const { user, logout } = useAuth();
const { theme, setTheme } = useTheme();
+ // useTranslation subscribes this component to `languageChanged`, so the
+ // menu labels and the active-locale check re-render the instant we switch.
+ const { t } = useTranslation();
const navigate = useNavigate();
const [confirmOpen, setConfirmOpen] = useState(false);
@@ -159,7 +196,53 @@ export function Topbar() {
staleTime: 60_000,
});
const avatarUrl = profile.data?.imageUrl ?? null;
- const displayName = user?.name ?? user?.email ?? "Unknown";
+ const displayName = user?.name ?? user?.email ?? t("shell.unknownUser");
+
+ // Hydrate the UI language from the server-persisted locale when the profile first
+ // arrives, so a locale chosen on another device carries over on this one.
+ //
+ // It stops the moment the user picks a language HERE. updateMyProfile is a
+ // read-modify-write with no concurrency token, so two switches in quick succession can
+ // land out of network order and leave the server holding the earlier choice; a later
+ // refetch would then flip the UI back to it. See the `ponytail:` note below.
+ const languageChosenThisSession = useRef(false);
+ const persistedLocale = profile.data?.locale;
+ useEffect(() => {
+ if (languageChosenThisSession.current) return;
+ if (persistedLocale && persistedLocale !== i18n.language) {
+ void i18n.changeLanguage(persistedLocale);
+ }
+ }, [persistedLocale]);
+
+ const updateProfile = useMutation({
+ mutationFn: updateMyProfile,
+ onSuccess: () => {
+ // 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);
+ },
+ });
+
+ // Switch the UI language and persist it. Only the locale travels through the
+ // mutation argument (never closed-over state); updateMyProfile re-reads the
+ // profile from the server and merges the current name/phone, so the switch
+ // never wipes those fields even if this component's profile query has not
+ // resolved (or failed).
+ //
+ // ponytail: two rapid switches issue two independent GET-then-PUT cycles with no
+ // concurrency token, so out-of-order delivery can leave the server holding the earlier
+ // choice. The UI no longer follows a stale server value (the hydration guard above), so
+ // the damage is bounded to "the language did not stick across a reload". The real fix is
+ // an ETag / RowVersion with If-Match on PUT /identity/profile, which is a contract
+ // change to an existing endpoint and belongs in its own PR. Tracked in
+ // https://github.com/fullstackhero/dotnet-starter-kit/issues/1359.
+ const onSelectLanguage = (tag: string) => {
+ languageChosenThisSession.current = true;
+ void i18n.changeLanguage(tag);
+ updateProfile.mutate({ locale: tag });
+ };
const onConfirmSignOut = () => {
setConfirmOpen(false);
@@ -189,7 +272,7 @@ export function Topbar() {
- Theme
+ {t("theme.title")}
setTheme("light")}
/>
setTheme("dark")}
/>
@@ -271,19 +354,36 @@ export function Topbar() {
+ {/* Language */}
+
+ {t("language")}
+
+
+ {SUPPORTED.map((tag) => (
+ onSelectLanguage(tag)}
+ />
+ ))}
+
+
+
+
{/* Account quick actions */}
- Account
+ {t("account.title")}
navigate("/settings/profile")}
/>
navigate("/settings")}
/>
@@ -298,7 +398,7 @@ export function Topbar() {
className="!my-0 cursor-pointer rounded-md !px-2.5 !py-1.5"
>
- Sign out
+ {t("actions.signOut")}
@@ -308,11 +408,8 @@ export function Topbar() {
- Sign out of fullstackhero?
-
- You'll need to sign in again to access this admin. Any unsaved
- work in this session will be lost.
-
+ {t("signOut.title")}
+ {t("signOut.description")}
@@ -340,7 +437,7 @@ export function Topbar() {
size="sm"
onClick={() => setConfirmOpen(false)}
>
- Cancel
+ {t("actions.cancel")}
- Sign out
+ {t("actions.signOut")}
diff --git a/clients/admin/src/components/list/error-band.tsx b/clients/admin/src/components/list/error-band.tsx
index 3f07300c04..9ed7e5d228 100644
--- a/clients/admin/src/components/list/error-band.tsx
+++ b/clients/admin/src/components/list/error-band.tsx
@@ -1,4 +1,5 @@
import { AlertTriangle } from "lucide-react";
+import { useTranslation } from "react-i18next";
type ErrorBandProps = {
message: string;
@@ -10,7 +11,8 @@ type ErrorBandProps = {
* ErrorBand — inline failure surface used between toolbar and content.
* Mono-caps eyebrow + destructive tint, matched to the rest of Console.
*/
-export function ErrorBand({ message, kind = "failure" }: ErrorBandProps) {
+export function ErrorBand({ message, kind }: ErrorBandProps) {
+ const { t } = useTranslation("common");
return (
- {kind} ·
+ {kind ?? t("errorBand.failure")} ·
{message}
diff --git a/clients/admin/src/components/list/filter-bar.tsx b/clients/admin/src/components/list/filter-bar.tsx
index 2d36ae487a..46ec389af3 100644
--- a/clients/admin/src/components/list/filter-bar.tsx
+++ b/clients/admin/src/components/list/filter-bar.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
type FilterBarProps = {
@@ -14,6 +15,7 @@ type FilterBarProps = {
* screens so it never overflows.
*/
export function FilterBar({ children, trailing, className }: FilterBarProps) {
+ const { t } = useTranslation("common");
return (
-
// Filters
+
// {t("filterBar.filters")}
{children}
{trailing &&
{trailing}
}
diff --git a/clients/admin/src/components/list/loading.tsx b/clients/admin/src/components/list/loading.tsx
index 8bfa7e7769..995206cc05 100644
--- a/clients/admin/src/components/list/loading.tsx
+++ b/clients/admin/src/components/list/loading.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
type LoadingRowProps = {
@@ -10,7 +11,8 @@ type LoadingRowProps = {
* a list while the first page resolves. Subtle, no spinner — the
* caret-style ellipsis is enough.
*/
-export function LoadingRow({ className, label = "Loading" }: LoadingRowProps) {
+export function LoadingRow({ className, label }: LoadingRowProps) {
+ const { t } = useTranslation("common");
return (
- {label}
+ {label ?? t("loading.label")}
);
diff --git a/clients/admin/src/components/list/pagination.tsx b/clients/admin/src/components/list/pagination.tsx
index a9bbfb0c08..13bc1a5ee4 100644
--- a/clients/admin/src/components/list/pagination.tsx
+++ b/clients/admin/src/components/list/pagination.tsx
@@ -1,4 +1,5 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
type PaginationProps = {
@@ -30,21 +31,28 @@ export function Pagination({
hasNext,
onPrev,
onNext,
- noun = "items",
+ noun,
}: PaginationProps) {
+ const { t } = useTranslation("common");
const p = String(page).padStart(2, "0");
const tp = String(Math.max(totalPages, 1)).padStart(2, "0");
return (
- Showing {shown} of {totalCount} {noun} · folio {p} / {tp}
+ {t("pagination.showing", {
+ shown,
+ total: totalCount,
+ noun: noun ?? t("pagination.items"),
+ page: p,
+ pages: tp,
+ })}
- Previous
+ {t("pagination.previous")}
- Next
+ {t("pagination.next")}
diff --git a/clients/admin/src/components/notifications/notification-bell.tsx b/clients/admin/src/components/notifications/notification-bell.tsx
index 7fd477c1c5..4e84b41b30 100644
--- a/clients/admin/src/components/notifications/notification-bell.tsx
+++ b/clients/admin/src/components/notifications/notification-bell.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, BellRing, CheckCheck } from "lucide-react";
@@ -12,6 +13,7 @@ import {
} from "@/api/notifications";
import { useRealtimeEvent } from "@/realtime/realtime-context";
import { useAuth } from "@/auth/use-auth";
+import { formatDate } from "@/lib/format";
import { cn } from "@/lib/cn";
/**
@@ -21,6 +23,7 @@ import { cn } from "@/lib/cn";
* /notifications.
*/
export function NotificationBell() {
+ const { t } = useTranslation("notifications");
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
@@ -78,7 +81,7 @@ export function NotificationBell() {
const markAll = useMutation({
mutationFn: markAllNotificationsRead,
onSuccess: (data) => {
- toast.success(`${data.updated} ${data.updated === 1 ? "notification" : "notifications"} marked read`);
+ toast.success(t("toast.marked", { count: data.updated }));
queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
});
@@ -93,7 +96,7 @@ export function NotificationBell() {
setOpen((v) => !v)}
- aria-label={count > 0 ? `${count} unread notifications` : "Notifications"}
+ aria-label={count > 0 ? t("bell.ariaUnread", { count }) : t("bell.ariaDefault")}
aria-haspopup="true"
aria-expanded={open}
className={cn(
@@ -124,11 +127,11 @@ export function NotificationBell() {
className="fixed inset-0 z-40 cursor-default bg-transparent"
/>
-
// Notifications
+
{t("bell.heading")}
{count > 0 && (
- Mark all read
+ {t("bell.markAllRead")}
)}
@@ -148,13 +151,13 @@ export function NotificationBell() {
role="status"
className="px-3 py-6 text-center font-mono text-xs uppercase tracking-[0.18em] text-[var(--color-muted-foreground)]"
>
- Loading
+ {t("bell.loading")}
)}
{!recent.isLoading && items.length === 0 && (
- You're all caught up.
+ {t("bell.empty")}
)}
@@ -176,7 +179,7 @@ export function NotificationBell() {
onClick={() => setOpen(false)}
className="inline-flex items-center gap-1 font-mono text-[10.5px] uppercase tracking-[0.14em] text-[var(--color-foreground)] hover:underline"
>
- View all
+ {t("bell.viewAll")}
@@ -195,6 +198,7 @@ function Row({
onMarkRead: () => void;
onClick: () => void;
}) {
+ const { t } = useTranslation("notifications");
const unread = !notif.readAtUtc;
const body = (
@@ -242,7 +246,7 @@ function Row({
e.stopPropagation();
onMarkRead();
}}
- aria-label="Mark as read"
+ aria-label={t("bell.markAsRead")}
className="invisible mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded text-[var(--color-muted-foreground)] transition-colors hover:bg-[var(--color-muted)] hover:text-[var(--color-foreground)] group-hover/notif:visible"
>
@@ -264,5 +268,5 @@ function formatRelative(value: string): string {
if (hr < 24) return `${hr}h`;
const day = Math.round(hr / 24);
if (day < 14) return `${day}d`;
- return d.toLocaleDateString();
+ return formatDate(value);
}
diff --git a/clients/admin/src/components/roles/create-role-dialog.tsx b/clients/admin/src/components/roles/create-role-dialog.tsx
index 95fb0628f7..4548559e5c 100644
--- a/clients/admin/src/components/roles/create-role-dialog.tsx
+++ b/clients/admin/src/components/roles/create-role-dialog.tsx
@@ -1,3 +1,5 @@
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
@@ -22,16 +24,19 @@ import { ApiRequestError } from "@/lib/api-client";
// ─── Schema (identical to the old create page) ───────────────────────────────
-const schema = z.object({
- name: z
- .string()
- .trim()
- .min(2, "At least 2 characters.")
- .max(64, "Keep under 64 characters."),
- description: z.string().trim().max(256, "Keep under 256 characters.").optional(),
-});
+type TFn = (key: string) => string;
-type FormValues = z.infer
;
+const makeSchema = (t: TFn) =>
+ z.object({
+ name: z
+ .string()
+ .trim()
+ .min(2, t("create.validation.min2"))
+ .max(64, t("create.validation.nameMax")),
+ description: z.string().trim().max(256, t("create.validation.descMax")).optional(),
+ });
+
+type FormValues = z.infer>;
// ─── Dialog ───────────────────────────────────────────────────────────────────
@@ -42,8 +47,10 @@ export function CreateRoleDialog({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
+ const { t } = useTranslation("roles");
const navigate = useNavigate();
const queryClient = useQueryClient();
+ const schema = useMemo(() => makeSchema(t), [t]);
const {
register,
@@ -64,7 +71,7 @@ export function CreateRoleDialog({
description: values.description?.trim() ? values.description : null,
}),
onSuccess: (result) => {
- toast.success(`Role ${result.name} created`);
+ toast.success(t("create.created", { name: result.name }));
queryClient.invalidateQueries({ queryKey: ["roles"] });
handleClose();
navigate(`/roles/${result.id}`);
@@ -74,7 +81,7 @@ export function CreateRoleDialog({
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: err.message;
- toast.error("Create failed", { description: detail });
+ toast.error(t("create.createFailed"), { description: detail });
},
});
@@ -108,22 +115,21 @@ export function CreateRoleDialog({
- New role
+ {t("create.title")}
- Create a role, then grant it permissions on its detail page. The role name is what shows
- up in user role assignments — choose something descriptive.
+ {t("create.description")}
{/* ── Form ── */}
- {submitting ? "Saving…" : "Create role"}
+ {submitting ? t("create.saving") : t("create.submit")}
diff --git a/clients/admin/src/components/route-error.tsx b/clients/admin/src/components/route-error.tsx
index 730bf97902..60dc93d88c 100644
--- a/clients/admin/src/components/route-error.tsx
+++ b/clients/admin/src/components/route-error.tsx
@@ -1,4 +1,6 @@
import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
import { Button } from "@/components/ui/button";
/**
@@ -9,9 +11,10 @@ import { Button } from "@/components/ui/button";
* rare cases an operator needs it.
*/
export function RouteError() {
+ const { t } = useTranslation("common");
const error = useRouteError();
const navigate = useNavigate();
- const { status, title, detail } = describe(error);
+ const { status, title, detail } = describe(error, t);
return (
@@ -26,7 +29,7 @@ export function RouteError() {
/>
-
// SYSTEM RESPONSE
+
{t("routeError.systemResponse")}
{status}
@@ -40,7 +43,7 @@ export function RouteError() {
{detail && (
- // stack trace · click to expand
+ {t("routeError.stackTrace")}
{detail}
@@ -50,10 +53,10 @@ export function RouteError() {
navigate(0)}>
- Reload →
+ {t("routeError.reload")}
navigate("/")}>
- Return to overview
+ {t("routeError.returnOverview")}
@@ -61,18 +64,18 @@ export function RouteError() {
);
}
-function describe(error: unknown): { status: string; title: string; detail?: string } {
+function describe(error: unknown, t: TFunction): { status: string; title: string; detail?: string } {
if (isRouteErrorResponse(error)) {
return {
status: String(error.status),
- title: error.statusText || "Route error",
+ title: error.statusText || t("routeError.routeError"),
detail: typeof error.data === "string" ? error.data : safeStringify(error.data),
};
}
if (error instanceof Error) {
return { status: "5XX", title: error.message, detail: error.stack };
}
- return { status: "5XX", title: "Unexpected error", detail: safeStringify(error) };
+ return { status: "5XX", title: t("routeError.unexpected"), detail: safeStringify(error) };
}
function safeStringify(value: unknown): string | undefined {
diff --git a/clients/admin/src/components/sessions/user-sessions-card.tsx b/clients/admin/src/components/sessions/user-sessions-card.tsx
index 6e7f8e81b2..74ab71f825 100644
--- a/clients/admin/src/components/sessions/user-sessions-card.tsx
+++ b/clients/admin/src/components/sessions/user-sessions-card.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import { LogOut, Monitor, Smartphone } from "lucide-react";
import { toast } from "sonner";
import {
@@ -20,12 +21,15 @@ import { IdentityPermissions } from "@/lib/permissions";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
+type TFn = (key: string, opts?: Record
) => string;
+
/**
* UserSessionsCard — admin view on user-detail. Lists every session a user
* has and lets a privileged operator revoke them individually or in bulk.
* Hidden when the operator lacks Sessions.ViewAll.
*/
export function UserSessionsCard({ userId }: { userId: string }) {
+ const { t } = useTranslation("sessions");
const { user } = useAuth();
const granted = user?.permissions ?? [];
const canView = granted.includes(IdentityPermissions.Sessions.ViewAll);
@@ -54,20 +58,20 @@ export function UserSessionsCard({ userId }: { userId: string }) {
mutationFn: (sessionId: string) => adminRevokeUserSession(userId, sessionId),
onMutate: (sessionId) => addBusy(sessionId),
onSuccess: () => {
- toast.success("Session revoked");
+ toast.success(t("revoked"));
queryClient.invalidateQueries({ queryKey: ["admin", "user-sessions", userId] });
},
- onError: (err) => toast.error("Revoke failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("revokeFailed"), { description: describe(err) }),
onSettled: (_d, _e, sessionId) => clearBusy(sessionId),
});
const revokeAll = useMutation({
mutationFn: () => adminRevokeAllUserSessions(userId),
onSuccess: (data) => {
- toast.success(`Revoked ${data.revokedCount} ${data.revokedCount === 1 ? "session" : "sessions"}`);
+ toast.success(t("revokedCount", { count: data.revokedCount }));
queryClient.invalidateQueries({ queryKey: ["admin", "user-sessions", userId] });
},
- onError: (err) => toast.error("Revoke all failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("revokeAllFailed"), { description: describe(err) }),
});
if (!canView) return null;
@@ -78,24 +82,24 @@ export function UserSessionsCard({ userId }: { userId: string }) {
return (
{query.isError ? (
) : query.isLoading ? (
- Loading
+ {t("card.loadingLabel")}
) : sessions.length === 0 ? (
- No sessions on record for this user.
+ {t("card.noneOnRecord")}
) : (
<>
@@ -107,6 +111,7 @@ export function UserSessionsCard({ userId }: { userId: string }) {
canRevoke={canRevoke && s.isActive}
busy={busyIds.has(s.id)}
onRevoke={() => revokeOne.mutate(s.id)}
+ t={t}
/>
))}
@@ -114,7 +119,7 @@ export function UserSessionsCard({ userId }: { userId: string }) {
{canRevoke && activeCount > 0 && (
- {activeCount} active {activeCount === 1 ? "session" : "sessions"}
+ {t("card.activeCount", { count: activeCount })}
- {revokeAll.isPending ? "Signing out…" : "Revoke all sessions"}
+ {revokeAll.isPending ? t("signingOut") : t("card.revokeAll")}
)}
@@ -139,11 +144,13 @@ function SessionRow({
canRevoke,
busy,
onRevoke,
+ t,
}: {
session: UserSessionDto;
canRevoke: boolean;
busy: boolean;
onRevoke: () => void;
+ t: TFn;
}) {
const Icon = (session.deviceType ?? "").toLowerCase().includes("mobile") ? Smartphone : Monitor;
return (
@@ -155,25 +162,25 @@ function SessionRow({
>
-
{describeDevice(session)}
+
{describeDevice(session, t)}
- {session.ipAddress ?? "unknown ip"}
- · last seen {formatRelative(session.lastActivityAt)}
+ {session.ipAddress ?? t("unknownIp")}
+ · {t("lastSeen", { time: formatRelative(session.lastActivityAt, t) })}
{session.isActive ? (
- Active
+ {t("badgeActive")}
) : (
- Revoked
+ {t("badgeRevoked")}
)}
{canRevoke ? (
- {busy ? "Revoking…" : "Revoke"}
+ {busy ? t("revoking") : t("revoke")}
) : (
@@ -182,26 +189,26 @@ function SessionRow({
);
}
-function describeDevice(s: UserSessionDto): string {
- const browser = s.browser ?? "Unknown browser";
+function describeDevice(s: UserSessionDto, t: TFn): string {
+ const browser = s.browser ?? t("unknownBrowser");
const version = s.browserVersion ? ` ${s.browserVersion}` : "";
- const os = s.operatingSystem ?? "unknown os";
- return `${browser}${version} on ${os}`;
+ const os = s.operatingSystem ?? t("unknownOs");
+ return t("deviceOn", { device: `${browser}${version}`, os });
}
-function formatRelative(value?: string | null): string {
+function formatRelative(value: string | null | undefined, t: TFn): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const diff = Date.now() - d.getTime();
const sec = Math.round(diff / 1000);
- if (sec < 60) return `${sec}s ago`;
+ if (sec < 60) return t("relative.secondsAgo", { n: sec });
const min = Math.round(sec / 60);
- if (min < 60) return `${min}m ago`;
+ if (min < 60) return t("relative.minutesAgo", { n: min });
const hr = Math.round(min / 60);
- if (hr < 24) return `${hr}h ago`;
+ if (hr < 24) return t("relative.hoursAgo", { n: hr });
const day = Math.round(hr / 24);
- if (day < 14) return `${day}d ago`;
+ if (day < 14) return t("relative.daysAgo", { n: day });
return d.toLocaleDateString();
}
diff --git a/clients/admin/src/components/tenants/adjust-validity-dialog.tsx b/clients/admin/src/components/tenants/adjust-validity-dialog.tsx
index af982a91a7..245bdbcec5 100644
--- a/clients/admin/src/components/tenants/adjust-validity-dialog.tsx
+++ b/clients/admin/src/components/tenants/adjust-validity-dialog.tsx
@@ -1,4 +1,5 @@
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -19,17 +20,21 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ApiRequestError } from "@/lib/api-client";
+import { formatDate } from "@/lib/format";
+
+type TFn = (key: string) => string;
// A `type="date"` input yields a `YYYY-MM-DD` string. zod validates the shape
// and that it parses to a real calendar date.
-const schema = z.object({
- validUpto: z
- .string()
- .min(1, "Pick a date.")
- .refine((v) => !Number.isNaN(new Date(v).getTime()), "Enter a valid date."),
-});
+const makeSchema = (t: TFn) =>
+ z.object({
+ validUpto: z
+ .string()
+ .min(1, t("adjust.validation.pickDate"))
+ .refine((v) => !Number.isNaN(new Date(v).getTime()), t("adjust.validation.invalidDate")),
+ });
-type FormValues = z.infer;
+type FormValues = z.infer>;
function describe(err: unknown, fallback: string): string {
if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message;
@@ -37,12 +42,6 @@ function describe(err: unknown, fallback: string): string {
return fallback;
}
-function formatDate(value?: string | null): string {
- if (!value) return "—";
- const d = new Date(value);
- return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString();
-}
-
/** `YYYY-MM-DD` (the native date input value) for an ISO/date string, for prefill. */
function toDateInputValue(value?: string | null): string {
if (!value) return "";
@@ -67,7 +66,9 @@ export function AdjustValidityDialog({
tenantId: string;
validUpto?: string;
}) {
+ const { t } = useTranslation("tenants");
const queryClient = useQueryClient();
+ const schema = useMemo(() => makeSchema(t), [t]);
const {
register,
@@ -88,14 +89,14 @@ export function AdjustValidityDialog({
// Pass the date via mutate(arg) — never close over form state at submit time.
mutationFn: (value: string) => adjustTenantValidity(tenantId, new Date(value).toISOString()),
onSuccess: (result) => {
- toast.success("Validity adjusted", {
- description: `Valid until ${formatDate(result.validUpto)}. No invoice was issued.`,
+ toast.success(t("adjust.toast.title"), {
+ description: t("adjust.toast.description", { date: formatDate(result.validUpto) }),
});
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["tenants"] });
handleClose();
},
- onError: (err) => toast.error("Adjust failed", { description: describe(err, "Could not adjust validity.") }),
+ onError: (err) => toast.error(t("adjust.toast.failed"), { description: describe(err, t("adjust.toast.failedFallback")) }),
});
function handleClose() {
@@ -125,13 +126,10 @@ export function AdjustValidityDialog({
>
- Adjust validity
+ {t("adjust.title")}
- Set this tenant's expiry date directly — an operator override with{" "}
- no invoice . Use for comps or
- corrections; renewals that should bill belong in Renew. Currently valid until{" "}
- {formatDate(validUpto)}.
+ {t("adjust.description", { date: formatDate(validUpto) })}
@@ -139,9 +137,9 @@ export function AdjustValidityDialog({
@@ -150,10 +148,10 @@ export function AdjustValidityDialog({
- Cancel
+ {t("adjust.cancel")}
- {submitting ? "Saving…" : "Adjust validity"}
+ {submitting ? t("adjust.saving") : t("adjust.submit")}
diff --git a/clients/admin/src/components/tenants/create-tenant-dialog.tsx b/clients/admin/src/components/tenants/create-tenant-dialog.tsx
index 2b47acaf9a..8c71135646 100644
--- a/clients/admin/src/components/tenants/create-tenant-dialog.tsx
+++ b/clients/admin/src/components/tenants/create-tenant-dialog.tsx
@@ -1,4 +1,5 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Controller, useForm } from "react-hook-form";
@@ -31,41 +32,34 @@ import {
} from "@/components/ui/dialog";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
+import { formatCurrency } from "@/lib/format";
// ─── Schema (unchanged contract) ────────────────────────────────────────────
const TENANT_ID_RE = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
-const schema = z.object({
- id: z
- .string()
- .trim()
- .regex(
- TENANT_ID_RE,
- "Lowercase letters, digits, hyphens. 3–64 chars. No leading/trailing hyphen.",
- ),
- name: z.string().trim().min(2, "At least 2 characters.").max(128),
- adminEmail: z.string().trim().email("Enter a valid email."),
- adminPassword: z
- .string()
- .min(8, "At least 8 characters.")
- .max(128, "Maximum 128 characters."),
- issuer: z.string().trim().min(2, "Required.").max(256),
- connectionString: z.string().trim().max(2048).optional(),
- // Optional: preselected to the default plan when plans load; if left empty the
- // server falls back to the configured trial plan.
- planKey: z.string().trim().optional(),
-});
-
-type FormValues = z.infer;
-
-function formatMoney(amount: number, currency: string): string {
- try {
- return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount);
- } catch {
- return `${amount.toFixed(2)} ${currency}`;
- }
-}
+type TFn = (key: string) => string;
+
+const makeSchema = (t: TFn) =>
+ z.object({
+ id: z
+ .string()
+ .trim()
+ .regex(TENANT_ID_RE, t("create.validation.id")),
+ name: z.string().trim().min(2, t("create.validation.min2")).max(128),
+ adminEmail: z.string().trim().email(t("create.validation.email")),
+ adminPassword: z
+ .string()
+ .min(8, t("create.validation.min8"))
+ .max(128, t("create.validation.max128")),
+ issuer: z.string().trim().min(2, t("create.validation.required")).max(256),
+ connectionString: z.string().trim().max(2048).optional(),
+ // Optional: preselected to the default plan when plans load; if left empty the
+ // server falls back to the configured trial plan.
+ planKey: z.string().trim().optional(),
+ });
+
+type FormValues = z.infer>;
/** Derive a URL-safe slug from free text. Trailing/leading hyphens trimmed; capped to 64. */
function slugify(input: string): string {
@@ -129,7 +123,8 @@ function PreviewRail({
planLabel: string | null;
email: string;
}) {
- const displayName = name.trim() || "New tenant";
+ const { t } = useTranslation("tenants");
+ const displayName = name.trim() || t("create.preview.newTenant");
const displaySlug = slug || "tenant-id";
return (
@@ -173,7 +168,7 @@ function PreviewRail({
className="size-1.5 rounded-full bg-[var(--color-primary)] ring-2 ring-[oklch(from_var(--color-primary)_l_c_h_/_0.18)]"
/>
- {planLabel ?? "Default plan"}
+ {planLabel ?? t("create.preview.defaultPlan")}
@@ -182,14 +177,14 @@ function PreviewRail({
className="size-1.5 rounded-full bg-[var(--color-success)] ring-2 ring-[oklch(from_var(--color-success)_l_c_h_/_0.18)]"
/>
- Active on creation
+ {t("create.preview.activeOnCreation")}
- Provisioning runs in the background. You can track progress on the tenant's detail page.
+ {t("create.preview.provisioningNote")}
);
@@ -204,8 +199,10 @@ export function CreateTenantDialog({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
+ const { t } = useTranslation("tenants");
const navigate = useNavigate();
const queryClient = useQueryClient();
+ const schema = useMemo(() => makeSchema(t), [t]);
// UI-only state, reset on close.
const [idMode, setIdMode] = useState<"auto" | "manual">("auto");
@@ -222,7 +219,7 @@ export function CreateTenantDialog({
const planOptions: SelectOption[] = (plansQuery.data ?? []).map((p) => ({
value: p.key,
label: p.name,
- hint: `${p.interval} · ${formatMoney(planTermPrice(p), p.currency)}`,
+ hint: `${p.interval} · ${formatCurrency(planTermPrice(p), p.currency)}`,
}));
// Prefer the conventional trial plan, else the first active plan.
const defaultPlanKey =
@@ -261,7 +258,7 @@ export function CreateTenantDialog({
const selectedPlan = plansQuery.data?.find((p) => p.key === planKey);
const planLabel = selectedPlan
- ? `${selectedPlan.name} · ${formatMoney(planTermPrice(selectedPlan), selectedPlan.currency)}`
+ ? `${selectedPlan.name} · ${formatCurrency(planTermPrice(selectedPlan), selectedPlan.currency)}`
: null;
// Preselect the default/trial plan once plans load (without clobbering a choice).
@@ -297,9 +294,8 @@ export function CreateTenantDialog({
planKey: values.planKey?.trim() ? values.planKey : null,
}),
onSuccess: (result) => {
- toast.success(`Tenant ${result.id} created`, {
- description:
- "Provisioning runs in the background. Track progress on the detail page.",
+ toast.success(t("create.toast.created", { id: result.id }), {
+ description: t("create.toast.createdDescription"),
});
// Fire-and-forget refresh — don't block navigation on the list refetch.
void queryClient.invalidateQueries({ queryKey: ["tenants"] });
@@ -311,7 +307,7 @@ export function CreateTenantDialog({
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: (err as Error).message;
- toast.error("Create failed", { description: detail });
+ toast.error(t("create.toast.createFailed"), { description: detail });
},
});
@@ -367,22 +363,21 @@ export function CreateTenantDialog({
{/* Header (leave room for the close affordance, top-right) */}
- New tenant
+ {t("create.title")}
- Provision a tenant and its seed admin. The identifier is the URL-safe slug used in
- routing and JWT claims.
+ {t("create.description")}
{/* Fields */}
@@ -446,7 +441,7 @@ export function CreateTenantDialog({
@@ -463,9 +458,9 @@ export function CreateTenantDialog({
{/* Password — generate + show/hide */}
@@ -473,16 +468,16 @@ export function CreateTenantDialog({
id="ct-adminPassword"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
- placeholder="Min 8 characters"
+ placeholder={t("create.field.passwordPlaceholder")}
className="pr-16 font-mono"
{...register("adminPassword")}
/>
-
+
setShowPassword((s) => !s)}
>
{showPassword ? : }
@@ -494,11 +489,11 @@ export function CreateTenantDialog({
{/* Plan */}
@@ -513,9 +508,9 @@ export function CreateTenantDialog({
options={planOptions}
emptyLabel={
plansQuery.isLoading
- ? "Loading plans…"
+ ? t("create.plan.loading")
: planOptions.length === 0
- ? "No active plans"
+ ? t("create.plan.none")
: undefined
}
disabled={plansQuery.isLoading || planOptions.length === 0}
@@ -535,9 +530,9 @@ export function CreateTenantDialog({
focus-visible:ring-2 focus-visible:ring-[oklch(from_var(--color-ring)_l_c_h_/_0.5)]"
>
- Advanced
+ {t("create.advanced.title")}
- issuer, dedicated database
+ {t("create.advanced.subtitle")}
- Cancel
+ {t("create.cancel")}
{submitting ? (
<>
- Provisioning…
+ {t("create.provisioning")}
>
) : (
- "Create tenant"
+ t("create.submit")
)}
diff --git a/clients/admin/src/components/tenants/renew-tenant-dialog.tsx b/clients/admin/src/components/tenants/renew-tenant-dialog.tsx
index 8546ab51a7..c0eeecd680 100644
--- a/clients/admin/src/components/tenants/renew-tenant-dialog.tsx
+++ b/clients/admin/src/components/tenants/renew-tenant-dialog.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarClock } from "lucide-react";
import { toast } from "sonner";
@@ -16,20 +17,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ApiRequestError } from "@/lib/api-client";
-
-function formatMoney(amount: number, currency: string): string {
- try {
- return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount);
- } catch {
- return `${amount.toFixed(2)} ${currency}`;
- }
-}
-
-function formatDate(value?: string | null): string {
- if (!value) return "—";
- const d = new Date(value);
- return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString();
-}
+import { formatCurrency, formatDate } from "@/lib/format";
/**
* Renew or change a tenant's plan. Renewing the same plan extends validity by one term; choosing a
@@ -48,6 +36,7 @@ export function RenewTenantDialog({
currentPlanKey?: string | null;
validUpto?: string;
}) {
+ const { t } = useTranslation("tenants");
const queryClient = useQueryClient();
const [planKey, setPlanKey] = useState("");
@@ -65,16 +54,16 @@ export function RenewTenantDialog({
const options: SelectOption[] = (plansQuery.data ?? []).map((p) => ({
value: p.key,
- label: p.key === currentPlanKey ? `${p.name} (current)` : p.name,
- hint: `${p.interval} · ${formatMoney(planTermPrice(p), p.currency)}`,
+ label: p.key === currentPlanKey ? t("renew.planCurrent", { name: p.name }) : p.name,
+ hint: `${p.interval} · ${formatCurrency(planTermPrice(p), p.currency)}`,
}));
const mutation = useMutation({
mutationFn: (key: string) => renewTenant(tenantId, key || null),
onSuccess: (result) => {
toast.success(
- result.planChanged ? `Plan changed to ${result.planKey}` : "Tenant renewed",
- { description: `Valid until ${formatDate(result.validUpto)}. A term invoice was issued.` },
+ result.planChanged ? t("renew.toast.planChanged", { plan: result.planKey }) : t("renew.toast.renewed"),
+ { description: t("renew.toast.description", { date: formatDate(result.validUpto) }) },
);
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["tenants"] });
@@ -85,7 +74,7 @@ export function RenewTenantDialog({
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: (err as Error).message;
- toast.error("Renew failed", { description: detail });
+ toast.error(t("renew.toast.failed"), { description: detail });
},
});
@@ -104,23 +93,22 @@ export function RenewTenantDialog({
>
- Renew subscription
+ {t("renew.title")}
- Extend the tenant by one plan term (stacking on remaining time) or switch plans. Currently
- valid until {formatDate(validUpto)}.
+ {t("renew.description", { date: formatDate(validUpto) })}
@@ -136,10 +124,10 @@ export function RenewTenantDialog({
onOpenChange(false)} disabled={mutation.isPending}>
- Cancel
+ {t("renew.cancel")}
mutation.mutate(planKey)} disabled={mutation.isPending || !planKey}>
- {mutation.isPending ? "Renewing…" : planChanged ? "Change plan & renew" : "Renew"}
+ {mutation.isPending ? t("renew.renewing") : planChanged ? t("renew.changeAndRenew") : t("renew.submit")}
diff --git a/clients/admin/src/components/tenants/tenant-branding-card.tsx b/clients/admin/src/components/tenants/tenant-branding-card.tsx
index 6d25b4aaa1..a276b92447 100644
--- a/clients/admin/src/components/tenants/tenant-branding-card.tsx
+++ b/clients/admin/src/components/tenants/tenant-branding-card.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, Palette, RotateCcw, Save } from "lucide-react";
@@ -33,6 +34,7 @@ import { cn } from "@/lib/cn";
* are rarely tweaked in practice. Wire them up here if the need lands.
*/
export function TenantBrandingCard({ tenantId }: { tenantId: string }) {
+ const { t } = useTranslation("tenants");
const queryClient = useQueryClient();
const themeQueryKey = useMemo(
@@ -62,39 +64,39 @@ export function TenantBrandingCard({ tenantId }: { tenantId: string }) {
const saveMutation = useMutation({
mutationFn: (theme: TenantThemeDto) => updateTenantTheme(tenantId, theme),
onSuccess: () => {
- toast.success("Branding saved");
+ toast.success(t("branding.toast.saved"));
void queryClient.invalidateQueries({ queryKey: themeQueryKey });
},
onError: (err) =>
- toast.error("Save failed", { description: apiErr(err) }),
+ toast.error(t("branding.toast.saveFailed"), { description: apiErr(err, t("branding.unknownError")) }),
});
const resetMutation = useMutation({
mutationFn: () => resetTenantTheme(tenantId),
onSuccess: () => {
- toast.success("Branding reset to defaults");
+ toast.success(t("branding.toast.reset"));
void queryClient.invalidateQueries({ queryKey: themeQueryKey });
},
onError: (err) =>
- toast.error("Reset failed", { description: apiErr(err) }),
+ toast.error(t("branding.toast.resetFailed"), { description: apiErr(err, t("branding.unknownError")) }),
});
if (themeQuery.isLoading) {
return (
-
+
);
}
if (themeQuery.isError) {
return (
-
-
+
+
);
}
@@ -116,12 +118,12 @@ export function TenantBrandingCard({ tenantId }: { tenantId: string }) {
{draft.isDefault && !dirty && (
- default
+ {t("branding.badge.default")}
)}
{dirty && (
- unsaved
+ {t("branding.badge.unsaved")}
)}
@@ -131,10 +133,10 @@ export function TenantBrandingCard({ tenantId }: { tenantId: string }) {
variant="ghost"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending || saveMutation.isPending}
- aria-label="Reset branding to defaults"
+ aria-label={t("branding.resetAria")}
>
- {resetMutation.isPending ? "Resetting…" : "Reset to defaults"}
+ {resetMutation.isPending ? t("branding.resetting") : t("branding.reset")}
)}
- {saveMutation.isPending ? "Saving…" : "Save branding"}
+ {saveMutation.isPending ? t("branding.saving") : t("branding.save")}
@@ -155,23 +157,23 @@ export function TenantBrandingCard({ tenantId }: { tenantId: string }) {
return (
-
+
= [
- { key: "primary", label: "Primary" },
- { key: "secondary", label: "Secondary" },
- { key: "tertiary", label: "Tertiary" },
- { key: "background", label: "Background" },
- { key: "surface", label: "Surface" },
- { key: "error", label: "Error" },
- { key: "warning", label: "Warning" },
- { key: "success", label: "Success" },
- { key: "info", label: "Info" },
+ { key: "primary", label: "branding.paletteField.primary" },
+ { key: "secondary", label: "branding.paletteField.secondary" },
+ { key: "tertiary", label: "branding.paletteField.tertiary" },
+ { key: "background", label: "branding.paletteField.background" },
+ { key: "surface", label: "branding.paletteField.surface" },
+ { key: "error", label: "branding.paletteField.error" },
+ { key: "warning", label: "branding.paletteField.warning" },
+ { key: "success", label: "branding.paletteField.success" },
+ { key: "info", label: "branding.paletteField.info" },
];
function PaletteEditor({
@@ -211,6 +213,7 @@ function PaletteEditor({
onChange: (next: Partial) => void;
defaults: PaletteDto;
}) {
+ const { t } = useTranslation("tenants");
return (
@@ -223,14 +226,14 @@ function PaletteEditor({
onClick={() => onChange(defaults)}
>
- Reset palette
+ {t("branding.palette.resetPalette")}
{PALETTE_FIELDS.map(({ key, label }) => (
onChange({ [key]: v } as Partial)}
/>
@@ -249,6 +252,7 @@ function ColorRow({
value: string;
onChange: (next: string) => void;
}) {
+ const { t } = useTranslation("tenants");
const valid = /^#[0-9a-f]{6}$/i.test(value);
return (
@@ -256,14 +260,14 @@ function ColorRow({
onChange(e.target.value.toUpperCase())}
className="sr-only"
- aria-label={`${label} color`}
+ aria-label={t("branding.color.aria", { name: label })}
/>
@@ -297,21 +301,21 @@ function BrandAssetsEditor({
assets: BrandAssetsDto;
onChange: (next: Partial
) => void;
}) {
+ const { t } = useTranslation("tenants");
return (
- Brand assets
+ {t("branding.assets.title")}
- URLs to your hosted brand assets. Upload via the Files module first,
- then paste the resulting public URL here.
+ {t("branding.assets.description")}
onChange({ logoUrl: v || null, deleteLogo: v.length === 0 })
@@ -319,7 +323,7 @@ function BrandAssetsEditor({
/>
onChange({ logoDarkUrl: v || null, deleteLogoDark: v.length === 0 })
@@ -327,7 +331,7 @@ function BrandAssetsEditor({
/>
onChange({ faviconUrl: v || null, deleteFavicon: v.length === 0 })
@@ -384,6 +388,7 @@ function AssetField({
// ─────────────────────────────────────────────────────────────────────────
function ThemePreview({ palette, label }: { palette: PaletteDto; label: string }) {
+ const { t } = useTranslation("tenants");
return (
- live
+ {t("branding.preview.live")}
@@ -422,21 +427,20 @@ function ThemePreview({ palette, label }: { palette: PaletteDto; label: string }
className="text-[13px] font-semibold"
style={{ color: palette.secondary }}
>
- Sample tenant page
+ {t("branding.preview.samplePage")}
- active
+ {t("branding.preview.active")}
- A short paragraph rendered with the chosen body color over the chosen
- surface, on the chosen page background. Action buttons use the primary token.
+ {t("branding.preview.paragraph")}
{/* Primary action */}
@@ -444,7 +448,7 @@ function ThemePreview({ palette, label }: { palette: PaletteDto; label: string }
className="inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium shadow-sm"
style={{ backgroundColor: palette.primary, color: palette.surface }}
>
- Primary action
+ {t("branding.preview.primaryAction")}
{/* Outline secondary */}
- Secondary
+ {t("branding.preview.secondary")}
{/* Warning pill */}
- warn
+ {t("branding.preview.warn")}
{/* Error pill */}
- error
+ {t("branding.preview.error")}
@@ -478,10 +482,10 @@ function ThemePreview({ palette, label }: { palette: PaletteDto; label: string }
);
}
-function apiErr(err: unknown): string {
+function apiErr(err: unknown, fallback: string): string {
if (err instanceof ApiRequestError) {
return err.problem?.detail ?? err.problem?.title ?? err.message;
}
if (err instanceof Error) return err.message;
- return "Unknown error";
+ return fallback;
}
diff --git a/clients/admin/src/components/ui/confirm-dialog.tsx b/clients/admin/src/components/ui/confirm-dialog.tsx
index 0b7bfad0c6..a4ef4e1535 100644
--- a/clients/admin/src/components/ui/confirm-dialog.tsx
+++ b/clients/admin/src/components/ui/confirm-dialog.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -22,8 +23,8 @@ export function ConfirmDialog({
onOpenChange,
title,
description,
- confirmLabel = "Confirm",
- cancelLabel = "Cancel",
+ confirmLabel,
+ cancelLabel,
onConfirm,
destructive = false,
pending = false,
@@ -38,6 +39,7 @@ export function ConfirmDialog({
destructive?: boolean;
pending?: boolean;
}) {
+ const { t } = useTranslation("common");
return (
(pending ? undefined : onOpenChange(o))}>
@@ -64,7 +66,7 @@ export function ConfirmDialog({
onOpenChange(false)} disabled={pending}>
- {cancelLabel}
+ {cancelLabel ?? t("actions.cancel")}
- {pending ? "Working…" : confirmLabel}
+ {pending ? t("confirm.working") : (confirmLabel ?? t("confirm.confirm"))}
diff --git a/clients/admin/src/components/ui/dialog.tsx b/clients/admin/src/components/ui/dialog.tsx
index 158e6f5513..dbd24dea3e 100644
--- a/clients/admin/src/components/ui/dialog.tsx
+++ b/clients/admin/src/components/ui/dialog.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
/**
@@ -54,6 +55,7 @@ export const DialogContent = React.forwardRef<
size?: "sm" | "md" | "lg" | "xl";
}
>(({ className, children, size = "md", ...props }, ref) => {
+ const { t } = useTranslation("common");
const sizeClass: Record, string> = {
sm: "sm:max-w-sm",
md: "sm:max-w-lg",
@@ -82,7 +84,7 @@ export const DialogContent = React.forwardRef<
{children}
(({ className, children, side = "right", showClose = true, ...props }, ref) => (
-
-
-
- {children}
- {showClose && (
-
-
-
- )}
-
-
-));
+>(({ className, children, side = "right", showClose = true, ...props }, ref) => {
+ const { t } = useTranslation("common");
+ return (
+
+
+
+ {children}
+ {showClose && (
+
+
+
+ )}
+
+
+ );
+});
SheetContent.displayName = "SheetContent";
// Aliases for call-site clarity when using sheet semantics.
diff --git a/clients/admin/src/components/users/create-user-dialog.tsx b/clients/admin/src/components/users/create-user-dialog.tsx
index 16c38a62e2..d6fe583219 100644
--- a/clients/admin/src/components/users/create-user-dialog.tsx
+++ b/clients/admin/src/components/users/create-user-dialog.tsx
@@ -1,3 +1,5 @@
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
@@ -24,25 +26,28 @@ import { describeError } from "@/lib/api-client";
const USERNAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{2,31}$/;
-const schema = z
- .object({
- firstName: z.string().trim().min(1, "Required.").max(64),
- lastName: z.string().trim().min(1, "Required.").max(64),
- userName: z
- .string()
- .trim()
- .regex(USERNAME_RE, "3–32 chars. Letters, digits, dot, dash, underscore. Start with a letter."),
- email: z.string().trim().email("Enter a valid email."),
- phoneNumber: z.string().trim().max(32).optional(),
- password: z.string().min(8, "At least 8 characters."),
- confirmPassword: z.string().min(8),
- })
- .refine((d) => d.password === d.confirmPassword, {
- path: ["confirmPassword"],
- message: "Passwords don't match.",
- });
+type TFn = (key: string) => string;
+
+const makeSchema = (t: TFn) =>
+ z
+ .object({
+ firstName: z.string().trim().min(1, t("create.validation.required")).max(64),
+ lastName: z.string().trim().min(1, t("create.validation.required")).max(64),
+ userName: z
+ .string()
+ .trim()
+ .regex(USERNAME_RE, t("create.validation.username")),
+ email: z.string().trim().email(t("create.validation.email")),
+ phoneNumber: z.string().trim().max(32).optional(),
+ password: z.string().min(8, t("create.validation.min8")),
+ confirmPassword: z.string().min(8),
+ })
+ .refine((d) => d.password === d.confirmPassword, {
+ path: ["confirmPassword"],
+ message: t("create.validation.mismatch"),
+ });
-type FormValues = z.infer;
+type FormValues = z.infer>;
// ─── Section label ────────────────────────────────────────────────────────────
@@ -82,8 +87,10 @@ export function CreateUserDialog({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
+ const { t } = useTranslation("users");
const navigate = useNavigate();
const queryClient = useQueryClient();
+ const schema = useMemo(() => makeSchema(t), [t]);
const {
register,
@@ -116,15 +123,15 @@ export function CreateUserDialog({
phoneNumber: values.phoneNumber?.trim() || undefined,
}),
onSuccess: (result) => {
- toast.success("User created", {
- description: result.message ?? "Confirmation email queued.",
+ toast.success(t("create.toast.created"), {
+ description: result.message ?? t("create.toast.createdFallback"),
});
queryClient.invalidateQueries({ queryKey: ["users"] });
handleClose();
navigate(result.userId ? `/users/${result.userId}` : "/users");
},
onError: (err) => {
- toast.error("Create failed", { description: describeError(err) });
+ toast.error(t("create.toast.createFailed"), { description: describeError(err) });
},
});
@@ -158,12 +165,11 @@ export function CreateUserDialog({
- New account
+ {t("create.title")}
- The new user is created in the current tenant and emailed a confirmation link. Roles can
- be assigned from the detail page after creation.
+ {t("create.description")}
@@ -174,15 +180,15 @@ export function CreateUserDialog({
@@ -195,7 +201,7 @@ export function CreateUserDialog({
@@ -210,9 +216,9 @@ export function CreateUserDialog({
@@ -244,7 +250,7 @@ export function CreateUserDialog({
@@ -285,7 +291,7 @@ export function CreateUserDialog({
@@ -310,10 +316,10 @@ export function CreateUserDialog({
onClick={handleClose}
disabled={submitting}
>
- Cancel
+ {t("create.cancel")}
- {submitting ? "Creating…" : "Create account"}
+ {submitting ? t("create.creating") : t("create.submit")}
diff --git a/clients/admin/src/components/webhooks/create-webhook-dialog.tsx b/clients/admin/src/components/webhooks/create-webhook-dialog.tsx
index 3d620d0df0..1cdc10e71d 100644
--- a/clients/admin/src/components/webhooks/create-webhook-dialog.tsx
+++ b/clients/admin/src/components/webhooks/create-webhook-dialog.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -24,13 +25,14 @@ import { Field } from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
+// Validation messages are i18n keys, resolved to text at render via t().
const schema = z.object({
url: z
.string()
.trim()
- .url("Must be a valid http(s) URL.")
+ .url("create.validation.url")
.refine((u) => u.startsWith("https://") || u.startsWith("http://"), {
- message: "Use http:// or https://",
+ message: "create.validation.scheme",
}),
secret: z
.string()
@@ -50,6 +52,7 @@ export function CreateWebhookDialog({
onOpenChange: (open: boolean) => void;
onCreated?: (id: string) => void;
}) {
+ const { t } = useTranslation("webhooks");
const [events, setEvents] = useState([]);
const [draftEvent, setDraftEvent] = useState("");
@@ -77,8 +80,8 @@ export function CreateWebhookDialog({
events,
}),
onSuccess: (id) => {
- toast.success("Subscription created", {
- description: "Use the Test button on the row to verify the endpoint accepts events.",
+ toast.success(t("create.toast.created"), {
+ description: t("create.toast.createdDesc"),
});
onCreated?.(id);
reset_();
@@ -89,14 +92,14 @@ export function CreateWebhookDialog({
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: (err as Error).message;
- toast.error("Create failed", { description: detail });
+ toast.error(t("create.toast.createFailed"), { description: detail });
},
});
const onSubmit = handleSubmit((values) => {
if (events.length === 0) {
- toast.warning("Pick at least one event", {
- description: "Subscriptions with no events would never fire.",
+ toast.warning(t("create.toast.pickEvent"), {
+ description: t("create.toast.pickEventDesc"),
});
return;
}
@@ -135,18 +138,16 @@ export function CreateWebhookDialog({
>
- New webhook subscription
+ {t("create.title")}
- Your endpoint receives a JSON payload with the event details. We sign each request
- with HMAC-SHA256 in the X-FSH-Signature header
- using the secret below — store it on your side and verify before trusting the body.
+ {t("create.description", { header: "X-FSH-Signature" })}
- Email{" "}
- confirmed
+ {t("confirm.successTitleLead")}{" "}
+ {t("confirm.successTitleAccent")}
{status.message}
@@ -146,7 +147,7 @@ export function ConfirmEmailPage() {
- Continue to sign in
+ {t("confirm.continueSignIn")}
@@ -165,27 +166,26 @@ export function ConfirmEmailPage() {
- Couldn't{" "}
- confirm {" "}
- your email
+ {t("confirm.errorTitleLead")}{" "}
+ {t("confirm.errorTitleAccent")} {" "}
+ {t("confirm.errorTitleTrail")}
{status.message}
- The link may have expired or been used already. If you've signed in since
- this email was sent, you can ignore it.
+ {t("confirm.errorHint")}
- Back to sign in
+ {t("confirm.backToSignIn")}
- Reset password instead
+ {t("confirm.resetInstead")}
@@ -199,7 +199,7 @@ export function ConfirmEmailPage() {
to="/login"
className="text-[12.5px] text-[var(--color-muted-foreground)] underline-offset-4 hover:text-[var(--color-foreground)] hover:underline"
>
- ← Back to sign in
+ {t("confirm.backLink")}
diff --git a/clients/admin/src/pages/auth/forgot-password.tsx b/clients/admin/src/pages/auth/forgot-password.tsx
index 908e5b97a8..d676071222 100644
--- a/clients/admin/src/pages/auth/forgot-password.tsx
+++ b/clients/admin/src/pages/auth/forgot-password.tsx
@@ -1,4 +1,5 @@
import { useState, type FormEvent } from "react";
+import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import {
@@ -28,6 +29,7 @@ import { env } from "@/env";
* account existence. Always render the same "check your inbox" success.
*/
export function ForgotPasswordPage() {
+ const { t } = useTranslation("auth");
const { isAuthenticated } = useAuth();
const [email, setEmail] = useState("");
const [tenant, setTenant] = useState(env.defaultTenant);
@@ -88,7 +90,7 @@ export function ForgotPasswordPage() {
- .NET 10 Starter Kit
+ {t("tagline.starterKit")}
@@ -108,24 +110,25 @@ export function ForgotPasswordPage() {
- Check your{" "}
- inbox
+ {t("forgot.successTitleLead")}{" "}
+ {t("forgot.successTitleAccent")}
- If an account exists for{" "}
- {email} in tenant{" "}
- {tenant} , a one-time
- reset link is on its way. The link expires in 30 minutes.
+ {t("forgot.successPre")}{" "}
+ {email} {" "}
+ {t("forgot.successMid")}{" "}
+ {tenant}
+ {t("forgot.successPost")}
- Didn't get it? Wait a minute, then check spam.
+ {t("forgot.tip1")}
- Still nothing? Confirm the email + tenant and retry.
+ {t("forgot.tip2")}
@@ -137,11 +140,11 @@ export function ForgotPasswordPage() {
setError(null);
}}
>
- Try a different address
+ {t("forgot.tryDifferent")}
- Back to sign in
+ {t("forgot.backToSignIn")}
@@ -150,11 +153,11 @@ export function ForgotPasswordPage() {
<>
- Reset your{" "}
- password
+ {t("forgot.titleLead")}{" "}
+ {t("forgot.titleAccent")}
- Enter the email + tenant you sign in with. We'll dispatch a one-time link.
+ {t("forgot.subtitle")}
@@ -169,7 +172,7 @@ export function ForgotPasswordPage() {
htmlFor="reset-tenant"
className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]"
>
- Tenant
+ {t("forgot.tenant")}
@@ -179,7 +182,7 @@ export function ForgotPasswordPage() {
onChange={(e) => setTenant(e.target.value)}
required
autoComplete="organization"
- placeholder="root"
+ placeholder={t("forgot.tenantPlaceholder")}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "forgot-error" : undefined}
className="h-11 pl-10 text-[14px]"
@@ -191,7 +194,7 @@ export function ForgotPasswordPage() {
htmlFor="reset-email"
className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]"
>
- Email
+ {t("forgot.email")}
@@ -203,7 +206,7 @@ export function ForgotPasswordPage() {
required
autoComplete="email"
autoFocus
- placeholder="operator@root.example"
+ placeholder={t("forgot.emailPlaceholder")}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "forgot-error" : undefined}
className="h-11 pl-10 text-[14px]"
@@ -236,11 +239,11 @@ export function ForgotPasswordPage() {
{mutation.isPending ? (
<>
-
Dispatching link…
+
{t("forgot.submitting")}
>
) : (
<>
-
Send reset link
+
{t("forgot.submit")}
>
)}
@@ -253,18 +256,18 @@ export function ForgotPasswordPage() {
- Remembered it?{" "}
+ {t("forgot.remembered")}{" "}
- Sign in
+ {t("forgot.signIn")}
- Encrypted in transit · JWT-secured session
+ {t("encrypted")}
diff --git a/clients/admin/src/pages/auth/reset-password.tsx b/clients/admin/src/pages/auth/reset-password.tsx
index 0a8093d87a..6ffaa15d20 100644
--- a/clients/admin/src/pages/auth/reset-password.tsx
+++ b/clients/admin/src/pages/auth/reset-password.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
+import { useTranslation } from "react-i18next";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import {
@@ -35,13 +36,14 @@ function scorePassword(value: string): Strength | null {
return "strong";
}
-const STRENGTH_META: Record = {
- weak: { label: "Weak", fill: "bg-[var(--color-destructive)]", bar: "w-1/3" },
- fair: { label: "Fair", fill: "bg-[var(--color-warning)]", bar: "w-2/3" },
- strong: { label: "Strong", fill: "bg-[var(--color-success)]", bar: "w-full" },
+const STRENGTH_META: Record = {
+ weak: { fill: "bg-[var(--color-destructive)]", bar: "w-1/3" },
+ fair: { fill: "bg-[var(--color-warning)]", bar: "w-2/3" },
+ strong: { fill: "bg-[var(--color-success)]", bar: "w-full" },
};
export function ResetPasswordPage() {
+ const { t } = useTranslation("auth");
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
@@ -63,8 +65,8 @@ export function ResetPasswordPage() {
const mutation = useMutation({
mutationFn: () => resetPassword({ email, password, token, tenant }),
onSuccess: () => {
- toast.success("Password updated", {
- description: "Sign in with your new password to continue.",
+ toast.success(t("reset.toastTitle"), {
+ description: t("reset.toastDesc"),
});
navigate("/login", { replace: true });
},
@@ -86,11 +88,11 @@ export function ResetPasswordPage() {
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (!matches) {
- setError("Passwords don't match.");
+ setError(t("reset.errMismatch"));
return;
}
if (password.length < 8) {
- setError("Use at least 8 characters.");
+ setError(t("reset.errTooShort"));
return;
}
mutation.mutate();
@@ -130,7 +132,7 @@ export function ResetPasswordPage() {
- .NET 10 Starter Kit
+ {t("tagline.starterKit")}
@@ -142,27 +144,27 @@ export function ResetPasswordPage() {
- This link is{" "}
- incomplete
+ {t("reset.incompleteTitleLead")}{" "}
+ {t("reset.incompleteTitleAccent")}
- The link is missing one of{" "}
- token ,{" "}
- email , or{" "}
- tenant . Some email
- clients clip long URLs — try copy-pasting the full link from the original
- email into your browser's address bar, or request a new one.
+ {t("reset.incompletePre")}{" "}
+ {t("reset.fieldToken")} ,{" "}
+ {t("reset.fieldEmail")} ,{" "}
+ {t("reset.or")}{" "}
+ {t("reset.fieldTenant")} .{" "}
+ {t("reset.incompletePost")}
- Request a new link
+ {t("reset.requestNewLink")}
- Back to sign in
+ {t("reset.backToSignIn")}
@@ -171,13 +173,15 @@ export function ResetPasswordPage() {
<>
- Set a new{" "}
- password
+ {t("reset.titleLead")}{" "}
+ {t("reset.titleAccent")}
- Resetting password for{" "}
- {email} on{" "}
- {tenant} .
+ {t("reset.subtitlePre")}{" "}
+ {email} {" "}
+ {t("reset.subtitleMid")}{" "}
+ {tenant}
+ {t("reset.subtitlePost")}
@@ -192,7 +196,7 @@ export function ResetPasswordPage() {
htmlFor="new-password"
className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]"
>
- New password
+ {t("reset.newPassword")}
setShowPassword((v) => !v)}
- aria-label={showPassword ? "Hide password" : "Show password"}
+ aria-label={showPassword ? t("reset.hidePassword") : t("reset.showPassword")}
className="absolute right-3.5 top-1/2 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
>
{showPassword ? : }
@@ -230,7 +234,7 @@ export function ResetPasswordPage() {
/>
- {STRENGTH_META[strength].label}
+ {t(`reset.strength_${strength}`)}
)}
@@ -241,7 +245,7 @@ export function ResetPasswordPage() {
htmlFor="confirm-password"
className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]"
>
- Confirm password
+ {t("reset.confirmPassword")}
setShowConfirm((v) => !v)}
- aria-label={showConfirm ? "Hide password" : "Show password"}
+ aria-label={showConfirm ? t("reset.hidePassword") : t("reset.showPassword")}
className="absolute right-3.5 top-1/2 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
>
{showConfirm ? : }
@@ -278,7 +282,7 @@ export function ResetPasswordPage() {
- {matches ? "Passwords match" : "Doesn't match yet"}
+ {matches ? t("reset.matches") : t("reset.notMatchYet")}
)}
@@ -308,12 +312,12 @@ export function ResetPasswordPage() {
{mutation.isPending ? (
<>
- Updating password…
+ {t("reset.submitting")}
>
) : (
<>
- Set new password
+ {t("reset.submit")}
>
)}
@@ -326,12 +330,12 @@ export function ResetPasswordPage() {
- Changed your mind?{" "}
+ {t("reset.changedMind")}{" "}
- Sign in
+ {t("reset.signIn")}
diff --git a/clients/admin/src/pages/billing/invoice-detail.tsx b/clients/admin/src/pages/billing/invoice-detail.tsx
index 9d0b18d932..d0ccc864eb 100644
--- a/clients/admin/src/pages/billing/invoice-detail.tsx
+++ b/clients/admin/src/pages/billing/invoice-detail.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Ban, CheckCircle2, Download, FileText, Send } from "lucide-react";
@@ -18,31 +19,13 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input";
import { EntityPageHeader, SettingsSection, Field } from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
+import { formatCurrency, formatDate, formatNumber } from "@/lib/format";
import { cn } from "@/lib/cn";
import { useAuth } from "@/auth/use-auth";
import { BillingPermissions } from "@/lib/permissions";
// ─── helpers ─────────────────────────────────────────────────────────
-function formatMoney(amount: number, currency: string) {
- try {
- return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount);
- } catch {
- return `${amount.toFixed(2)} ${currency}`;
- }
-}
-
-const dateLong = new Intl.DateTimeFormat(undefined, {
- month: "long",
- day: "numeric",
- year: "numeric",
-});
-function formatDate(iso?: string | null) {
- if (!iso) return "—";
- const d = new Date(iso);
- return Number.isNaN(d.getTime()) ? iso : dateLong.format(d);
-}
-
function formatPeriod(year: number, month: number) {
return `${year}-${String(month).padStart(2, "0")}`;
}
@@ -71,8 +54,11 @@ function describe(err: unknown, fallback: string): string {
// ─── component ───────────────────────────────────────────────────────
export function InvoiceDetailPage() {
+ const { t } = useTranslation("billing");
const { invoiceId = "" } = useParams<{ invoiceId: string }>();
const navigate = useNavigate();
+ const statusLabel = (status: InvoiceStatus): string =>
+ t(`status.${status.charAt(0).toLowerCase()}${status.slice(1)}`);
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
// Issue / mark-paid / void and PDF download all require Billing.Manage on the server.
@@ -99,36 +85,36 @@ export function InvoiceDetailPage() {
// could be stale if the query refetched between render and click.
const downloadMutation = useMutation({
mutationFn: ({ id, number }: { id: string; number: string }) => downloadInvoicePdf(id, number),
- onError: (err) => toast.error("Download failed", { description: describe(err, "Could not download the invoice PDF.") }),
+ onError: (err) => toast.error(t("invoiceDetail.toast.downloadFailed"), { description: describe(err, t("invoiceDetail.toast.downloadFailedDesc")) }),
});
const issueMutation = useMutation({
mutationFn: () => issueInvoice(invoiceId, dueAt ? new Date(dueAt).toISOString() : null),
onSuccess: () => {
- toast.success("Invoice issued", { description: "Status moved to Issued." });
+ toast.success(t("invoiceDetail.toast.issued"), { description: t("invoiceDetail.toast.issuedDesc") });
setDueAt("");
invalidate();
},
- onError: (err) => toast.error("Issue failed", { description: describe(err, "Could not issue invoice.") }),
+ onError: (err) => toast.error(t("invoiceDetail.toast.issueFailed"), { description: describe(err, t("invoiceDetail.toast.issueFailedDesc")) }),
});
const payMutation = useMutation({
mutationFn: () => markInvoicePaid(invoiceId),
onSuccess: () => {
- toast.success("Marked paid");
+ toast.success(t("invoiceDetail.toast.markedPaid"));
invalidate();
},
- onError: (err) => toast.error("Mark-paid failed", { description: describe(err, "Could not mark paid.") }),
+ onError: (err) => toast.error(t("invoiceDetail.toast.markPaidFailed"), { description: describe(err, t("invoiceDetail.toast.markPaidFailedDesc")) }),
});
const voidMutation = useMutation({
mutationFn: () => voidInvoice(invoiceId, voidReason.trim() ? voidReason.trim() : null),
onSuccess: () => {
- toast.success("Invoice voided");
+ toast.success(t("invoiceDetail.toast.voided"));
setVoidReason("");
invalidate();
},
- onError: (err) => toast.error("Void failed", { description: describe(err, "Could not void invoice.") }),
+ onError: (err) => toast.error(t("invoiceDetail.toast.voidFailed"), { description: describe(err, t("invoiceDetail.toast.voidFailedDesc")) }),
});
// ── render ─────────────────────────────────────────────────────────
@@ -137,7 +123,7 @@ export function InvoiceDetailPage() {
navigate("/billing/invoices")} className="-ml-2 mb-4">
- All invoices
+ {t("invoiceDetail.back")}
{query.isLoading ? (
@@ -147,38 +133,40 @@ export function InvoiceDetailPage() {
) : query.isError ? (
- {describe(query.error, "Failed to load invoice.")}
+ {describe(query.error, t("invoiceDetail.loadError"))}
) : invoice ? (
{invoice.invoiceNumber}
- {invoice.status}
+ {statusLabel(invoice.status)}
{invoice.purpose && (
- {invoice.purpose === "Subscription" ? "Subscription" : "Usage"}
+ {invoice.purpose === "Subscription"
+ ? t("purpose.subscription")
+ : t("purpose.usage")}
)}
- tenant {invoice.tenantId} · period {formatPeriod(invoice.periodYear, invoice.periodMonth)} · created {formatDate(invoice.createdAtUtc)}
+ {t("label.tenant")} {invoice.tenantId} · {t("label.period")} {formatPeriod(invoice.periodYear, invoice.periodMonth)} · {t("label.created")} {formatDate(invoice.createdAtUtc)}
{invoice.periodStartUtc && invoice.periodEndUtc && (
- ` · term ${formatDate(invoice.periodStartUtc)} – ${formatDate(invoice.periodEndUtc)}`
+ ` · ${t("label.term")} ${formatDate(invoice.periodStartUtc)} – ${formatDate(invoice.periodEndUtc)}`
)}
- {invoice.issuedAtUtc && ` · issued ${formatDate(invoice.issuedAtUtc)}`}
+ {invoice.issuedAtUtc && ` · ${t("label.issued")} ${formatDate(invoice.issuedAtUtc)}`}
{invoice.dueAtUtc && invoice.status === "Issued" && (
- · due {formatDate(invoice.dueAtUtc)}
+ · {t("label.due")} {formatDate(invoice.dueAtUtc)}
)}
{invoice.paidAtUtc && (
- · paid {formatDate(invoice.paidAtUtc)}
+ · {t("label.paid")} {formatDate(invoice.paidAtUtc)}
)}
{invoice.voidedAtUtc && (
- · voided {formatDate(invoice.voidedAtUtc)}
+ · {t("label.voided")} {formatDate(invoice.voidedAtUtc)}
)}
@@ -192,10 +180,10 @@ export function InvoiceDetailPage() {
downloadMutation.mutate({ id: invoice.id, number: invoice.invoiceNumber })
}
disabled={downloadMutation.isPending}
- title="Download this invoice as a PDF"
+ title={t("invoiceDetail.downloadTitle")}
>
- {downloadMutation.isPending ? "Preparing…" : "Download PDF"}
+ {downloadMutation.isPending ? t("invoiceDetail.preparing") : t("invoiceDetail.download")}
)}
@@ -205,18 +193,18 @@ export function InvoiceDetailPage() {
{/* Line items */}
{query.isError ? (
- {describe(query.error, "Failed to load line items.")}
+ {describe(query.error, t("invoiceDetail.lineItems.loadError"))}
) : query.isLoading ? (
@@ -229,7 +217,7 @@ export function InvoiceDetailPage() {
) : invoice && invoice.lineItems.length === 0 ? (
- No line items.
+ {t("invoiceDetail.lineItems.empty")}
) : invoice ? (
@@ -238,10 +226,10 @@ export function InvoiceDetailPage() {
))}
- subtotal
+ {t("label.subtotal")}
- {formatMoney(invoice.subtotalAmount, invoice.currency)}
+ {formatCurrency(invoice.subtotalAmount, invoice.currency)}
@@ -259,11 +247,11 @@ export function InvoiceDetailPage() {
{/* Issue */}
-
+
issueMutation.mutate()}
className="w-full"
>
- {issueMutation.isPending ? "Issuing…" : "Issue invoice"}
+ {issueMutation.isPending ? t("invoiceDetail.issue.submitting") : t("invoiceDetail.issue.submit")}
@@ -286,8 +274,8 @@ export function InvoiceDetailPage() {
{/* Mark paid */}
payMutation.mutate()}
className="w-full"
>
- {payMutation.isPending ? "Saving…" : "Mark as paid"}
+ {payMutation.isPending ? t("invoiceDetail.markPaid.submitting") : t("invoiceDetail.markPaid.submit")}
@@ -304,8 +292,8 @@ export function InvoiceDetailPage() {
{/* Void */}
-
+
setVoidReason(e.target.value)}
disabled={
@@ -337,7 +325,7 @@ export function InvoiceDetailPage() {
onClick={() => voidMutation.mutate()}
className="w-full"
>
- {voidMutation.isPending ? "Voiding…" : "Void invoice"}
+ {voidMutation.isPending ? t("invoiceDetail.void.submitting") : t("invoiceDetail.void.submit")}
@@ -345,7 +333,7 @@ export function InvoiceDetailPage() {
)}
{invoice.notes && (
-
+
{invoice.notes}
@@ -370,6 +358,15 @@ function LineItemRow({
currency: string;
delayIndex: number;
}) {
+ const { t } = useTranslation("billing");
+ const kindLabel =
+ item.kind === "BaseFee"
+ ? t("kind.baseFee")
+ : item.kind === "Overage"
+ ? t("kind.overage")
+ : item.kind === "Metered"
+ ? t("kind.metered")
+ : item.kind;
return (
{item.description}
- {item.kind}
+ {kindLabel}
{item.resource && (
@@ -388,11 +385,11 @@ function LineItemRow({
)}
- {item.quantity.toLocaleString()} × {formatMoney(item.unitPrice, currency)}
+ {formatNumber(item.quantity)} × {formatCurrency(item.unitPrice, currency)}
- {formatMoney(item.amount, currency)}
+ {formatCurrency(item.amount, currency)}
);
diff --git a/clients/admin/src/pages/billing/invoices-list.tsx b/clients/admin/src/pages/billing/invoices-list.tsx
index 3fae58095f..d3ca434336 100644
--- a/clients/admin/src/pages/billing/invoices-list.tsx
+++ b/clients/admin/src/pages/billing/invoices-list.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import {
@@ -24,6 +25,7 @@ import { Label } from "@/components/ui/label";
import { Select } from "@/components/list";
import { KpiTile } from "@/components/kpi-tile";
import { ApiRequestError } from "@/lib/api-client";
+import { formatCurrency, formatDate } from "@/lib/format";
import { cn } from "@/lib/cn";
const PAGE_SIZE = 20;
@@ -32,26 +34,6 @@ const STATUSES: InvoiceStatus[] = ["Draft", "Issued", "Paid", "Void"];
// ─── helpers ─────────────────────────────────────────────────────────
-function formatMoney(amount: number, currency: string) {
- try {
- return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount);
- } catch {
- return `${amount.toFixed(2)} ${currency}`;
- }
-}
-
-const dateShort = new Intl.DateTimeFormat(undefined, {
- month: "short",
- day: "2-digit",
- year: "numeric",
-});
-
-function formatDate(iso?: string | null) {
- if (!iso) return "—";
- const d = new Date(iso);
- return Number.isNaN(d.getTime()) ? iso : dateShort.format(d);
-}
-
function formatPeriod(year: number, month: number) {
return `${year}-${String(month).padStart(2, "0")}`;
}
@@ -71,16 +53,19 @@ function statusVariant(status: InvoiceStatus): React.ComponentProps {
+ if (err instanceof ApiRequestError)
+ return err.problem?.detail ?? err.problem?.title ?? err.message;
+ if (err instanceof Error) return err.message;
+ return t("invoices.loadError");
+ };
+ const statusLabel = (status: InvoiceStatus): string =>
+ t(`status.${status.charAt(0).toLowerCase()}${status.slice(1)}`);
const [pageNumber, setPageNumber] = useState(1);
const [tenantFilter, setTenantFilter] = useState("");
@@ -143,42 +128,46 @@ export function InvoicesListPage() {
{/* KPI strip — page-scope (current page, not all-time) */}
: data?.items.length ?? 0}
- subtitle={data ? `${data.totalCount.toLocaleString()} total` : "loading…"}
+ subtitle={
+ data
+ ? t("invoices.kpi.totalCount", { count: data.totalCount })
+ : t("invoices.kpi.loading")
+ }
/>
) : (
- formatMoney(totals.totalBilled, totals.currency)
+ formatCurrency(totals.totalBilled, totals.currency)
)
}
- subtitle="this page"
+ subtitle={t("invoices.kpi.thisPage")}
/>
) : (
- formatMoney(totals.outstanding, totals.currency)
+ formatCurrency(totals.outstanding, totals.currency)
)
}
- subtitle="issued, awaiting payment"
+ subtitle={t("invoices.kpi.outstandingHint")}
/>
) : (
- formatMoney(totals.paid, totals.currency)
+ formatCurrency(totals.paid, totals.currency)
)
}
- subtitle={`${totals.paidCount} invoice${totals.paidCount === 1 ? "" : "s"}`}
+ subtitle={t("invoices.kpi.paidCount", { count: totals.paidCount })}
/>
@@ -188,24 +177,22 @@ export function InvoicesListPage() {
- Filters
+ {t("invoices.filters.title")}
-
- All filters are AND-combined. Period is matched exactly (year + month).
-
+ {t("invoices.filters.description")}
{filtersDirty && (
- Clear
+ {t("invoices.filters.clear")}
)}
- Tenant
+ {t("invoices.filters.tenant")}
{
setTenantFilter(e.target.value);
@@ -215,7 +202,7 @@ export function InvoicesListPage() {
/>
- Status
+ {t("invoices.filters.status")}
({ value: s, label: s }))}
- emptyLabel="All"
+ options={STATUSES.map((s) => ({ value: s, label: statusLabel(s) }))}
+ emptyLabel={t("status.all")}
/>
- Year
+ {t("invoices.filters.year")}
-
Month
+
{t("invoices.filters.month")}
- Invoices
+ {t("invoices.list.title")}
- {data ? (
- <>
- Page {data.pageNumber} of{" "}
- {Math.max(data.totalPages, 1)} ·{" "}
- {data.totalCount.toLocaleString()} total
- >
- ) : (
- "Loading…"
- )}
+ {data
+ ? t("invoices.list.summary", {
+ page: data.pageNumber,
+ total: Math.max(data.totalPages, 1),
+ count: data.totalCount,
+ })
+ : t("invoices.list.loading")}
@@ -295,7 +280,7 @@ export function InvoicesListPage() {
) : items.length === 0 ? (
- No invoices match the current filters.
+ {t("invoices.list.empty")}
) : (
@@ -322,22 +307,25 @@ export function InvoicesListPage() {
{inv.invoiceNumber}
- {inv.status}
+ {statusLabel(inv.status)}
{inv.purpose && (
- {inv.purpose === "Subscription" ? "Subscription" : "Usage"}
+ {inv.purpose === "Subscription"
+ ? t("purpose.subscription")
+ : t("purpose.usage")}
)}
- tenant
{inv.tenantId} ·
- period {formatPeriod(inv.periodYear, inv.periodMonth)} ·
- created {formatDate(inv.createdAtUtc)}
+ {t("label.tenant")}{" "}
+
{inv.tenantId} ·{" "}
+ {t("label.period")} {formatPeriod(inv.periodYear, inv.periodMonth)} ·{" "}
+ {t("label.created")} {formatDate(inv.createdAtUtc)}
{inv.paidAtUtc && (
<>
{" · "}
- paid {formatDate(inv.paidAtUtc)}
+ {t("label.paid")} {formatDate(inv.paidAtUtc)}
>
)}
@@ -345,7 +333,7 @@ export function InvoicesListPage() {
<>
{" · "}
- voided {formatDate(inv.voidedAtUtc)}
+ {t("label.voided")} {formatDate(inv.voidedAtUtc)}
>
)}
@@ -356,11 +344,11 @@ export function InvoicesListPage() {
{/* Amount column */}
- {formatMoney(inv.subtotalAmount, inv.currency)}
+ {formatCurrency(inv.subtotalAmount, inv.currency)}
{inv.dueAtUtc && inv.status === "Issued" && (
- due {formatDate(inv.dueAtUtc)}
+ {t("label.due")} {formatDate(inv.dueAtUtc)}
)}
@@ -375,7 +363,12 @@ export function InvoicesListPage() {
{/* Pagination */}
- {data ? `Page ${data.pageNumber} / ${Math.max(data.totalPages, 1)}` : ""}
+ {data
+ ? t("pagination.pageOf", {
+ page: data.pageNumber,
+ total: Math.max(data.totalPages, 1),
+ })
+ : ""}
setPageNumber((p) => Math.max(1, p - 1))}
>
- Previous
+ {t("pagination.previous")}
setPageNumber((p) => p + 1)}
>
- Next
+ {t("pagination.next")}
diff --git a/clients/admin/src/pages/billing/layout.tsx b/clients/admin/src/pages/billing/layout.tsx
index 45ac563ebc..4af342de44 100644
--- a/clients/admin/src/pages/billing/layout.tsx
+++ b/clients/admin/src/pages/billing/layout.tsx
@@ -1,14 +1,15 @@
import { NavLink, Outlet } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { CreditCard } from "lucide-react";
import { cn } from "@/lib/cn";
import { EntityPageHeader } from "@/components/list";
-type Tab = { to: string; label: string };
+type Tab = { to: string; labelKey: string };
const TABS: Tab[] = [
- { to: "/billing/plans", label: "Plans" },
- { to: "/billing/invoices", label: "Invoices" },
- { to: "/billing/topups", label: "Top-ups" },
+ { to: "/billing/plans", labelKey: "layout.tab.plans" },
+ { to: "/billing/invoices", labelKey: "layout.tab.invoices" },
+ { to: "/billing/topups", labelKey: "layout.tab.topups" },
];
/**
@@ -16,18 +17,19 @@ const TABS: Tab[] = [
* inside `
`.
*/
export function BillingLayout() {
+ const { t } = useTranslation("billing");
return (
{TABS.map((tab) => (
- {tab.label}
+ {t(tab.labelKey)}
))}
diff --git a/clients/admin/src/pages/billing/plans-list.tsx b/clients/admin/src/pages/billing/plans-list.tsx
index 5a9b1cee13..8a992f3a12 100644
--- a/clients/admin/src/pages/billing/plans-list.tsx
+++ b/clients/admin/src/pages/billing/plans-list.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Pencil, Plus, Tag } from "lucide-react";
import { getPlans, planTermPrice, type BillingPlanDto } from "@/api/billing";
@@ -8,36 +9,30 @@ import { Skeleton } from "@/components/ui/skeleton";
import { StatStrip, Stat, SettingsSection } from "@/components/list";
import { PlanFormDialog } from "@/components/billing/plan-form-dialog";
import { ApiRequestError } from "@/lib/api-client";
+import { formatCurrency } from "@/lib/format";
import { useAuth } from "@/auth/use-auth";
import { BillingPermissions } from "@/lib/permissions";
// ─── helpers ──────────────────────────────────────────────────────────
-function formatMoney(amount: number, currency: string) {
- try {
- return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount);
- } catch {
- return `${amount.toFixed(2)} ${currency}`;
- }
-}
-
function formatOverageRates(rates: BillingPlanDto["overageRates"], currency: string) {
const entries = Object.entries(rates).filter(([, v]) => v && v > 0);
if (entries.length === 0) return "—";
return entries
- .map(([resource, rate]) => `${resource} ${formatMoney(rate ?? 0, currency)}`)
+ .map(([resource, rate]) => `${resource} ${formatCurrency(rate ?? 0, currency)}`)
.join(" · ");
}
-function describe(err: unknown): string {
- if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message;
- if (err instanceof Error) return err.message;
- return "Failed to load plans.";
-}
-
// ─── component ────────────────────────────────────────────────────────
export function PlansListPage() {
+ const { t } = useTranslation("billing");
+ const describe = (err: unknown): string => {
+ if (err instanceof ApiRequestError)
+ return err.problem?.detail ?? err.problem?.title ?? err.message;
+ if (err instanceof Error) return err.message;
+ return t("plans.loadError");
+ };
const [dialogOpen, setDialogOpen] = useState(false);
const [editingPlan, setEditingPlan] = useState
(undefined);
const { user: currentUser } = useAuth();
@@ -78,37 +73,41 @@ export function PlansListPage() {
{/* KPI strip */}
: totals.count}
- hint={`${totals.active} active`}
+ hint={t("plans.stat.activeCount", { count: totals.active })}
/>
: totals.active}
- hint={totals.count - totals.active > 0 ? `${totals.count - totals.active} inactive` : "all active"}
+ hint={
+ totals.count - totals.active > 0
+ ? t("plans.stat.inactiveCount", { count: totals.count - totals.active })
+ : t("plans.stat.allActive")
+ }
/>
) : (
- formatMoney(totals.averagePrice, totals.currency)
+ formatCurrency(totals.averagePrice, totals.currency)
)
}
- hint="monthly subscription fee"
+ hint={t("plans.stat.averageHint")}
/>
{/* Plans list */}
- New plan
+ {t("plans.newPlan")}
) : undefined
}
@@ -130,7 +129,7 @@ export function PlansListPage() {
) : plans.length === 0 ? (
- No plans yet. Create your first plan to start charging tenants.
+ {t("plans.empty")}
) : (
@@ -147,16 +146,20 @@ export function PlansListPage() {
{plan.key}
{plan.name}
- {plan.interval === "Yearly" ? "Yearly" : "Monthly"}
+
+ {plan.interval === "Yearly" ? t("interval.yearly") : t("interval.monthly")}
+
{plan.isActive ? (
- Active
+ {t("status.active")}
) : (
- Inactive
+ {t("status.inactive")}
)}
- currency {plan.currency} ·{" "}
- overage {formatOverageRates(plan.overageRates, plan.currency)}
+ {t("plans.meta", {
+ currency: plan.currency,
+ overage: formatOverageRates(plan.overageRates, plan.currency),
+ })}
@@ -164,17 +167,17 @@ export function PlansListPage() {
- {formatMoney(planTermPrice(plan), plan.currency)}
+ {formatCurrency(planTermPrice(plan), plan.currency)}
- {plan.interval === "Yearly" ? "per year" : "per month"}
+ {plan.interval === "Yearly" ? t("perYear") : t("perMonth")}
{canManageBilling && (
openEdit(plan)}
>
diff --git a/clients/admin/src/pages/billing/topups-list.tsx b/clients/admin/src/pages/billing/topups-list.tsx
index d5cf7149a5..4a51265d7b 100644
--- a/clients/admin/src/pages/billing/topups-list.tsx
+++ b/clients/admin/src/pages/billing/topups-list.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
useMutation,
@@ -39,6 +40,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Select } from "@/components/list";
import { KpiTile } from "@/components/kpi-tile";
import { ApiRequestError } from "@/lib/api-client";
+import { formatCurrency, formatDate } from "@/lib/format";
import { useAuth } from "@/auth/use-auth";
import { BillingPermissions } from "@/lib/permissions";
@@ -48,26 +50,6 @@ const STATUSES: TopupRequestStatus[] = ["Pending", "Approved", "Rejected", "Comp
// ─── helpers ─────────────────────────────────────────────────────────
-function formatMoney(amount: number, currency: string) {
- try {
- return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount);
- } catch {
- return `${amount.toFixed(2)} ${currency}`;
- }
-}
-
-const dateShort = new Intl.DateTimeFormat(undefined, {
- month: "short",
- day: "2-digit",
- year: "numeric",
-});
-
-function formatDate(iso?: string | null) {
- if (!iso) return "—";
- const d = new Date(iso);
- return Number.isNaN(d.getTime()) ? iso : dateShort.format(d);
-}
-
function statusVariant(status: TopupRequestStatus): React.ComponentProps["variant"] {
switch (status) {
case "Completed":
@@ -94,6 +76,9 @@ type ActionTarget = { request: TopupRequestDto; mode: "approve" | "reject" };
// ─── component ───────────────────────────────────────────────────────
export function TopupsListPage() {
+ const { t } = useTranslation("billing");
+ const statusLabel = (status: TopupRequestStatus): string =>
+ t(`status.${status.charAt(0).toLowerCase()}${status.slice(1)}`);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
@@ -159,11 +144,11 @@ export function TopupsListPage() {
mutationFn: (vars: { id: string; note?: string }) =>
approveTopupRequest(vars.id, vars.note),
onSuccess: (invoiceId) => {
- toast.success("Invoice generated", {
- description: "A draft invoice was created for this top-up.",
+ toast.success(t("topups.toast.invoiceGenerated"), {
+ description: t("topups.toast.invoiceGeneratedDesc"),
action: invoiceId
? {
- label: "View invoice",
+ label: t("topups.toast.viewInvoice"),
onClick: () => navigate(`/billing/invoices/${invoiceId}`),
}
: undefined,
@@ -172,19 +157,19 @@ export function TopupsListPage() {
closeAction();
},
onError: (err) =>
- toast.error("Approve failed", { description: describe(err, "Could not approve the request.") }),
+ toast.error(t("topups.toast.approveFailed"), { description: describe(err, t("topups.toast.approveFailedDesc")) }),
});
const rejectMutation = useMutation({
mutationFn: (vars: { id: string; reason?: string }) =>
rejectTopupRequest(vars.id, vars.reason),
onSuccess: () => {
- toast.success("Request rejected");
+ toast.success(t("topups.toast.rejected"));
invalidate();
closeAction();
},
onError: (err) =>
- toast.error("Reject failed", { description: describe(err, "Could not reject the request.") }),
+ toast.error(t("topups.toast.rejectFailed"), { description: describe(err, t("topups.toast.rejectFailedDesc")) }),
});
const actionPending = approveMutation.isPending || rejectMutation.isPending;
@@ -203,25 +188,29 @@ export function TopupsListPage() {
{/* KPI strip — page-scope (current page, not all-time) */}
: data?.items.length ?? 0}
- subtitle={data ? `${data.totalCount.toLocaleString()} total` : "loading…"}
+ subtitle={
+ data
+ ? t("topups.kpi.totalCount", { count: data.totalCount })
+ : t("topups.kpi.loading")
+ }
/>
: totals.pendingCount}
- subtitle="awaiting decision (this page)"
+ subtitle={t("topups.kpi.pendingHint")}
/>
) : (
- formatMoney(totals.requested, totals.currency)
+ formatCurrency(totals.requested, totals.currency)
)
}
- subtitle="this page"
+ subtitle={t("topups.kpi.thisPage")}
/>
@@ -231,24 +220,22 @@ export function TopupsListPage() {
- Filters
+ {t("topups.filters.title")}
-
- Review wallet top-up requests across every tenant. Approve to generate an invoice.
-
+ {t("topups.filters.description")}
{filtersDirty && (
- Clear
+ {t("invoices.filters.clear")}
)}
- Tenant
+ {t("topups.filters.tenant")}
{
setTenantFilter(e.target.value);
@@ -258,7 +245,7 @@ export function TopupsListPage() {
/>
- Status
+ {t("topups.filters.status")}
({ value: s, label: s }))}
- emptyLabel="All"
+ options={STATUSES.map((s) => ({ value: s, label: statusLabel(s) }))}
+ emptyLabel={t("status.all")}
/>
@@ -276,23 +263,21 @@ export function TopupsListPage() {
{/* List */}
- Top-up requests
+ {t("topups.list.title")}
- {data ? (
- <>
- Page {data.pageNumber} of{" "}
- {Math.max(data.totalPages, 1)} ·{" "}
- {data.totalCount.toLocaleString()} total
- >
- ) : (
- "Loading…"
- )}
+ {data
+ ? t("invoices.list.summary", {
+ page: data.pageNumber,
+ total: Math.max(data.totalPages, 1),
+ count: data.totalCount,
+ })
+ : t("invoices.list.loading")}
{query.isError && (
- {describe(query.error, "Failed to load top-up requests.")}
+ {describe(query.error, t("topups.loadError"))}
)}
@@ -312,7 +297,7 @@ export function TopupsListPage() {
) : items.length === 0 ? (
- No top-up requests match the current filters.
+ {t("topups.list.empty")}
) : (
@@ -333,23 +318,24 @@ export function TopupsListPage() {
- {formatMoney(req.amount, req.currency)}
+ {formatCurrency(req.amount, req.currency)}
- {req.status}
+ {statusLabel(req.status)}
{req.invoiceId && (
navigate(`/billing/invoices/${req.invoiceId}`)}
className="inline-flex items-center gap-1 rounded font-mono text-[11px] text-[var(--color-primary)] underline-offset-2 hover:underline"
>
- invoice
+ {t("topups.invoiceLink")}
)}
- tenant {req.tenantId} ·
- created {formatDate(req.createdAtUtc)}
- {req.decidedAtUtc && ` · decided ${formatDate(req.decidedAtUtc)}`}
+ {t("label.tenant")}{" "}
+ {req.tenantId} ·{" "}
+ {t("label.created")} {formatDate(req.createdAtUtc)}
+ {req.decidedAtUtc && ` · ${t("label.decided")} ${formatDate(req.decidedAtUtc)}`}
{req.note && ` · “${req.note}”`}
@@ -366,7 +352,7 @@ export function TopupsListPage() {
setAction({ request: req, mode: "approve" });
}}
>
- Approve
+ {t("topups.approve")}
- Reject
+ {t("topups.reject")}
)}
@@ -391,7 +377,12 @@ export function TopupsListPage() {
{/* Pagination */}
- {data ? `Page ${data.pageNumber} / ${Math.max(data.totalPages, 1)}` : ""}
+ {data
+ ? t("pagination.pageOf", {
+ page: data.pageNumber,
+ total: Math.max(data.totalPages, 1),
+ })
+ : ""}
setPageNumber((p) => Math.max(1, p - 1))}
>
- Previous
+ {t("pagination.previous")}
setPageNumber((p) => p + 1)}
>
- Next
+ {t("pagination.next")}
@@ -419,44 +410,33 @@ export function TopupsListPage() {
onOpenChange={(open) => {
if (!open) closeAction();
}}
- title={action?.mode === "reject" ? "Reject top-up request" : "Approve top-up request"}
+ title={action?.mode === "reject" ? t("topups.confirm.rejectTitle") : t("topups.confirm.approveTitle")}
destructive={action?.mode === "reject"}
- confirmLabel={action?.mode === "reject" ? "Reject" : "Approve & generate invoice"}
+ confirmLabel={action?.mode === "reject" ? t("topups.confirm.rejectConfirm") : t("topups.confirm.approveConfirm")}
pending={actionPending}
onConfirm={confirmAction}
description={
action ? (
- {action.mode === "reject" ? (
- <>
- Reject the{" "}
-
- {formatMoney(action.request.amount, action.request.currency)}
- {" "}
- top-up for tenant{" "}
- {action.request.tenantId} ? This cannot be undone.
- >
- ) : (
- <>
- Approve the{" "}
-
- {formatMoney(action.request.amount, action.request.currency)}
- {" "}
- top-up for tenant{" "}
- {action.request.tenantId} ? An invoice will be
- generated for the operator to mark paid.
- >
- )}
+ {action.mode === "reject"
+ ? t("topups.confirm.rejectBody", {
+ amount: formatCurrency(action.request.amount, action.request.currency),
+ tenant: action.request.tenantId,
+ })
+ : t("topups.confirm.approveBody", {
+ amount: formatCurrency(action.request.amount, action.request.currency),
+ tenant: action.request.tenantId,
+ })}
- {action.mode === "reject" ? "Reason" : "Note"}{" "}
- (optional)
+ {action.mode === "reject" ? t("topups.confirm.reason") : t("topups.confirm.note")}{" "}
+ {t("topups.confirm.optional")}
setDecisionNote(e.target.value)}
disabled={actionPending}
diff --git a/clients/admin/src/pages/dashboard.tsx b/clients/admin/src/pages/dashboard.tsx
index a1449d08a4..a1a3107c47 100644
--- a/clients/admin/src/pages/dashboard.tsx
+++ b/clients/admin/src/pages/dashboard.tsx
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
@@ -13,6 +14,7 @@ import { listInvoices, getPlans } from "@/api/billing";
import { Skeleton } from "@/components/ui/skeleton";
import { EntityPageHeader, Stat, StatStrip, ToneIconTile, type ToneIconTileTone } from "@/components/list";
import { useAuth } from "@/auth/use-auth";
+import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/cn";
/**
@@ -21,6 +23,7 @@ import { cn } from "@/lib/cn";
* the rest of the app. No fake "Coming soon" filler.
*/
export function DashboardPage() {
+ const { t } = useTranslation("dashboard");
const { user } = useAuth();
const tenantsQuery = useQuery({
@@ -53,65 +56,65 @@ export function DashboardPage() {
icon={LayoutDashboard}
title={
<>
- Overview{firstName ? (
- , {firstName}
+ {t("title")}{firstName ? (
+ {t("greeting", { name: firstName })}
) : null}
>
}
tone="primary"
- description="Operate every tenant on this instance — identity, multitenancy, billing, and the rest of the system surface."
+ description={t("description")}
/>
{/* ── KPI stat strip ───────────────────────────────────────────── */}
) : (
- tenantsTotal?.toLocaleString() ?? "—"
+ tenantsTotal != null ? formatNumber(tenantsTotal) : "—"
)
}
- hint="registered on this instance"
+ hint={t("stat.tenantsHint")}
/>
) : (
- plans.length.toLocaleString()
+ formatNumber(plans.length)
)
}
- hint={`${activePlans} active`}
+ hint={t("stat.plansActive", { count: activePlans })}
/>
) : (
- invoicesPage?.items.length.toLocaleString() ?? "—"
+ invoicesPage != null ? formatNumber(invoicesPage.items.length) : "—"
)
}
hint={
invoicesPage
- ? `${invoicesPage.totalCount.toLocaleString()} total ledger`
- : "loading…"
+ ? t("stat.invoicesHint", { count: invoicesPage.totalCount })
+ : t("stat.invoicesLoading")
}
/>
) : (
- outstandingCount.toLocaleString()
+ formatNumber(outstandingCount)
)
}
- hint="issued, awaiting payment"
+ hint={t("stat.outstandingHint")}
tone={outstandingCount > 0 ? "warning" : "default"}
/>
@@ -119,36 +122,36 @@ export function DashboardPage() {
{/* ── Quick pivots ─────────────────────────────────────────────── */}
- Entry points
+ {t("entryPoints")}
diff --git a/clients/admin/src/pages/health/page.tsx b/clients/admin/src/pages/health/page.tsx
index 026360e131..9e0ae84b2c 100644
--- a/clients/admin/src/pages/health/page.tsx
+++ b/clients/admin/src/pages/health/page.tsx
@@ -1,4 +1,6 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
import { useQuery } from "@tanstack/react-query";
import { Activity, ChevronRight, Heart, RefreshCw } from "lucide-react";
import { getLiveness, getReadiness, type HealthEntry, type HealthResult, type HealthStatus } from "@/api/health";
@@ -9,7 +11,14 @@ import { cn } from "@/lib/cn";
const REFRESH_INTERVAL_MS = 10_000;
+// Map a HealthStatus enum onto its localized label, falling back to the raw
+// value for any status not in the catalog.
+function statusLabel(t: TFunction, status: HealthStatus): string {
+ return t(`status.${String(status).toLowerCase()}`, { defaultValue: String(status) });
+}
+
export function HealthPage() {
+ const { t } = useTranslation("health");
const live = useQuery({
queryKey: ["health", "live"],
queryFn: getLiveness,
@@ -47,48 +56,49 @@ export function HealthPage() {
- Live process and dependency probes. Auto-refreshes every {REFRESH_INTERVAL_MS / 1000} seconds.
- The probe endpoints themselves (/health/live{" "}
- /health/ready) are unauthenticated so they can be
- scraped by load balancers and uptime monitors.
+ {t("description", {
+ seconds: REFRESH_INTERVAL_MS / 1000,
+ live: "/health/live",
+ ready: "/health/ready",
+ })}
>
}
>
- Refresh
+ {t("refresh")}
}
- hint="API process responding"
+ hint={t("stat.livenessHint")}
tone={statusToTone(liveStatus)}
/>
}
- hint="Dependencies reachable"
+ hint={t("stat.readinessHint")}
tone={statusToTone(readyStatus)}
/>
0 ? "success" : "default"}
/>
0
- ? `${checksDegraded} degraded · ${checksFailing} unhealthy`
- : `${checksFailing} unhealthy`
+ ? t("stat.checksFailingHintDegraded", { degraded: checksDegraded, failing: checksFailing })
+ : t("stat.checksFailingHint", { failing: checksFailing })
}
tone={checksFailing > 0 ? "danger" : checksDegraded > 0 ? "warning" : "default"}
/>
@@ -97,30 +107,30 @@ export function HealthPage() {
{live.isError && (
)}
{ready.isError && (
)}
);
@@ -141,6 +151,7 @@ function ProbeSection({
loading: boolean;
description: string;
}) {
+ const { t } = useTranslation("health");
return (
{loading ? (
- Probing…
+ {t("probing")}
) : !result || result.results.length === 0 ? (
-
No dependency checks reported.
+
{t("noChecks")}
- The probe responded with status {result?.status ?? "—"}.
+ {t("noChecksDetail", { status: result?.status ?? "—" })}
@@ -176,6 +187,7 @@ function ProbeSection({
}
function CheckRow({ entry }: { entry: HealthEntry }) {
+ const { t } = useTranslation("health");
const [open, setOpen] = useState(false);
const hasDetails = !!entry.details && Object.keys(entry.details).length > 0;
@@ -191,7 +203,7 @@ function CheckRow({ entry }: { entry: HealthEntry }) {
)}
- {entry.durationMs.toFixed(1)}ms
+ {t("durationMs", { ms: entry.durationMs.toFixed(1) })}
{hasDetails ? (
{status};
+ return {statusLabel(t, status)} ;
}
function StatusDot({ status }: { status: HealthStatus }) {
+ const { t } = useTranslation("health");
const tone = statusToColor(status);
return (
);
}
function StatusGlyph({ status }: { status: HealthStatus }) {
+ const { t } = useTranslation("health");
if (status === "Healthy") {
return (
- Healthy
+ {t("status.healthy")}
);
}
- return {status} ;
+ return {statusLabel(t, status)} ;
}
function statusToTone(s: HealthStatus): "default" | "success" | "warning" | "danger" {
diff --git a/clients/admin/src/pages/impersonation/list.tsx b/clients/admin/src/pages/impersonation/list.tsx
index 9120543bd6..e6307e1339 100644
--- a/clients/admin/src/pages/impersonation/list.tsx
+++ b/clients/admin/src/pages/impersonation/list.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Clock, RefreshCw, ShieldOff, UserCog } from "lucide-react";
import {
@@ -28,14 +29,10 @@ import { cn } from "@/lib/cn";
const REFRESH_INTERVAL_MS = 5_000;
-const STATUS_OPTIONS: { value: ImpersonationGrantStatus; label: string }[] = [
- { value: "Active", label: "Active" },
- { value: "Ended", label: "Ended" },
- { value: "Revoked", label: "Revoked" },
- { value: "Expired", label: "Expired" },
-];
+const STATUS_VALUES: ImpersonationGrantStatus[] = ["Active", "Ended", "Revoked", "Expired"];
export function ImpersonationListPage() {
+ const { t } = useTranslation("impersonation");
const { user } = useAuth();
const canRevoke = (user?.permissions ?? []).includes(IdentityPermissions.Impersonation.Revoke);
const canImpersonate = (user?.permissions ?? []).includes(IdentityPermissions.Users.Impersonate);
@@ -72,13 +69,18 @@ export function ImpersonationListPage() {
[allGrants, status],
);
+ const statusOptions = STATUS_VALUES.map((value) => ({
+ value,
+ label: t(`status.${value.toLowerCase()}`),
+ }));
+
return (
- Refresh
+ {t("refresh")}
- 0 ? "signal" : "default"} />
-
- 0 ? "danger" : "default"} />
-
+ 0 ? "signal" : "default"} />
+
+ 0 ? "danger" : "default"} />
+
setStatus(v as ImpersonationGrantStatus | "")}
- options={STATUS_OPTIONS}
- placeholder="All statuses"
+ options={statusOptions}
+ placeholder={t("allStatuses")}
minWidth="12rem"
/>
@@ -114,22 +116,22 @@ export function ImpersonationListPage() {
message={
grants.error instanceof ApiRequestError
? grants.error.problem?.detail ?? grants.error.message
- : "Failed to load impersonation grants."
+ : t("loadError")
}
/>
)}
- {grants.isLoading && }
+ {grants.isLoading && }
{!grants.isLoading && items.length === 0 && !grants.isError && (
)}
@@ -229,6 +231,7 @@ function GrantRow({
onRevoke: () => void;
onReopen: () => void;
}) {
+ const { t } = useTranslation("impersonation");
const [open, setOpen] = useState(false);
return (
@@ -254,15 +257,15 @@ function GrantRow({
{formatTimestamp(grant.startedAtUtc)}
- · expires {formatTimestamp(grant.expiresAtUtc)}
+ · {t("expires", { time: formatTimestamp(grant.expiresAtUtc) })}
setOpen((v) => !v)}
aria-expanded={open}
- aria-label={open ? "Hide grant details" : "Show grant details"}
+ aria-label={open ? t("hideDetails") : t("showDetails")}
className="ml-1 inline-flex items-center gap-0.5 underline-offset-2 hover:text-[var(--color-foreground)] hover:underline"
>
- {open ? "hide" : "details"}
+ {open ? t("hide") : t("details")}
@@ -274,14 +277,14 @@ function GrantRow({
variant="outline"
size="sm"
onClick={onReopen}
- title="Issue a fresh impersonation token for this user — use when you've lost the original browser tab."
+ title={t("reopenTitle")}
>
- Re-open
+ {t("reopen")}
)}
{canRevoke ? (
- Revoke
+ {t("revoke")}
) : (
!canReopen &&
@@ -294,23 +297,24 @@ function GrantRow({
}
function Details({ grant }: { grant: ImpersonationGrantDto }) {
+ const { t } = useTranslation("impersonation");
return (
-
+
{grant.reason || "—"}
- {grant.id}
- {grant.jti}
- {grant.actorUserId} @ {grant.actorTenantId}
- {grant.impersonatedUserId}
+ {grant.id}
+ {grant.jti}
+ {grant.actorUserId} @ {grant.actorTenantId}
+ {grant.impersonatedUserId}
{grant.endedAtUtc && (
- {new Date(grant.endedAtUtc).toLocaleString()}
+ {new Date(grant.endedAtUtc).toLocaleString()}
)}
{grant.revokedAtUtc && (
<>
- {new Date(grant.revokedAtUtc).toLocaleString()}
- {grant.revokedByUserName ?? grant.revokedByUserId ?? "—"}
-
+ {new Date(grant.revokedAtUtc).toLocaleString()}
+ {grant.revokedByUserName ?? grant.revokedByUserId ?? "—"}
+
{grant.revokeReason || "—"}
>
@@ -341,6 +345,7 @@ function StatusGlyph({ status }: { status: ImpersonationGrantStatus }) {
}
function StatusBadge({ status }: { status: ImpersonationGrantStatus }) {
+ const { t } = useTranslation("impersonation");
const variant =
status === "Active" ? "brand" :
status === "Revoked" ? "danger" :
@@ -348,7 +353,7 @@ function StatusBadge({ status }: { status: ImpersonationGrantStatus }) {
"muted";
return (
- {status}
+ {t(`status.${status.toLowerCase()}`)}
);
}
diff --git a/clients/admin/src/pages/login.tsx b/clients/admin/src/pages/login.tsx
index 6be23f80e9..e260782155 100644
--- a/clients/admin/src/pages/login.tsx
+++ b/clients/admin/src/pages/login.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from "react";
+import { useTranslation } from "react-i18next";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import {
AlertCircle,
@@ -34,6 +35,7 @@ import type { DemoAccount } from "@/pages/login.demo-accounts";
type LocationState = { from?: { pathname: string } };
export function LoginPage() {
+ const { t } = useTranslation("auth");
const { isAuthenticated, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
@@ -51,9 +53,9 @@ export function LoginPage() {
// Surface why the previous session ended (read-and-clear, one-shot).
useEffect(() => {
if (consumeSignedOutReason() === "inactivity") {
- setNotice("You were signed out due to inactivity.");
+ setNotice(t("login.inactivityNotice"));
}
- }, []);
+ }, [t]);
if (isAuthenticated) {
return ;
@@ -71,7 +73,7 @@ export function LoginPage() {
? err.problem?.detail ?? err.problem?.title ?? err.message
: err instanceof Error
? err.message
- : "Login failed";
+ : t("login.errorFallback");
setError(message);
} finally {
setSubmitting(false);
@@ -127,7 +129,7 @@ export function LoginPage() {
- Platform Admin
+ {t("tagline.platformAdmin")}
@@ -137,10 +139,10 @@ export function LoginPage() {
@@ -224,7 +226,7 @@ export function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
- placeholder="Enter your password"
+ placeholder={t("login.passwordPlaceholder")}
required
aria-invalid={error ? true : undefined}
className="h-11 pr-11 text-[14px]"
@@ -232,7 +234,7 @@ export function LoginPage() {
setShowPassword((v) => !v)}
- aria-label={showPassword ? "Hide password" : "Show password"}
+ aria-label={showPassword ? t("login.hidePassword") : t("login.showPassword")}
className="absolute right-3.5 top-1/2 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
>
{showPassword ? : }
@@ -265,11 +267,11 @@ export function LoginPage() {
{submitting ? (
<>
- Signing in…
+ {t("login.submitting")}
>
) : (
<>
- Sign in
+ {t("login.submit")}
>
)}
@@ -286,7 +288,7 @@ export function LoginPage() {
className="flex h-10 w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-[var(--color-primary)]/25 bg-transparent text-[12.5px] font-medium text-[var(--color-primary)]/70 transition-all duration-150 hover:border-[var(--color-primary)]/40 hover:bg-[var(--color-primary)]/[0.04] hover:text-[var(--color-primary)]"
>
- Sign in with a demo account
+ {t("login.demoButton")}
)}
@@ -295,10 +297,10 @@ export function LoginPage() {
- Encrypted in transit · JWT-secured session
+ {t("encrypted")}
- fullstackhero Administration
+ {t("login.footer")}
diff --git a/clients/admin/src/pages/not-found.tsx b/clients/admin/src/pages/not-found.tsx
index 239c9bdacc..d180e65177 100644
--- a/clients/admin/src/pages/not-found.tsx
+++ b/clients/admin/src/pages/not-found.tsx
@@ -1,4 +1,5 @@
import { Link, useLocation, useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { ArrowLeft, FileQuestion, Home } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -10,6 +11,7 @@ import { Button } from "@/components/ui/button";
* Mounted at the root via `path: "*"`.
*/
export function NotFoundPage() {
+ const { t } = useTranslation("common");
const navigate = useNavigate();
const location = useLocation();
const requested = location.pathname + location.search;
@@ -35,11 +37,10 @@ export function NotFoundPage() {
- Page not found
+ {t("notFound.title")}
- We couldn't find anything at that address. It may be a stale link, a
- renamed route, or a path that never existed.
+ {t("notFound.body")}
{/* Requested path — soft inline display */}
@@ -48,7 +49,7 @@ export function NotFoundPage() {
title={requested}
>
- Requested
+ {t("notFound.requested")}
{requested}
@@ -58,7 +59,7 @@ export function NotFoundPage() {
- Back home
+ {t("notFound.backHome")}
@@ -70,7 +71,7 @@ export function NotFoundPage() {
className="mt-5 inline-flex items-center gap-1.5 text-[12px] text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-foreground)]"
>
- Go back to the previous page
+ {t("notFound.goBack")}
diff --git a/clients/admin/src/pages/notifications/inbox.tsx b/clients/admin/src/pages/notifications/inbox.tsx
index c3f1d30e49..d5b4e7937f 100644
--- a/clients/admin/src/pages/notifications/inbox.tsx
+++ b/clients/admin/src/pages/notifications/inbox.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, CheckCheck, ExternalLink, RefreshCw } from "lucide-react";
import { toast } from "sonner";
@@ -20,19 +21,21 @@ import {
} from "@/components/list";
import { EmptyState } from "@/components/empty-state";
import { ApiRequestError } from "@/lib/api-client";
+import { formatDate } from "@/lib/format";
import { cn } from "@/lib/cn";
type Filter = "all" | "unread";
-const FILTER_OPTIONS = [
- { value: "unread", label: "Unread" },
- { value: "all", label: "All" },
-];
-
export function NotificationsInboxPage() {
+ const { t } = useTranslation("notifications");
const queryClient = useQueryClient();
const [filter, setFilter] = useState("unread");
+ const FILTER_OPTIONS = [
+ { value: "unread", label: t("filter.unread") },
+ { value: "all", label: t("filter.all") },
+ ];
+
const query = useQuery({
queryKey: ["notifications", "inbox", filter],
queryFn: () =>
@@ -48,16 +51,16 @@ export function NotificationsInboxPage() {
const markOne = useMutation({
mutationFn: (id: string) => markNotificationRead(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notifications"] }),
- onError: (err) => toast.error("Mark read failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("toast.markReadFailed"), { description: describe(err) }),
});
const markAll = useMutation({
mutationFn: markAllNotificationsRead,
onSuccess: (data) => {
- toast.success(`${data.updated} ${data.updated === 1 ? "notification" : "notifications"} marked read`);
+ toast.success(t("toast.marked", { count: data.updated }));
queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
- onError: (err) => toast.error("Mark all failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("toast.markAllFailed"), { description: describe(err) }),
});
const items = query.data ?? [];
@@ -66,10 +69,10 @@ export function NotificationsInboxPage() {
- Refresh
+ {t("inbox.refresh")}
- {markAll.isPending ? "Marking…" : "Mark all read"}
+ {markAll.isPending ? t("inbox.marking") : t("inbox.markAllRead")}
@@ -107,22 +110,22 @@ export function NotificationsInboxPage() {
message={
query.error instanceof ApiRequestError
? query.error.problem?.detail ?? query.error.message
- : "Failed to load notifications."
+ : t("inbox.loadError")
}
/>
)}
- {query.isLoading && }
+ {query.isLoading && }
{!query.isLoading && items.length === 0 && !query.isError && (
)}
@@ -145,6 +148,7 @@ function Row({
notif: NotificationDto;
onMarkRead: () => void;
}) {
+ const { t } = useTranslation("notifications");
const unread = !notif.readAtUtc;
return (
{notif.title}
{notif.type}
- {new Date(notif.createdAtUtc).toLocaleString()}
+ {formatDate(notif.createdAtUtc)}
{notif.body && (
@@ -184,13 +188,13 @@ function Row({
className="mt-1 inline-flex items-center gap-1 font-mono text-[10.5px] uppercase tracking-[0.14em] text-[var(--color-foreground)] hover:underline"
>
- Open
+ {t("row.open")}
)}
{unread && (
- Mark read
+ {t("row.markRead")}
)}
diff --git a/clients/admin/src/pages/roles/detail.tsx b/clients/admin/src/pages/roles/detail.tsx
index 9879289f10..b25b7ee910 100644
--- a/clients/admin/src/pages/roles/detail.tsx
+++ b/clients/admin/src/pages/roles/detail.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
@@ -32,13 +33,17 @@ import { cn } from "@/lib/cn";
const SYSTEM_ROLE_NAMES = new Set(["Admin", "Basic"]);
-const profileSchema = z.object({
- name: z.string().trim().min(2, "At least 2 characters.").max(64),
- description: z.string().trim().max(256).optional(),
-});
-type ProfileValues = z.infer;
+type TFn = (key: string, opts?: Record) => string;
+
+const makeProfileSchema = (t: TFn) =>
+ z.object({
+ name: z.string().trim().min(2, t("detail.validation.min2")).max(64),
+ description: z.string().trim().max(256).optional(),
+ });
+type ProfileValues = z.infer>;
export function RoleDetailPage() {
+ const { t } = useTranslation("roles");
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -56,16 +61,16 @@ export function RoleDetailPage() {
{isSystem && (
- System
+ {t("system")}
)}
navigate("/roles")}
className="h-9 gap-1.5 rounded-lg px-3 text-[13px]"
>
- Registry
+ {t("detail.registry")}
@@ -83,12 +88,12 @@ export function RoleDetailPage() {
message={
query.error instanceof ApiRequestError
? query.error.problem?.detail ?? query.error.message
- : "Failed to load role."
+ : t("detail.loadError")
}
/>
)}
- {query.isLoading &&
}
+ {query.isLoading &&
}
{isSystem && role && (
- 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")}
@@ -137,7 +140,9 @@ export function RoleDetailPage() {
// ─── Profile section ────────────────────────────────────────────────────
function ProfileSection({ role, disabled }: { role: RoleDto; disabled: boolean }) {
+ const { t } = useTranslation("roles");
const queryClient = useQueryClient();
+ const profileSchema = useMemo(() => makeProfileSchema(t), [t]);
const {
register,
handleSubmit,
@@ -164,7 +169,7 @@ function ProfileSection({ role, disabled }: { role: RoleDto; disabled: boolean }
description: values.description?.trim() ? values.description : null,
}),
onSuccess: (result) => {
- toast.success("Role updated");
+ toast.success(t("detail.profile.updated"));
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["roles", result.id] });
},
@@ -173,7 +178,7 @@ function ProfileSection({ role, disabled }: { role: RoleDto; disabled: boolean }
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: (err as Error).message;
- toast.error("Update failed", { description: detail });
+ toast.error(t("detail.profile.updateFailed"), { description: detail });
},
});
@@ -182,12 +187,12 @@ function ProfileSection({ role, disabled }: { role: RoleDto; disabled: boolean }
return (
}
>
-
+
-
+
new Set(role.permissions ?? []), [role.permissions]);
const [selected, setSelected] = useState>(initial);
@@ -248,7 +254,7 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean
permissions: Array.from(selected),
}),
onSuccess: () => {
- toast.success("Permissions updated");
+ toast.success(t("detail.perm.updated"));
queryClient.invalidateQueries({ queryKey: ["roles", role.id] });
},
onError: (err: unknown) => {
@@ -256,7 +262,7 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: (err as Error).message;
- toast.error("Update failed", { description: detail });
+ toast.error(t("detail.perm.updateFailed"), { description: detail });
},
});
@@ -288,9 +294,9 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean
return (
@@ -298,13 +304,17 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean
{dirty ? (
- Unsaved changes · {String(selected.size).padStart(2, "0")} of{" "}
- {String(total).padStart(2, "0")} granted
+ {t("detail.perm.unsaved", {
+ granted: String(selected.size).padStart(2, "0"),
+ total: String(total).padStart(2, "0"),
+ })}
) : (
- All changes saved · {String(selected.size).padStart(2, "0")} of{" "}
- {String(total).padStart(2, "0")} granted
+ {t("detail.perm.allSaved", {
+ granted: String(selected.size).padStart(2, "0"),
+ total: String(total).padStart(2, "0"),
+ })}
)}
@@ -317,7 +327,7 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean
onClick={() => setSelected(new Set(initial))}
className="h-9 rounded-lg px-3 text-[13px]"
>
- Discard
+ {t("detail.perm.discard")}
- {mutation.isPending ? "Saving…" : "Save permissions"}
+ {mutation.isPending ? t("detail.perm.saving") : t("detail.perm.save")}
@@ -369,7 +379,7 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean
disabled && "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-[var(--color-muted-foreground)]",
)}
>
- {allOn ? "Clear all" : someOn ? "Select remaining" : "Select all"}
+ {allOn ? t("detail.perm.clearAll") : someOn ? t("detail.perm.selectRemaining") : t("detail.perm.selectAll")}
@@ -422,12 +432,12 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean
{entry.root && (
- root
+ {t("detail.perm.rootBadge")}
)}
{entry.basic && (
- basic
+ {t("detail.perm.basicBadge")}
)}
@@ -451,12 +461,13 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean
// ─── Danger zone ────────────────────────────────────────────────────────
function DangerZone({ role, onDeleted }: { role: RoleDto; onDeleted: () => void }) {
+ const { t } = useTranslation("roles");
const [confirm, setConfirm] = useState("");
const mutation = useMutation({
mutationFn: () => deleteRole(role.id),
onSuccess: () => {
- toast.success(`Role ${role.name} deleted`);
+ toast.success(t("detail.danger.deleted", { name: role.name }));
onDeleted();
},
onError: (err: unknown) => {
@@ -464,7 +475,7 @@ function DangerZone({ role, onDeleted }: { role: RoleDto; onDeleted: () => void
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: (err as Error).message;
- toast.error("Delete failed", { description: detail });
+ toast.error(t("detail.danger.deleteFailed"), { description: detail });
},
});
@@ -472,14 +483,14 @@ function DangerZone({ role, onDeleted }: { role: RoleDto; onDeleted: () => void
return (
- Type {role.name} to confirm deletion.
+ {t("detail.danger.confirmPre")} {role.name} {t("detail.danger.confirmPost")}
void
className="h-9 rounded-lg px-4 text-[13px]"
>
- {mutation.isPending ? "Deleting…" : "Delete role"}
+ {mutation.isPending ? t("detail.danger.deleting") : t("detail.danger.delete")}
diff --git a/clients/admin/src/pages/roles/list.tsx b/clients/admin/src/pages/roles/list.tsx
index 0581be88a8..c0d0dcdc39 100644
--- a/clients/admin/src/pages/roles/list.tsx
+++ b/clients/admin/src/pages/roles/list.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Plus, Shield, ShieldCheck } from "lucide-react";
@@ -17,6 +18,7 @@ const DESKTOP_COLS =
"grid-cols-[1fr_120px_24px] lg:grid-cols-[1.4fr_2fr_120px_24px]";
export function RolesListPage() {
+ const { t } = useTranslation("roles");
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
@@ -55,17 +57,17 @@ export function RolesListPage() {
setCreateOpen(true)}
className="h-9 flex-1 gap-1.5 rounded-lg px-4 text-[13px] font-semibold sm:flex-none"
>
- New role
+ {t("newRole")}
@@ -78,8 +80,8 @@ export function RolesListPage() {
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
- placeholder="Search by name or description…"
- aria-label="Search roles"
+ placeholder={t("search.placeholder")}
+ aria-label={t("search.aria")}
className="h-9 w-full rounded-md border border-[var(--color-input)] bg-transparent pl-9 pr-3 text-[13px] outline-none transition-colors placeholder:text-[oklch(from_var(--color-muted-foreground)_l_c_h_/_0.7)] focus-visible:border-[var(--color-ring)] focus-visible:ring-[3px] focus-visible:ring-[oklch(from_var(--color-ring)_l_c_h_/_0.5)]"
/>
@@ -89,37 +91,37 @@ export function RolesListPage() {
message={
query.error instanceof ApiRequestError
? query.error.problem?.detail ?? query.error.message
- : "Failed to load roles."
+ : t("loadError")
}
/>
)}
- {query.isLoading &&
}
+ {query.isLoading &&
}
{!query.isLoading && filtered.length === 0 && !query.isError && (
searchActive ? (
-
No roles found.
+
{t("empty.searchTitle")}
- Nothing matches “{debounced}”. Try a different term.
+ {t("empty.searchDesc", { term: debounced })}
setSearch("")}
>
- Clear search
+ {t("clearSearch")}
) : (
setCreateOpen(true)} className="h-9 rounded-lg px-4 text-[13px]">
- New role
+ {t("newRole")}
}
/>
@@ -129,7 +131,7 @@ export function RolesListPage() {
{filtered.length > 0 && (
- {filtered.length} role{filtered.length !== 1 ? "s" : ""} found
+ {t("found", { count: filtered.length })}
{/* Mobile card list */}
@@ -150,13 +152,13 @@ export function RolesListPage() {
className={`grid items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-muted)]/40 px-4 py-2.5 ${DESKTOP_COLS}`}
>
- Name
+ {t("col.name")}
- Description
+ {t("col.description")}
- Permissions
+ {t("col.permissions")}
@@ -189,13 +191,14 @@ function RoleMobileCard({
role: RoleDto;
onClick: () => void;
}) {
+ const { t } = useTranslation("roles");
const isSystem = ROOT_ROLE_NAMES.has(role.name);
return (
@@ -218,12 +221,12 @@ function RoleMobileCard({
{isSystem && (
- System
+ {t("system")}
)}
- {role.description ?? No description on file. }
+ {role.description ?? {t("noDescription")} }
@@ -232,8 +235,7 @@ function RoleMobileCard({
{role.permissions != null && (
- {role.permissions.length}{" "}
- {role.permissions.length === 1 ? "permission" : "permissions"}
+ {t("permCount", { count: role.permissions.length })}
)}
@@ -253,11 +255,12 @@ function RoleDesktopRow({
isLast: boolean;
onClick: () => void;
}) {
+ const { t } = useTranslation("roles");
const isSystem = ROOT_ROLE_NAMES.has(role.name);
const permLabel =
role.permissions === undefined || role.permissions === null
? "—"
- : `${role.permissions.length} ${role.permissions.length === 1 ? "permission" : "permissions"}`;
+ : t("permCount", { count: role.permissions.length });
return (
@@ -284,7 +287,7 @@ function RoleDesktopRow({
{isSystem && (
- System
+ {t("system")}
)}
@@ -292,7 +295,7 @@ function RoleDesktopRow({
{/* Description (lg+) */}
- {role.description ?? No description on file. }
+ {role.description ?? {t("noDescription")} }
diff --git a/clients/admin/src/pages/settings/appearance.tsx b/clients/admin/src/pages/settings/appearance.tsx
index 5496869562..1266ac0056 100644
--- a/clients/admin/src/pages/settings/appearance.tsx
+++ b/clients/admin/src/pages/settings/appearance.tsx
@@ -1,4 +1,5 @@
import { Moon, Palette, Sun } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { useTheme } from "@/components/theme/theme-provider";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/list";
@@ -8,21 +9,21 @@ type Mode = "light" | "dark";
const MODES: {
value: Mode;
- label: string;
+ labelKey: string;
icon: typeof Sun;
- blurb: string;
+ blurbKey: string;
}[] = [
{
value: "light",
- label: "Light",
+ labelKey: "appearance.theme.light",
icon: Sun,
- blurb: "Paper-white surfaces, magazine-print mood.",
+ blurbKey: "appearance.theme.lightBlurb",
},
{
value: "dark",
- label: "Dark",
+ labelKey: "appearance.theme.dark",
icon: Moon,
- blurb: "Console-default. Lower glare for long sessions.",
+ blurbKey: "appearance.theme.darkBlurb",
},
];
@@ -32,18 +33,19 @@ const MODES: {
* to a tri-state. Persistence is handled by the provider; we just call setTheme.
*/
export function AppearanceSettings() {
+ const { t } = useTranslation("settings");
const { theme, setTheme } = useTheme();
return (
{/* Theme */}
- {MODES.map(({ value, label, icon: Icon, blurb }) => {
+ {MODES.map(({ value, labelKey, icon: Icon, blurbKey }) => {
const active = theme === value;
return (
{active && (
- Active
+ {t("appearance.active")}
)}
@@ -83,10 +85,10 @@ export function AppearanceSettings() {
active && "text-[var(--color-accent-signal)]",
)}
>
- {label}
+ {t(labelKey)}
- {blurb}
+ {t(blurbKey)}
);
@@ -96,12 +98,12 @@ export function AppearanceSettings() {
{/* Density — placeholder for a future compact toggle */}
- Compact rows · coming soon
+ {t("appearance.density.comingSoon")}
diff --git a/clients/admin/src/pages/settings/layout.tsx b/clients/admin/src/pages/settings/layout.tsx
index 5d58e8a01b..8ee02996e4 100644
--- a/clients/admin/src/pages/settings/layout.tsx
+++ b/clients/admin/src/pages/settings/layout.tsx
@@ -1,4 +1,5 @@
import { NavLink, Outlet, useLocation } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import {
ChevronRight,
MonitorSmartphone,
@@ -13,34 +14,34 @@ import { cn } from "@/lib/cn";
type Tab = {
to: string;
- label: string;
- hint: string;
+ labelKey: string;
+ hintKey: string;
icon: LucideIcon;
};
const TABS: Tab[] = [
{
to: "/settings/profile",
- label: "Profile",
- hint: "Your identity and avatar",
+ labelKey: "tab.profile.label",
+ hintKey: "tab.profile.hint",
icon: UserRound,
},
{
to: "/settings/security",
- label: "Security",
- hint: "Password and two-factor auth",
+ labelKey: "tab.security.label",
+ hintKey: "tab.security.hint",
icon: ShieldCheck,
},
{
to: "/settings/sessions",
- label: "Sessions",
- hint: "Active devices and sign-outs",
+ labelKey: "tab.sessions.label",
+ hintKey: "tab.sessions.hint",
icon: MonitorSmartphone,
},
{
to: "/settings/appearance",
- label: "Appearance",
- hint: "Theme and visual preferences",
+ labelKey: "tab.appearance.label",
+ hintKey: "tab.appearance.hint",
icon: Palette,
},
];
@@ -56,6 +57,7 @@ const pad2 = (n: number) => n.toString().padStart(2, "0");
* visible at page level. Child routes render via .
*/
export function SettingsLayout() {
+ const { t } = useTranslation("settings");
const location = useLocation();
const activeIndex = Math.max(
0,
@@ -71,7 +73,7 @@ export function SettingsLayout() {
icon={Settings}
title={
- Settings
+ {t("title")}
- {active.label}
+ {t(active.labelKey)}
}
- description={active.hint}
+ description={t(active.hintKey)}
/>
{/* ─── Editorial left nav ─── */}
-
+
{/* Desktop: sticky vertical numbered list */}
- Sections
+ {t("sections")}
{/* Faint vertical rail tying the numbers together */}
@@ -100,12 +102,12 @@ export function SettingsLayout() {
aria-hidden
className="absolute bottom-1 left-[14px] top-1 w-px bg-[oklch(from_var(--color-border)_l_c_h_/_0.6)]"
/>
- {TABS.map((t, i) => {
+ {TABS.map((tab, i) => {
const num = pad2(i + 1);
return (
-
+
cn(
@@ -143,10 +145,10 @@ export function SettingsLayout() {
: "text-[var(--color-muted-foreground)] group-hover:text-[var(--color-foreground)]",
)}
>
- {t.label}
+ {t(tab.labelKey)}
- {t.hint}
+ {t(tab.hintKey)}
- {TABS.map(({ to, label, icon: Icon }) => (
+ {TABS.map(({ to, labelKey, icon: Icon }) => (
- {label}
+ {t(labelKey)}
))}
diff --git a/clients/admin/src/pages/settings/profile.tsx b/clients/admin/src/pages/settings/profile.tsx
index dbfc2fc656..16873e2f9a 100644
--- a/clients/admin/src/pages/settings/profile.tsx
+++ b/clients/admin/src/pages/settings/profile.tsx
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import { Fingerprint, ShieldCheck, UserRound } from "lucide-react";
import { toast } from "sonner";
import { getMyProfile, setProfileImage } from "@/api/users";
@@ -19,32 +20,33 @@ import { ApiRequestError } from "@/lib/api-client";
* instead of the old base64 data: URL approach that hit the 2048-char limit.
*/
export function ProfileSettings() {
+ const { t } = useTranslation("settings");
const queryClient = useQueryClient();
const profile = useQuery({ queryKey: ["identity", "profile"], queryFn: getMyProfile });
const imageMutation = useMutation({
mutationFn: (url: string | null) => setProfileImage(url),
onSuccess: () => {
- toast.success("Profile image updated");
+ toast.success(t("profile.imageUpdated"));
void queryClient.invalidateQueries({ queryKey: ["identity", "profile"] });
},
onError: (err: unknown) => {
const message =
err instanceof ApiRequestError
? (err.problem?.detail ?? err.problem?.title ?? err.message)
- : "Failed to update profile image";
+ : t("profile.imageUpdateError");
toast.error(message);
},
});
- if (profile.isLoading) return ;
+ if (profile.isLoading) return ;
if (profile.isError) {
return (
);
@@ -55,15 +57,15 @@ export function ProfileSettings() {
[user.firstName, user.lastName].filter(Boolean).join(" ").trim() ||
user.userName ||
user.email ||
- "Account";
+ t("profile.fallbackName");
return (
{/* Avatar — presigned upload via ImageInput, no base64 data: URLs */}
-
+
-
+
-
+
{user.emailConfirmed !== undefined && (
- {user.emailConfirmed ? "Address verified" : "Not yet verified"}
+ {user.emailConfirmed ? t("profile.addressVerified") : t("profile.addressNotVerified")}
)}
-
+
- {user.isActive ? "Active" : "Disabled"}
+ {user.isActive ? t("profile.badge.active") : t("profile.badge.disabled")}
- {user.emailConfirmed ? "Email confirmed" : "Email pending"}
+ {user.emailConfirmed ? t("profile.badge.emailConfirmed") : t("profile.badge.emailPending")}
- {user.twoFactorEnabled ? "2FA enabled" : "2FA off"}
+ {user.twoFactorEnabled ? t("profile.badge.twoFaEnabled") : t("profile.badge.twoFaOff")}
diff --git a/clients/admin/src/pages/settings/security.tsx b/clients/admin/src/pages/settings/security.tsx
index 95d2e15275..ec71356b57 100644
--- a/clients/admin/src/pages/settings/security.tsx
+++ b/clients/admin/src/pages/settings/security.tsx
@@ -1,5 +1,6 @@
-import { forwardRef, useEffect, useState } from "react";
+import { forwardRef, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -50,16 +51,17 @@ import { cn } from "@/lib/cn";
* Password change is driven through a Dialog (mirrors dashboard pattern).
*/
export function SecuritySettings() {
+ const { t } = useTranslation("settings");
const profile = useQuery({ queryKey: ["identity", "profile"], queryFn: getMyProfile });
- if (profile.isLoading) return ;
+ if (profile.isLoading) return ;
if (profile.isError) {
return (
);
@@ -77,22 +79,25 @@ export function SecuritySettings() {
// ─── Password section ────────────────────────────────────────────────────
-const passwordSchema = z
- .object({
- current: z.string().min(1, "Required."),
- next: z.string().min(8, "At least 8 characters."),
- confirm: z.string().min(8),
- })
- .refine((v) => v.next === v.confirm, {
- path: ["confirm"],
- message: "Passwords don't match.",
- })
- .refine((v) => v.next !== v.current, {
- path: ["next"],
- message: "New password must differ from the current one.",
- });
-
-type PasswordValues = z.infer;
+type TFn = (key: string) => string;
+
+const makePasswordSchema = (t: TFn) =>
+ z
+ .object({
+ current: z.string().min(1, t("security.validation.required")),
+ next: z.string().min(8, t("security.validation.min8")),
+ confirm: z.string().min(8),
+ })
+ .refine((v) => v.next === v.confirm, {
+ path: ["confirm"],
+ message: t("security.validation.mismatch"),
+ })
+ .refine((v) => v.next !== v.current, {
+ path: ["next"],
+ message: t("security.validation.mustDiffer"),
+ });
+
+type PasswordValues = z.infer>;
/**
* RevealInput — password Input with a first-class show/hide eye toggle
@@ -101,6 +106,7 @@ type PasswordValues = z.infer;
*/
const RevealInput = forwardRef(
({ className, ...props }, ref) => {
+ const { t } = useTranslation("settings");
const [show, setShow] = useState(false);
return (
@@ -114,7 +120,7 @@ const RevealInput = forwardRef
(
type="button"
tabIndex={-1}
onClick={() => setShow((s) => !s)}
- aria-label={show ? "Hide password" : "Show password"}
+ aria-label={show ? t("security.password.hidePassword") : t("security.password.showPassword")}
className="absolute right-1.5 top-1/2 grid size-6 -translate-y-1/2 place-items-center rounded-md text-[var(--color-muted-foreground)] outline-none transition-colors hover:text-[var(--color-foreground)] focus-visible:ring-2 focus-visible:ring-[oklch(from_var(--color-ring)_l_c_h_/_0.5)]"
>
{show ? : }
@@ -126,22 +132,22 @@ const RevealInput = forwardRef(
RevealInput.displayName = "RevealInput";
function PasswordSection() {
+ const { t } = useTranslation("settings");
const [dialogOpen, setDialogOpen] = useState(false);
return (
<>
- Changing your password does not revoke other sessions automatically — visit the Sessions
- tab to sign out other devices.
+ {t("security.password.note")}
setDialogOpen(true)}>
- Change password
+ {t("security.password.change")}
@@ -158,6 +164,8 @@ function ChangePasswordDialog({
open: boolean;
onOpenChange: (next: boolean) => void;
}) {
+ const { t } = useTranslation("settings");
+ const passwordSchema = useMemo(() => makePasswordSchema(t), [t]);
const {
register,
handleSubmit,
@@ -181,8 +189,8 @@ function ChangePasswordDialog({
confirmNewPassword: v.confirm,
}),
onSuccess: () => {
- toast.success("Password changed", {
- description: "Other active sessions remain valid until you revoke them.",
+ toast.success(t("security.password.toastSuccess"), {
+ description: t("security.password.toastSuccessDesc"),
});
onOpenChange(false);
},
@@ -191,7 +199,7 @@ function ChangePasswordDialog({
err instanceof ApiRequestError
? (err.problem?.detail ?? err.problem?.title ?? err.message)
: (err as Error).message;
- toast.error("Change failed", { description: detail });
+ toast.error(t("security.password.toastFail"), { description: detail });
},
});
@@ -206,17 +214,16 @@ function ChangePasswordDialog({
- Change password
+ {t("security.password.dialogTitle")}
- Sign-out events for other devices aren't fired automatically — visit the Sessions tab
- below to end them after rotating your password.
+ {t("security.password.dialogDescription")}
@@ -285,6 +292,7 @@ function TwoFactorSection({ enabled }: { enabled: boolean }) {
}
function TwoFactorEnroll() {
+ const { t } = useTranslation("settings");
const queryClient = useQueryClient();
const [enrollment, setEnrollment] = useState(null);
const [code, setCode] = useState("");
@@ -299,7 +307,7 @@ function TwoFactorEnroll() {
err instanceof ApiRequestError
? (err.problem?.detail ?? err.problem?.title ?? err.message)
: (err as Error).message;
- toast.error("Enrollment failed", { description: detail });
+ toast.error(t("security.twoFa.enrollFailed"), { description: detail });
},
});
@@ -307,15 +315,15 @@ function TwoFactorEnroll() {
mutationFn: (otp: string) => verifyEnrollTwoFactor(otp),
onSuccess: (data) => {
if (data.success) {
- toast.success("Two-factor enabled", {
- description: "Future logins require a 6-digit code from your authenticator.",
+ toast.success(t("security.twoFa.enabledToast"), {
+ description: t("security.twoFa.enabledToastDesc"),
});
setEnrollment(null);
setCode("");
setQrSvg(null);
void queryClient.invalidateQueries({ queryKey: ["identity", "profile"] });
} else {
- toast.error("Verification failed", { description: "That code didn't match. Try again." });
+ toast.error(t("security.twoFa.verifyFailed"), { description: t("security.twoFa.verifyFailedDesc") });
}
},
onError: (err: unknown) => {
@@ -323,7 +331,7 @@ function TwoFactorEnroll() {
err instanceof ApiRequestError
? (err.problem?.detail ?? err.problem?.title ?? err.message)
: (err as Error).message;
- toast.error("Verification failed", { description: detail });
+ toast.error(t("security.twoFa.verifyFailed"), { description: detail });
},
});
@@ -374,14 +382,13 @@ function TwoFactorEnroll() {
return (
- Adds an authenticator app code on top of your password. Recommended for every operator
- account.
+ {t("security.twoFa.enrollDescription")}
- off
+ {t("security.twoFa.badgeOff")}
}
@@ -393,10 +400,10 @@ function TwoFactorEnroll() {
disabled={beginMutation.isPending}
>
- {beginMutation.isPending ? "Generating…" : "Enable two-factor"}
+ {beginMutation.isPending ? t("security.twoFa.generating") : t("security.twoFa.enable")}
- You'll scan a QR code in your authenticator app (1Password, Google Authenticator, Authy…).
+ {t("security.twoFa.enrollHint")}
) : (
@@ -405,7 +412,7 @@ function TwoFactorEnroll() {
{qrSvg ? (
) : (
- Rendering…
+ {t("security.twoFa.rendering")}
)}
- Can't scan? Enter manually
+ {t("security.twoFa.cantScan")}
@@ -434,11 +441,11 @@ function TwoFactorEnroll() {
>
{copiedKey ? (
<>
- copied
+ {t("security.twoFa.copied")}
>
) : (
<>
- copy
+ {t("security.twoFa.copy")}
>
)}
@@ -446,12 +453,12 @@ function TwoFactorEnroll() {
- 6-digit code from your app
+ {t("security.twoFa.codeLabel")}
setCode(e.target.value.replace(/\s/g, ""))}
@@ -468,7 +475,7 @@ function TwoFactorEnroll() {
disabled={code.length < 6 || verifyMutation.isPending}
variant="signal"
>
- {verifyMutation.isPending ? "Verifying…" : "Confirm & enable"}
+ {verifyMutation.isPending ? t("security.twoFa.verifying") : t("security.twoFa.confirmEnable")}
- Cancel
+ {t("security.twoFa.cancel")}
@@ -490,6 +497,7 @@ function TwoFactorEnroll() {
}
function TwoFactorDisable() {
+ const { t } = useTranslation("settings");
const queryClient = useQueryClient();
const [password, setPassword] = useState("");
@@ -497,11 +505,11 @@ function TwoFactorDisable() {
mutationFn: (pw: string) => disableTwoFactor(pw),
onSuccess: (data) => {
if (data.success) {
- toast.success("Two-factor disabled");
+ toast.success(t("security.twoFa.disabledToast"));
setPassword("");
void queryClient.invalidateQueries({ queryKey: ["identity", "profile"] });
} else {
- toast.error("Disable failed", { description: "Password verification failed." });
+ toast.error(t("security.twoFa.disableFailed"), { description: t("security.twoFa.disableFailedDesc") });
}
},
onError: (err: unknown) => {
@@ -509,20 +517,19 @@ function TwoFactorDisable() {
err instanceof ApiRequestError
? (err.problem?.detail ?? err.problem?.title ?? err.message)
: (err as Error).message;
- toast.error("Disable failed", { description: detail });
+ toast.error(t("security.twoFa.disableFailed"), { description: detail });
},
});
return (
- Two-factor is currently enabled on your account. Confirm your password to disable — this
- rotates the authenticator secret, so a fresh enroll will generate a new QR.
+ {t("security.twoFa.disableDescription")}
- enabled
+ {t("security.twoFa.badgeEnabled")}
}
@@ -535,14 +542,14 @@ function TwoFactorDisable() {
disabled={password.length === 0 || mutation.isPending}
>
- {mutation.isPending ? "Disabling…" : "Disable two-factor"}
+ {mutation.isPending ? t("security.twoFa.disabling") : t("security.twoFa.disable")}
}
>
diff --git a/clients/admin/src/pages/settings/sessions.tsx b/clients/admin/src/pages/settings/sessions.tsx
index b99c1fb02a..2cf12b1b90 100644
--- a/clients/admin/src/pages/settings/sessions.tsx
+++ b/clients/admin/src/pages/settings/sessions.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import {
AlertTriangle,
LogOut,
@@ -19,9 +20,13 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ErrorBand, LoadingRow, SettingsSection } from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
+import { formatDate } from "@/lib/format";
import { cn } from "@/lib/cn";
+type TFn = (key: string, opts?: Record) => string;
+
export function SessionsSettings() {
+ const { t } = useTranslation("sessions");
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["identity", "sessions", "me"],
@@ -39,10 +44,10 @@ export function SessionsSettings() {
mutationFn: (sessionId: string) => revokeMySession(sessionId),
onMutate: (sessionId) => setBusyIds((prev) => new Set(prev).add(sessionId)),
onSuccess: () => {
- toast.success("Session revoked");
+ toast.success(t("revoked"));
void queryClient.invalidateQueries({ queryKey: ["identity", "sessions", "me"] });
},
- onError: (err) => toast.error("Revoke failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("revokeFailed"), { description: describe(err) }),
onSettled: (_d, _e, sessionId) =>
setBusyIds((prev) => {
const next = new Set(prev);
@@ -54,22 +59,20 @@ export function SessionsSettings() {
const revokeAll = useMutation({
mutationFn: revokeAllMySessions,
onSuccess: (data) => {
- toast.success(
- `Revoked ${data.revokedCount} other ${data.revokedCount === 1 ? "session" : "sessions"}`,
- );
+ toast.success(t("revokedOther", { count: data.revokedCount }));
void queryClient.invalidateQueries({ queryKey: ["identity", "sessions", "me"] });
},
- onError: (err) => toast.error("Revoke all failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("revokeAllFailed"), { description: describe(err) }),
});
- if (query.isLoading) return ;
+ if (query.isLoading) return ;
if (query.isError) {
return (
);
@@ -78,9 +81,9 @@ export function SessionsSettings() {
return (
0 ? (
@@ -90,9 +93,7 @@ export function SessionsSettings() {
aria-hidden
/>
- {activeOtherCount} other{" "}
- {activeOtherCount === 1 ? "session is" : "sessions are"} active.
- Sign them all out at once if you suspect an account compromise.
+ {t("otherActiveWarn", { count: activeOtherCount })}
- {revokeAll.isPending ? "Signing out…" : "Sign out everywhere else"}
+ {revokeAll.isPending ? t("signingOut") : t("signOutEverywhere")}
) : undefined
@@ -110,7 +111,7 @@ export function SessionsSettings() {
>
{sorted.length === 0 ? (
- No active sessions found. (Including this one? That would be a bug — please refresh.)
+ {t("noneFound")}
) : (
@@ -138,6 +139,7 @@ function SessionRow({
busy: boolean;
onRevoke: () => void;
}) {
+ const { t } = useTranslation("sessions");
const isMobile = (session.deviceType ?? "").toLowerCase().includes("mobile");
const Icon = isMobile ? Smartphone : Monitor;
@@ -160,32 +162,32 @@ function SessionRow({
- {describeDevice(session)}
+ {describeDevice(session, t)}
{session.isCurrentSession && (
- This device
+ {t("thisDevice")}
)}
{!session.isActive && (
- Revoked
+ {t("badgeRevoked")}
)}
- {session.ipAddress ?? "unknown ip"}
- · last seen {formatRelative(session.lastActivityAt)}
- · expires {formatDate(session.expiresAt)}
+ {session.ipAddress ?? t("unknownIp")}
+ · {t("lastSeen", { time: formatRelative(session.lastActivityAt, t) })}
+ · {t("expires", { date: formatDate(session.expiresAt) })}
{session.isCurrentSession ? (
- use Sign out
+ {t("useSignOut")}
) : session.isActive ? (
- {busy ? "Revoking…" : "Revoke"}
+ {busy ? t("revoking") : t("revoke")}
) : (
@@ -206,32 +208,26 @@ function sortSessions(rows: UserSessionDto[]): UserSessionDto[] {
});
}
-function describeDevice(s: UserSessionDto): string {
- const browser = s.browser ?? "Unknown browser";
+function describeDevice(s: UserSessionDto, t: TFn): string {
+ const browser = s.browser ?? t("unknownBrowser");
const version = s.browserVersion ? ` ${s.browserVersion}` : "";
- const os = s.operatingSystem ?? "unknown os";
- return `${browser}${version} on ${os}`;
-}
-
-function formatDate(value?: string | null): string {
- if (!value) return "—";
- const d = new Date(value);
- return Number.isNaN(d.getTime()) ? value : d.toLocaleString();
+ const os = s.operatingSystem ?? t("unknownOs");
+ return t("deviceOn", { device: `${browser}${version}`, os });
}
-function formatRelative(value?: string | null): string {
+function formatRelative(value: string | null | undefined, t: TFn): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const diff = Date.now() - d.getTime();
const sec = Math.round(diff / 1000);
- if (sec < 60) return `${sec}s ago`;
+ if (sec < 60) return t("relative.secondsAgo", { n: sec });
const min = Math.round(sec / 60);
- if (min < 60) return `${min}m ago`;
+ if (min < 60) return t("relative.minutesAgo", { n: min });
const hr = Math.round(min / 60);
- if (hr < 24) return `${hr}h ago`;
+ if (hr < 24) return t("relative.hoursAgo", { n: hr });
const day = Math.round(hr / 24);
- if (day < 14) return `${day}d ago`;
+ if (day < 14) return t("relative.daysAgo", { n: day });
return d.toLocaleDateString();
}
diff --git a/clients/admin/src/pages/tenants/detail.tsx b/clients/admin/src/pages/tenants/detail.tsx
index b13c3daf10..385e3f36f1 100644
--- a/clients/admin/src/pages/tenants/detail.tsx
+++ b/clients/admin/src/pages/tenants/detail.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -46,8 +47,10 @@ import {
} from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
+import { formatDate } from "@/lib/format";
export function TenantDetailPage() {
+ const { t } = useTranslation("tenants");
const { id = "" } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -94,21 +97,21 @@ export function TenantDetailPage() {
const activationMutation = useMutation({
mutationFn: (isActive: boolean) => changeTenantActivation(id, isActive),
onSuccess: (result) => {
- toast.success(result.isActive ? "Tenant activated" : "Tenant deactivated");
+ toast.success(result.isActive ? t("detail.toast.activated") : t("detail.toast.deactivated"));
setActivationConfirmOpen(false);
queryClient.invalidateQueries({ queryKey: ["tenant", id] });
queryClient.invalidateQueries({ queryKey: ["tenants"] });
},
- onError: (err) => toast.error("Activation change failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("detail.toast.activationFailed"), { description: describe(err) }),
});
const retryMutation = useMutation({
mutationFn: () => retryTenantProvisioning(id),
onSuccess: () => {
- toast.success("Provisioning re-queued");
+ toast.success(t("detail.toast.reQueued"));
queryClient.invalidateQueries({ queryKey: ["tenant", id, "provisioning"] });
},
- onError: (err) => toast.error("Retry failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("detail.toast.retryFailed"), { description: describe(err) }),
});
const tenant = tenantQuery.data;
@@ -121,12 +124,12 @@ export function TenantDetailPage() {
navigate("/tenants")}>
- Registry
+ {t("detail.back")}
@@ -134,12 +137,12 @@ export function TenantDetailPage() {
)}
- {tenantQuery.isLoading && !tenant &&
}
+ {tenantQuery.isLoading && !tenant &&
}
{tenant && (
<>
{/* ── Hero identity card ─────────────────────────────────────── */}
-
+
{/* Left: monogram + name + meta + badges */}
@@ -160,11 +163,11 @@ export function TenantDetailPage() {
- {tenant.isActive ? "Active" : "Inactive"}
+ {tenant.isActive ? t("detail.badge.active") : t("detail.badge.inactive")}
{tenant.expiryState && tenant.expiryState !== "Active" && (
- {tenant.expiryState === "InGrace" ? "In grace" : "Expired"}
+ {tenant.expiryState === "InGrace" ? t("detail.expiry.inGrace") : t("detail.expiry.expired")}
)}
{tenant.plan && (
@@ -175,7 +178,7 @@ export function TenantDetailPage() {
)}
- Valid until {formatDate(tenant.validUpto)}
+ {t("detail.validUntil", { date: formatDate(tenant.validUpto) })}
{tenant.issuer && (
@@ -193,10 +196,10 @@ export function TenantDetailPage() {
variant="signal"
onClick={() => setImpersonateOpen(true)}
className="shrink-0"
- title="Sign in as a user inside this tenant"
+ title={t("detail.actions.impersonateHint")}
>
- Impersonate user
+ {t("detail.actions.impersonate")}
)}
{canManageSubscription && (
@@ -204,10 +207,10 @@ export function TenantDetailPage() {
variant="outline"
onClick={() => setRenewOpen(true)}
className="shrink-0"
- title="Extend validity by one plan term, or switch plans"
+ title={t("detail.actions.renewHint")}
>
- Renew / change plan
+ {t("detail.actions.renew")}
)}
{canManageSubscription && (
@@ -215,10 +218,10 @@ export function TenantDetailPage() {
variant="outline"
onClick={() => setAdjustOpen(true)}
className="shrink-0"
- title="Set the expiry date directly with no invoice (operator override)"
+ title={t("detail.actions.adjustHint")}
>
- Adjust validity
+ {t("detail.actions.adjust")}
)}
{canUpdateTenant && (
@@ -229,10 +232,10 @@ export function TenantDetailPage() {
className="shrink-0"
>
{activationMutation.isPending
- ? "Updating…"
+ ? t("detail.actions.updating")
: tenant.isActive
- ? "Deactivate tenant"
- : "Activate tenant"}
+ ? t("detail.actions.deactivate")
+ : t("detail.actions.activate")}
)}
@@ -270,22 +273,13 @@ export function TenantDetailPage() {
open={activationConfirmOpen}
onOpenChange={setActivationConfirmOpen}
destructive={tenant.isActive}
- title={tenant.isActive ? "Deactivate tenant?" : "Activate tenant?"}
+ title={tenant.isActive ? t("detail.confirm.deactivateTitle") : t("detail.confirm.activateTitle")}
description={
- tenant.isActive ? (
- <>
- Users of
{tenant.name} will
- be blocked from signing in and all their API requests will be rejected until you
- reactivate the tenant.
- >
- ) : (
- <>
-
{tenant.name} 's users
- will be able to sign in and use the platform again.
- >
- )
+ tenant.isActive
+ ? t("detail.confirm.deactivateBody", { name: tenant.name })
+ : t("detail.confirm.activateBody", { name: tenant.name })
}
- confirmLabel={tenant.isActive ? "Deactivate" : "Activate"}
+ confirmLabel={tenant.isActive ? t("detail.confirm.deactivate") : t("detail.confirm.activate")}
pending={activationMutation.isPending}
onConfirm={() => activationMutation.mutate(!tenant.isActive)}
/>
@@ -297,30 +291,30 @@ export function TenantDetailPage() {
{/* ── Details section ────────────────────────────────────────── */}
- {tenant.id}
- {tenant.name}
- {tenant.adminEmail}
- {tenant.issuer ?? "—"}
- {tenant.plan ?? "—"}
-
+ {tenant.id}
+ {tenant.name}
+ {tenant.adminEmail}
+ {tenant.issuer ?? "—"}
+ {tenant.plan ?? "—"}
+
{formatDate(tenant.validUpto)}
{tenant.expiryState && tenant.expiryState !== "Active" && (
- {tenant.expiryState === "InGrace" ? "In grace" : "Expired"}
+ {tenant.expiryState === "InGrace" ? t("detail.expiry.inGrace") : t("detail.expiry.expired")}
)}
-
+
- {tenant.isActive ? "Active" : "Inactive"}
+ {tenant.isActive ? t("detail.badge.active") : t("detail.badge.inactive")}
@@ -328,9 +322,9 @@ export function TenantDetailPage() {
{/* ── Provisioning section ───────────────────────────────────── */}
{status === "Failed"
- ? `Failed at ${currentStep ?? "unknown step"}`
+ ? t("detail.provisioning.failedAt", { step: currentStep ?? t("detail.provisioning.unknownStep") })
: currentStep
? `${overall} · ${currentStep}`
: overall}
@@ -454,7 +451,7 @@ function ProvisioningPanel({
{status === "Failed" && canRetry && (
- {retryPending ? "Re-queuing…" : "Retry provisioning"}
+ {retryPending ? t("detail.provisioning.reQueuing") : t("detail.provisioning.retry")}
)}
@@ -463,7 +460,7 @@ function ProvisioningPanel({
{error ? (
) : loading && steps.length === 0 ? (
- Loading…
+ {t("detail.provisioning.loadingBody")}
) : notTracked ? (
- This tenant wasn't created through the provisioning pipeline, so there's no run
- history to show. Tenants created via the console report their seed/migrate steps here.
+ {t("detail.provisioning.notTrackedBody")}
) : steps.length === 0 ? (
- No provisioning runs recorded.
+ {t("detail.provisioning.noRuns")}
) : (
@@ -548,6 +544,7 @@ function StepRow({
index: number;
isLast: boolean;
}) {
+ const { t } = useTranslation("tenants");
const isCompleted = step.status === "Completed";
const isFailed = step.status === "Failed";
const isRunning = step.status === "Running";
@@ -589,7 +586,7 @@ function StepRow({
step.startedUtc && step.completedUtc
? formatDuration(step.startedUtc, step.completedUtc)
: step.startedUtc
- ? "in flight"
+ ? t("detail.provisioning.inFlight")
: null;
return (
@@ -659,12 +656,6 @@ function StepRow({
// ─── helpers ────────────────────────────────────────────────────────────────
-function formatDate(value: string | undefined): string {
- if (!value) return "—";
- const d = new Date(value);
- return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString();
-}
-
function expiryVariant(state: string): React.ComponentProps["variant"] {
return state === "Expired" ? "danger" : state === "InGrace" ? "warning" : "outline";
}
diff --git a/clients/admin/src/pages/tenants/list.tsx b/clients/admin/src/pages/tenants/list.tsx
index f7d80fc87d..a571132eea 100644
--- a/clients/admin/src/pages/tenants/list.tsx
+++ b/clients/admin/src/pages/tenants/list.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Building2, ChevronLeft, ChevronRight, Plus } from "lucide-react";
@@ -8,6 +9,7 @@ import { Monogram } from "@/components/monogram";
import { EntityPageHeader, ErrorBand } from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
+import { formatDate } from "@/lib/format";
import { CreateTenantDialog } from "@/components/tenants/create-tenant-dialog";
import { useAuth } from "@/auth/use-auth";
import { MultitenancyPermissions } from "@/lib/permissions";
@@ -17,12 +19,8 @@ const PAGE_SIZE = 12;
// Desktop grid template — shared by header + rows.
const DESKTOP_COLS = "grid-cols-[1fr_140px_24px] lg:grid-cols-[1.6fr_1.4fr_140px_24px]";
-function formatDate(value: string): string {
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
-}
-
export function TenantsListPage() {
+ const { t } = useTranslation("tenants");
const [pageNumber, setPageNumber] = useState(1);
const [createOpen, setCreateOpen] = useState(false);
const navigate = useNavigate();
@@ -43,22 +41,22 @@ export function TenantsListPage() {
const pageBadge = useMemo(() => {
if (!data) return "—";
const p = String(data.pageNumber).padStart(2, "0");
- const t = String(Math.max(data.totalPages, 1)).padStart(2, "0");
- return `Page ${p} of ${t}`;
- }, [data]);
+ const total = String(Math.max(data.totalPages, 1)).padStart(2, "0");
+ return t("list.page", { page: p, total });
+ }, [data, t]);
return (
{canCreateTenant && (
@@ -66,7 +64,7 @@ export function TenantsListPage() {
onClick={() => setCreateOpen(true)}
className="h-9 flex-1 gap-1.5 rounded-lg px-4 text-[13px] font-semibold sm:flex-none"
>
- New tenant
+ {t("list.newTenant")}
)}
@@ -76,7 +74,7 @@ export function TenantsListPage() {
message={
query.error instanceof ApiRequestError
? query.error.problem?.detail ?? query.error.message
- : "Failed to load tenants."
+ : t("list.loadError")
}
/>
)}
@@ -86,15 +84,15 @@ export function TenantsListPage() {
role="status"
className="py-12 text-center font-mono text-sm uppercase tracking-[0.18em] text-[var(--color-muted-foreground)]"
>
- Loading…
+ {t("list.loading")}
)}
{!query.isLoading && items.length === 0 && !query.isError && (
-
No tenants yet.
+
{t("list.empty.title")}
- Provision the first tenant to get started.
+ {t("list.empty.description")}
)}
@@ -102,7 +100,7 @@ export function TenantsListPage() {
{items.length > 0 && (
- {data?.totalCount ?? 0} tenant{(data?.totalCount ?? 0) !== 1 ? "s" : ""} registered
+ {t("list.found", { count: data?.totalCount ?? 0 })}
{/* Mobile card list */}
@@ -122,13 +120,13 @@ export function TenantsListPage() {
className={`grid items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-muted)]/40 px-4 py-2.5 ${DESKTOP_COLS}`}
>
- Tenant
+ {t("list.col.tenant")}
- Admin email
+ {t("list.col.adminEmail")}
- Status
+ {t("list.col.status")}
@@ -160,7 +158,7 @@ export function TenantsListPage() {
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
className="h-9 rounded-lg px-3 text-[13px]"
>
- Previous
+ {t("list.previous")}
setPageNumber((p) => p + 1)}
className="h-9 rounded-lg px-3 text-[13px]"
>
- Next
+ {t("list.next")}
@@ -183,6 +181,7 @@ export function TenantsListPage() {
// ─── Status pill ─────────────────────────────────────────────────────────
function StatusPill({ active }: { active: boolean }) {
+ const { t } = useTranslation("tenants");
return (
- {active ? "Active" : "Inactive"}
+ {active ? t("list.status.active") : t("list.status.inactive")}
);
}
@@ -200,12 +199,13 @@ function StatusPill({ active }: { active: boolean }) {
// ─── Mobile card ───────────────────────────────────────────────────────────
function TenantMobileCard({ tenant, onClick }: { tenant: TenantDto; onClick: () => void }) {
+ const { t } = useTranslation("tenants");
return (
void }) {
+ const { t } = useTranslation("tenants");
return (
- {tenant.id} · valid {formatDate(tenant.validUpto)}
+ {t("list.row.idValid", { id: tenant.id, date: formatDate(tenant.validUpto) })}
diff --git a/clients/admin/src/pages/users/detail.tsx b/clients/admin/src/pages/users/detail.tsx
index fde0e92e71..5519336a61 100644
--- a/clients/admin/src/pages/users/detail.tsx
+++ b/clients/admin/src/pages/users/detail.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, Mail, ShieldCheck, User as UserIcon, Users } from "lucide-react";
@@ -24,6 +25,7 @@ import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
export function UserDetailPage() {
+ const { t } = useTranslation("users");
const { id = "" } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -43,11 +45,11 @@ export function UserDetailPage() {
const toggleMutation = useMutation({
mutationFn: (activate: boolean) => toggleUserStatus(id, activate),
onSuccess: (_, activate) => {
- toast.success(activate ? "User activated" : "User deactivated");
+ toast.success(activate ? t("detail.toast.activated") : t("detail.toast.deactivated"));
queryClient.invalidateQueries({ queryKey: ["user", id] });
queryClient.invalidateQueries({ queryKey: ["users"] });
},
- onError: (err) => toast.error("Status change failed", { description: describeErr(err) }),
+ onError: (err) => toast.error(t("detail.toast.statusFailed"), { description: describeErr(err) }),
});
const user = userQuery.data;
@@ -72,13 +74,13 @@ export function UserDetailPage() {
onClick={() => navigate("/users")}
className="h-9 gap-1.5 rounded-lg px-3 text-[13px]"
>
- Directory
+ {t("detail.back")}
{userQuery.isError && }
- {userQuery.isLoading && !user && }
+ {userQuery.isLoading && !user && }
{user && (
<>
@@ -110,14 +112,14 @@ export function UserDetailPage() {
variant={user.isActive ? "success" : "muted"}
className="font-mono text-[10px] uppercase tracking-[0.14em]"
>
- {user.isActive ? "Active" : "Disabled"}
+ {user.isActive ? t("detail.badge.active") : t("detail.badge.disabled")}
- {user.emailConfirmed ? "Email confirmed" : "Email pending"}
+ {user.emailConfirmed ? t("detail.badge.emailConfirmed") : t("detail.badge.emailPending")}
@@ -130,10 +132,10 @@ export function UserDetailPage() {
className="shrink-0 h-9 rounded-lg px-4 text-[13px]"
>
{toggleMutation.isPending
- ? "Updating…"
+ ? t("detail.toggle.updating")
: user.isActive
- ? "Deactivate account"
- : "Activate account"}
+ ? t("detail.toggle.deactivate")
+ : t("detail.toggle.activate")}
@@ -141,28 +143,28 @@ export function UserDetailPage() {
{/* Identity details */}
-
+
{user.id ?? "—"}
-
+
{user.userName ?? "—"}
-
+
{user.email ?? "—"}
-
+
{user.phoneNumber ?? "—"}
-
- {user.isActive ? "Active" : "Disabled"}
+
+ {user.isActive ? t("detail.status.active") : t("detail.status.disabled")}
-
- {user.emailConfirmed ? "Yes" : "Pending confirmation"}
+
+ {user.emailConfirmed ? t("detail.status.yes") : t("detail.status.pendingConfirmation")}
@@ -228,6 +230,7 @@ function RolesEditor({
error: unknown;
onSaved: () => void;
}) {
+ const { t } = useTranslation("users");
const queryClient = useQueryClient();
const [draft, setDraft] = useState
>({});
@@ -251,11 +254,11 @@ function RolesEditor({
const mutation = useMutation({
mutationFn: (next: UserRoleDto[]) => assignUserRoles(userId, next),
onSuccess: () => {
- toast.success("Roles updated");
+ toast.success(t("detail.roles.updated"));
queryClient.invalidateQueries({ queryKey: ["user", userId, "roles"] });
onSaved();
},
- onError: (err) => toast.error("Role update failed", { description: describeErr(err) }),
+ onError: (err) => toast.error(t("detail.roles.updateFailed"), { description: describeErr(err) }),
});
const onSave = () => {
@@ -266,12 +269,12 @@ function RolesEditor({
return (
0
- ? `${dirtyCount} pending change${dirtyCount === 1 ? "" : "s"} — review and save when ready.`
- : "Tap any role to toggle. Changes are batched — review and save when ready."
+ ? t("detail.roles.pending", { count: dirtyCount })
+ : t("detail.roles.hint")
}
footer={
!loading && roles.length > 0 ? (
@@ -282,7 +285,7 @@ function RolesEditor({
className="h-9 rounded-lg px-4 text-[13px]"
>
- {mutation.isPending ? "Saving…" : "Save changes"}
+ {mutation.isPending ? t("detail.roles.saving") : t("detail.roles.saveChanges")}
- Discard
+ {t("detail.roles.discard")}
) : undefined
@@ -300,12 +303,12 @@ function RolesEditor({
) : loading ? (
- Loading
+ {t("detail.roles.loading")}
) : roles.length === 0 ? (
- No roles defined for this tenant.
+ {t("detail.roles.none")}
) : (
@@ -335,12 +338,13 @@ function RoleRow({
changed: boolean;
onToggle: () => void;
}) {
+ const { t } = useTranslation("users");
return (
- {role.roleName ?? "Untitled role"}
+ {role.roleName ?? t("detail.roles.untitled")}
{changed && (
void;
label: string;
}) {
+ const { t } = useTranslation("users");
return (
- {enabled ? "On" : "Off"}
+ {enabled ? t("detail.roles.on") : t("detail.roles.off")}
);
}
diff --git a/clients/admin/src/pages/users/list.tsx b/clients/admin/src/pages/users/list.tsx
index d69fb86b57..e473f9c80f 100644
--- a/clients/admin/src/pages/users/list.tsx
+++ b/clients/admin/src/pages/users/list.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Plus, Users } from "lucide-react";
@@ -27,6 +28,7 @@ const DESKTOP_COLS =
"grid-cols-[1fr_140px_24px] lg:grid-cols-[1.6fr_140px_180px_24px]";
export function UsersListPage() {
+ const { t } = useTranslation("users");
const navigate = useNavigate();
const [pageNumber, setPageNumber] = useState(1);
@@ -81,9 +83,9 @@ export function UsersListPage() {
const pageBadge = useMemo(() => {
if (!data) return "—";
const p = String(data.pageNumber).padStart(2, "0");
- const t = String(Math.max(data.totalPages, 1)).padStart(2, "0");
- return `Page ${p} of ${t}`;
- }, [data]);
+ const total = String(Math.max(data.totalPages, 1)).padStart(2, "0");
+ return t("page", { page: p, total });
+ }, [data, t]);
const filtersActive =
activeFilter !== "any" || confirmedFilter !== "any" || roleId !== "";
@@ -100,18 +102,18 @@ export function UsersListPage() {
setCreateOpen(true)}
className="h-9 flex-1 gap-1.5 rounded-lg px-4 text-[13px] font-semibold sm:flex-none"
>
- New user
+ {t("newUser")}
@@ -125,39 +127,39 @@ export function UsersListPage() {
type="search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
- placeholder="Search name, username, email…"
- aria-label="Search users"
+ placeholder={t("search.placeholder")}
+ aria-label={t("search.aria")}
className="h-9 w-full rounded-md border border-[var(--color-input)] bg-transparent pl-9 pr-3 font-mono text-[12.5px] outline-none transition-colors placeholder:text-[oklch(from_var(--color-muted-foreground)_l_c_h_/_0.7)] focus-visible:border-[var(--color-ring)] focus-visible:ring-[3px] focus-visible:ring-[oklch(from_var(--color-ring)_l_c_h_/_0.5)]"
/>
setRoleId(v)}
options={(rolesQuery.data ?? []).map((r) => ({ value: r.id ?? "", label: r.name ?? r.id ?? "" }))}
- placeholder="Any role"
+ placeholder={t("filter.anyRole")}
minWidth="9rem"
/>
@@ -167,7 +169,7 @@ export function UsersListPage() {
message={
usersQuery.error instanceof ApiRequestError
? usersQuery.error.problem?.detail ?? usersQuery.error.message
- : "Failed to load users."
+ : t("loadError")
}
/>
)}
@@ -177,17 +179,17 @@ export function UsersListPage() {
role="status"
className="py-12 text-center font-mono text-sm uppercase tracking-[0.18em] text-[var(--color-muted-foreground)]"
>
- Loading…
+ {t("loading")}
)}
{!usersQuery.isLoading && items.length === 0 && !usersQuery.isError && (
-
No matches.
+
{t("empty.title")}
{searchActive
- ? "Adjust filters or invite a new user."
- : "Register the first member to seed this tenant."}
+ ? t("empty.withFilters")
+ : t("empty.noData")}
{searchActive && (
- Clear filters
+ {t("clearFilters")}
)}
@@ -204,7 +206,7 @@ export function UsersListPage() {
{items.length > 0 && (
- {data?.totalCount ?? 0} user{(data?.totalCount ?? 0) !== 1 ? "s" : ""} found
+ {t("found", { count: data?.totalCount ?? 0 })}
{/* Mobile card list */}
@@ -226,13 +228,13 @@ export function UsersListPage() {
className={`grid items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-muted)]/40 px-4 py-2.5 ${DESKTOP_COLS}`}
>
- Name
+ {t("col.name")}
- Username
+ {t("col.username")}
- Status
+ {t("col.status")}
@@ -265,7 +267,7 @@ export function UsersListPage() {
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
className="h-9 rounded-lg px-3 text-[13px]"
>
- Previous
+ {t("previous")}
setPageNumber((p) => p + 1)}
className="h-9 rounded-lg px-3 text-[13px]"
>
- Next
+ {t("next")}
@@ -296,15 +298,16 @@ function UserMobileCard({
index: number;
onClick: () => void;
}) {
+ const { t } = useTranslation("users");
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
- const display = fullName || user.userName || user.email || "Unnamed";
+ const display = fullName || user.userName || user.email || t("unnamed");
return (
- {user.email ?? "no email"}
+ {user.email ?? t("noEmail")}
@@ -341,7 +344,7 @@ function UserMobileCard({
: "bg-[var(--color-muted)] text-[var(--color-muted-foreground)]",
)}
>
- {user.isActive ? "Active" : "Inactive"}
+ {user.isActive ? t("badge.active") : t("badge.inactive")}
- {user.emailConfirmed ? "Email confirmed" : "Email pending"}
+ {user.emailConfirmed ? t("badge.emailConfirmed") : t("badge.emailPending")}
@@ -369,8 +372,9 @@ function UserDesktopRow({
isLast?: boolean;
onClick: () => void;
}) {
+ const { t } = useTranslation("users");
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
- const display = fullName || user.userName || user.email || "Unnamed";
+ const display = fullName || user.userName || user.email || t("unnamed");
return (
@@ -401,7 +405,7 @@ function UserDesktopRow({
!user.email && "italic opacity-60",
)}
>
- {user.email ?? "no email on file"}
+ {user.email ?? t("noEmailOnFile")}
@@ -424,7 +428,7 @@ function UserDesktopRow({
: "bg-[var(--color-muted)] text-[var(--color-muted-foreground)]",
)}
>
- {user.isActive ? "Active" : "Inactive"}
+ {user.isActive ? t("badge.active") : t("badge.inactive")}
- {user.emailConfirmed ? "Confirmed" : "Pending"}
+ {user.emailConfirmed ? t("badge.confirmed") : t("badge.pending")}
diff --git a/clients/admin/src/pages/webhooks/detail.tsx b/clients/admin/src/pages/webhooks/detail.tsx
index 553962753a..a6008b7f4e 100644
--- a/clients/admin/src/pages/webhooks/detail.tsx
+++ b/clients/admin/src/pages/webhooks/detail.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import {
keepPreviousData,
@@ -35,11 +36,13 @@ import {
SettingsSection,
} from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
+import { formatDate } from "@/lib/format";
import { cn } from "@/lib/cn";
const PAGE_SIZE = 25;
export function WebhookDetailPage() {
+ const { t } = useTranslation("webhooks");
const { id = "" } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -66,33 +69,33 @@ export function WebhookDetailPage() {
mutationFn: () => testWebhookSubscription(id),
onSuccess: (data) => {
toast[data.success ? "success" : "warning"](
- data.success ? "Test event delivered" : "Endpoint rejected the test event",
+ data.success ? t("toast.testDelivered") : t("toast.testRejected"),
);
deliveries.refetch();
},
- onError: (err) => toast.error("Test failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("toast.testFailed"), { description: describe(err) }),
});
const remove = useMutation({
mutationFn: () => deleteWebhookSubscription(id),
onSuccess: () => {
- toast.success("Subscription deleted");
+ toast.success(t("toast.deleted"));
queryClient.invalidateQueries({ queryKey: ["webhooks", "subscriptions"] });
navigate("/webhooks");
},
- onError: (err) => toast.error("Delete failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("toast.deleteFailed"), { description: describe(err) }),
});
return (
navigate("/webhooks")}>
- Subscriptions
+ {t("detail.back")}
}
/>
@@ -102,34 +105,34 @@ export function WebhookDetailPage() {
message={
subsQuery.error instanceof ApiRequestError
? subsQuery.error.problem?.detail ?? subsQuery.error.message
- : "Failed to load subscription."
+ : t("detail.loadError")
}
/>
)}
- {subsQuery.isLoading && }
+ {subsQuery.isLoading && }
{!subsQuery.isLoading && !sub && !subsQuery.isError && (
-
+
)}
{sub && (
test.mutate()} disabled={test.isPending}>
- {test.isPending ? "Sending…" : "Send test event"}
+ {test.isPending ? t("detail.sending") : t("detail.sendTest")}
{
- if (window.confirm(`Delete subscription to ${sub.url}?`)) {
+ if (window.confirm(t("list.confirmDelete", { url: sub.url }))) {
remove.mutate();
}
}}
@@ -137,45 +140,45 @@ export function WebhookDetailPage() {
className="text-[var(--color-destructive)] hover:bg-[oklch(from_var(--color-destructive)_l_c_h_/_0.08)]"
>
- {remove.isPending ? "Deleting…" : "Delete subscription"}
+ {remove.isPending ? t("detail.deleting") : t("detail.deleteSubscription")}
}
>
-
-
+
- {sub.isActive ? "Active" : "Inactive"}
+ {sub.isActive ? t("badge.active") : t("badge.inactive")}
} />
-
-
+
+
{sub.events.map((e) => (
{e}
))}
{sub.events.length === 0 && (
- — no events; subscription would never fire
+ {t("detail.events.none")}
)}
- {deliveries.data ? `${deliveries.data.totalCount} attempts` : "—"}
+ {deliveries.data ? t("detail.deliveries.attempts", { count: deliveries.data.totalCount }) : "—"}
- Refresh
+ {t("detail.deliveries.refresh")}
}
@@ -192,10 +195,10 @@ export function WebhookDetailPage() {
{deliveries.isError ? (
) : deliveries.isLoading ? (
-
+
) : (deliveries.data?.items.length ?? 0) === 0 ? (
- No deliveries yet. Try the test button above, or wait for matching events to fire.
+ {t("detail.deliveries.empty")}
) : (
<>
@@ -216,7 +219,7 @@ export function WebhookDetailPage() {
hasNext={deliveries.data!.hasNext}
onPrev={() => setDeliveryPage((p) => Math.max(1, p - 1))}
onNext={() => setDeliveryPage((p) => p + 1)}
- noun="deliveries"
+ noun={t("detail.deliveries.noun")}
/>
)}
@@ -230,6 +233,7 @@ export function WebhookDetailPage() {
}
function DeliveryRow({ delivery }: { delivery: WebhookDeliveryDto }) {
+ const { t } = useTranslation("webhooks");
const Icon = delivery.success ? CheckCircle2 : XCircle;
const tone = delivery.success ? "text-[var(--color-success)]" : "text-[var(--color-destructive)]";
return (
@@ -240,16 +244,16 @@ function DeliveryRow({ delivery }: { delivery: WebhookDeliveryDto }) {
{delivery.eventType}
- {delivery.errorMessage ?? (delivery.success ? "OK" : "Failed")}
+ {delivery.errorMessage ?? (delivery.success ? t("delivery.ok") : t("delivery.failed"))}
- HTTP {delivery.httpStatusCode || "—"}
+ {t("delivery.http", { code: delivery.httpStatusCode || "—" })}
- try {delivery.attemptCount}
+ {t("delivery.try", { count: delivery.attemptCount })}
);
diff --git a/clients/admin/src/pages/webhooks/list.tsx b/clients/admin/src/pages/webhooks/list.tsx
index ff9278c0b1..d72e6bd9e4 100644
--- a/clients/admin/src/pages/webhooks/list.tsx
+++ b/clients/admin/src/pages/webhooks/list.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
keepPreviousData,
@@ -32,11 +33,13 @@ import {
import { EmptyState } from "@/components/empty-state";
import { CreateWebhookDialog } from "@/components/webhooks/create-webhook-dialog";
import { ApiRequestError } from "@/lib/api-client";
+import { formatDate } from "@/lib/format";
import { cn } from "@/lib/cn";
const PAGE_SIZE = 25;
export function WebhooksListPage() {
+ const { t } = useTranslation("webhooks");
const navigate = useNavigate();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
@@ -54,16 +57,16 @@ export function WebhooksListPage() {
onMutate: (id) => setBusyId(id),
onSuccess: (data) => {
if (data.success) {
- toast.success("Test event delivered", {
- description: "Your endpoint accepted the payload. Check Deliveries for details.",
+ toast.success(t("toast.testDelivered"), {
+ description: t("toast.testDeliveredDesc"),
});
} else {
- toast.warning("Test failed", {
- description: "Endpoint rejected the test event. See Deliveries for the response code.",
+ toast.warning(t("toast.testFailed"), {
+ description: t("toast.testFailedDesc"),
});
}
},
- onError: (err) => toast.error("Test failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("toast.testFailed"), { description: describe(err) }),
onSettled: () => setBusyId(null),
});
@@ -71,10 +74,10 @@ export function WebhooksListPage() {
mutationFn: (id: string) => deleteWebhookSubscription(id),
onMutate: (id) => setBusyId(id),
onSuccess: () => {
- toast.success("Subscription deleted");
+ toast.success(t("toast.deleted"));
queryClient.invalidateQueries({ queryKey: ["webhooks", "subscriptions"] });
},
- onError: (err) => toast.error("Delete failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("toast.deleteFailed"), { description: describe(err) }),
onSettled: () => setBusyId(null),
});
@@ -85,10 +88,10 @@ export function WebhooksListPage() {
- Refresh
+ {t("list.refresh")}
setCreateOpen(true)} className="flex-1 sm:flex-none">
- New subscription
+ {t("list.newSubscription")}
@@ -110,22 +113,22 @@ export function WebhooksListPage() {
message={
query.error instanceof ApiRequestError
? query.error.problem?.detail ?? query.error.message
- : "Failed to load subscriptions."
+ : t("list.loadError")
}
/>
)}
- {query.isLoading &&
}
+ {query.isLoading &&
}
{!query.isLoading && items.length === 0 && !query.isError && (
setCreateOpen(true)}>
- New subscription
+ {t("list.newSubscription")}
}
/>
@@ -141,7 +144,7 @@ export function WebhooksListPage() {
busy={busyId === sub.id}
onTest={() => test.mutate(sub.id)}
onDelete={() => {
- if (window.confirm(`Delete subscription to ${sub.url}?`)) {
+ if (window.confirm(t("list.confirmDelete", { url: sub.url }))) {
remove.mutate(sub.id);
}
}}
@@ -162,7 +165,7 @@ export function WebhooksListPage() {
hasNext={data.hasNext}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => p + 1)}
- noun="subscriptions"
+ noun={t("list.noun")}
/>
)}
@@ -190,6 +193,7 @@ function Row({
onDelete: () => void;
onOpen: () => void;
}) {
+ const { t } = useTranslation("webhooks");
const num = String(index).padStart(3, "0");
return (
@@ -203,7 +207,7 @@ function Row({
sub.isActive ? "bg-[var(--color-accent-signal)]" : "bg-[var(--color-muted-foreground)]/50",
)}
aria-hidden
- title={sub.isActive ? "Active" : "Inactive"}
+ title={sub.isActive ? t("badge.activeTitle") : t("badge.inactiveTitle")}
/>
4 && (
- +{sub.events.length - 4} more
+ {t("list.moreEvents", { count: sub.events.length - 4 })}
)}
- · since {new Date(sub.createdAtUtc).toLocaleDateString()}
+ {t("list.since", { date: formatDate(sub.createdAtUtc) })}
@@ -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