diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md
index cf3fa6b6ef..3f63770aac 100644
--- a/.agents/rules/architecture.md
+++ b/.agents/rules/architecture.md
@@ -81,8 +81,9 @@ In `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`):
2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected)
3. HttpsRedirection → SecurityHeaders → static files → Routing
4. **`UseAuthentication`**
-5. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth
-6. RateLimiting → Quotas → `UseAuthorization` → `MapModules`
+5. **`UseHeroLocalization`** — request localization, sits **between `UseAuthentication` and `UseAuthorization`** so the user-`locale`-claim culture provider can read `HttpContext.User`
+6. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth
+7. RateLimiting → Quotas → `UseAuthorization` → `MapModules`
`app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`** — so tenant resolution is header-driven, not claim-driven. See `modules/multitenancy.md`.
diff --git a/.agents/rules/localization.md b/.agents/rules/localization.md
new file mode 100644
index 0000000000..bd9a6078a0
--- /dev/null
+++ b/.agents/rules/localization.md
@@ -0,0 +1,63 @@
+# Localization (i18n)
+
+`src/BuildingBlocks/Core/Localization/` + per-module `Localization/` folders. Read before adding any user-facing message (exception, validation, API error). **The client's culture is always respected, never the server's.**
+
+## Culture negotiation (already wired — don't re-add)
+
+`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically.
+
+## Catalogs — hybrid, one marker per catalog
+
+- **Core (`SharedResources`)** — generic / cross-cutting messages: ProblemDetails titles (`Error.*`), cross-module errors (`Error.TenantContextRequired`, `Error.NoCurrentUser`, …), and shared validation (`Validation.*`).
+- **Per module (`Resources`)** — domain-specific messages owned by the module: `src/Modules//Modules./Localization/Resources.cs` (marker `public sealed class Resources;`) + co-located `Resources.resx` (en) + `Resources.pt.resx` (pt). `ResourcesPath = ""` (co-located), so the resx manifest name must equal the marker's full type name.
+
+Key naming: `Error..` for domain messages (`Catalog.ProductNotFound`), `Error.` / `Validation.` for Core. PascalCase. Placeholders are `{0}`, `{1}` (`string.Format` via the localizer) — **not** the frontend's `{{name}}`.
+
+## Exceptions — localize at the boundary, log stays English
+
+Throw with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site.
+
+```csharp
+// domain message -> module catalog
+throw new NotFoundException($"Product {id} not found.")
+{
+ MessageKey = "Catalog.ProductNotFound",
+ MessageArgs = [id],
+ ResourceSource = typeof(CatalogResources),
+};
+
+// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources)
+throw new UnauthorizedException("Tenant context is required.")
+{
+ MessageKey = "Error.TenantContextRequired",
+};
+```
+
+`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException("...")` still renders its English literal.
+
+**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body.
+
+## Validators — inject the localizer, defer resolution
+
+```csharp
+public sealed class XCommandValidator : AbstractValidator
+{
+ public XCommandValidator(IStringLocalizer localizer)
+ {
+ RuleFor(x => x.Name)
+ .NotEmpty().WithMessage(_ => localizer["Validation.NameRequired"]);
+ }
+}
+```
+
+Always the `.WithMessage(_ => localizer["Key"])` lambda (resolution is deferred to `Validate()`, under the request culture) — never `.WithMessage(localizer["Key"])`. **Catalog choice:** inject `IStringLocalizer` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<Resources>` for module-specific validation messages kept in the module's own catalog. DI provides the localizer automatically (`AddValidatorsFromAssembly` + `AddHeroLocalization` + the module's own `AddLocalization`); nested validators (`Include(new PagedQueryValidator(localizer))`) receive it from the parent.
+
+## Tests (required with every catalog change)
+
+- **Parity** — every key present in both `en` and `pt` for each catalog (Core + every `Resources`). Extend the parity test when adding a module catalog.
+- **Code → resx guard** — every referenced key (`MessageKey`, `localizer["…"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently).
+- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub.
+
+## Emails / background handlers
+
+Integration-event handlers run without an HTTP request, so there is no negotiated culture. Localizing outbound emails needs the recipient's stored locale propagated to the handler — **not yet implemented** (tracked for a future PR); email bodies stay English for now.
diff --git a/AGENTS.md b/AGENTS.md
index cbe60e9e1f..6603c5120e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -104,6 +104,7 @@ Single long-lived branch: **`main`** (the default) — there is **no `develop`**
| CORS, security headers, rate limiting, idempotency, quotas | `security.md` |
| SignalR / SSE backend | `realtime.md` |
| Logging, correlation, OpenTelemetry | `logging.md` |
+| Localization (i18n), request culture, resource catalogs, localized exceptions | `localization.md` |
| Unit test conventions, NetArchTest | `testing.md` |
| Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` |
| **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` |
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/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..24695f77c4 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";
@@ -21,6 +22,8 @@ import { ApiRequestError } from "@/lib/api-client";
import { env } from "@/env";
import { cn } from "@/lib/cn";
+type TFn = (key: string, opts?: Record) => string;
+
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -54,6 +57,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 +80,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 +134,7 @@ function PickStep({
onPick: (user: UserDto) => void;
onCancel: () => void;
}) {
+ const { t } = useTranslation("impersonation");
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
@@ -164,8 +169,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 +180,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 +218,7 @@ function PickStep({
{[user.firstName, user.lastName].filter(Boolean).join(" ") ||
user.userName ||
user.email ||
- "Unnamed"}
+ t("dialog.pick.unnamed")}
{user.userName && (
@@ -235,7 +240,7 @@ function PickStep({
- Cancel
+ {t("dialog.cancel")}
>
@@ -257,6 +262,7 @@ function ConfigureStep({
onBack?: () => void;
onDone: () => void;
}) {
+ const { t } = useTranslation("impersonation");
const [reason, setReason] = useState("");
const [minutes, setMinutes] = useState(15);
@@ -273,8 +279,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 +289,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 +299,7 @@ function ConfigureStep({
- // Duration
+ {t("dialog.configure.duration")}
{DURATION_OPTIONS.map((opt) => {
const active = minutes === opt.minutes;
@@ -313,7 +319,7 @@ function ConfigureStep({
{opt.minutes}
-
minutes
+
{t("dialog.configure.minutes")}
);
})}
@@ -325,7 +331,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 +340,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 +352,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 +377,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 +407,7 @@ function SelectedUserCard({
tenantId: string;
tenantName?: string;
}) {
+ const { t } = useTranslation("impersonation");
return (
- {labelFor(user)}
+ {labelFor(user, t)}
{user.userName && (
@{user.userName}
@@ -456,11 +460,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..e6b4d179f3 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, 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,37 @@ 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 once the profile
+ // loads, so a locale chosen on another device carries over on this one.
+ const persistedLocale = profile.data?.locale;
+ useEffect(() => {
+ 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).
+ const onSelectLanguage = (tag: string) => {
+ void i18n.changeLanguage(tag);
+ updateProfile.mutate({ locale: tag });
+ };
const onConfirmSignOut = () => {
setConfirmOpen(false);
@@ -189,7 +256,7 @@ export function Topbar() {
- Theme
+ {t("theme.title")}
setTheme("light")}
/>
setTheme("dark")}
/>
@@ -271,19 +338,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 +382,7 @@ export function Topbar() {
className="!my-0 cursor-pointer rounded-md !px-2.5 !py-1.5"
>
- Sign out
+ {t("actions.signOut")}
@@ -308,11 +392,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 +421,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..e354e1069d
--- /dev/null
+++ b/clients/admin/tests/i18n/format.spec.ts
@@ -0,0 +1,60 @@
+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 () => {
+ const m = await import("/src/lib/format.ts");
+ 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..fdfe88ed8f
--- /dev/null
+++ b/clients/admin/tests/i18n/parity.spec.ts
@@ -0,0 +1,22 @@
+import { expect, test } from "@playwright/test";
+import { 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", () => {
+ 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/dashboard/package-lock.json b/clients/dashboard/package-lock.json
index cfe3247a57..929e96dec1 100644
--- a/clients/dashboard/package-lock.json
+++ b/clients/dashboard/package-lock.json
@@ -18,10 +18,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "i18next": "^26.3.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+ "react-i18next": "^17.0.10",
"react-router-dom": "^7.15.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
@@ -282,6 +285,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",
@@ -4815,6 +4827,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",
@@ -6183,6 +6241,33 @@
"react": "^19.2.6"
}
},
+ "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",
@@ -7000,7 +7085,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",
@@ -7163,6 +7248,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",
@@ -7238,6 +7332,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/dashboard/package.json b/clients/dashboard/package.json
index 0ccc2d8485..13fbbdf435 100644
--- a/clients/dashboard/package.json
+++ b/clients/dashboard/package.json
@@ -22,10 +22,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "i18next": "^26.3.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+ "react-i18next": "^17.0.10",
"react-router-dom": "^7.15.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
diff --git a/clients/dashboard/public/config.json b/clients/dashboard/public/config.json
index 2245e87ae2..5af9d5c9b3 100644
--- a/clients/dashboard/public/config.json
+++ b/clients/dashboard/public/config.json
@@ -3,5 +3,6 @@
"defaultTenant": "root",
"demoMode": true,
"inactivityIdleMs": 1200000,
- "inactivityWarningMs": 60000
+ "inactivityWarningMs": 60000,
+ "defaultLanguage": "en-US"
}
diff --git a/clients/dashboard/src/api/identity.ts b/clients/dashboard/src/api/identity.ts
index 3297c8c18a..3c2e0903d0 100644
--- a/clients/dashboard/src/api/identity.ts
+++ b/clients/dashboard/src/api/identity.ts
@@ -16,6 +16,7 @@ export type UserDto = {
phoneNumber?: string;
imageUrl?: string;
twoFactorEnabled?: boolean;
+ locale?: string | null;
};
export type UserRoleDto = {
@@ -394,6 +395,8 @@ export type UpdateProfileInput = {
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;
};
/**
@@ -401,7 +404,9 @@ export type UpdateProfileInput = {
* server-side. Image and email changes go through their own dedicated
* endpoints — this is for the editable profile fields surfaced in
* settings/profile. Reads the current profile first so unset optional
- * fields keep their existing values instead of being nulled.
+ * fields keep their existing values instead of being nulled — the backend
+ * sets FirstName/LastName unconditionally from the command, so a locale-only
+ * save (the language switcher) would otherwise wipe the names.
*/
export async function updateMyProfile(input: UpdateProfileInput): Promise {
const profile = await getMyProfile();
@@ -412,6 +417,7 @@ export async function updateMyProfile(input: UpdateProfileInput): Promise
firstName: input.firstName ?? profile.firstName ?? null,
lastName: input.lastName ?? profile.lastName ?? null,
phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null,
+ locale: input.locale ?? profile.locale ?? null,
email: profile.email,
deleteCurrentImage: false,
}),
diff --git a/clients/dashboard/src/auth/protected-route.tsx b/clients/dashboard/src/auth/protected-route.tsx
index 2b43eed164..f7633149c5 100644
--- a/clients/dashboard/src/auth/protected-route.tsx
+++ b/clients/dashboard/src/auth/protected-route.tsx
@@ -1,7 +1,9 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { useAuth } from "@/auth/use-auth";
export function ProtectedRoute() {
+ const { t } = useTranslation("common");
const { isAuthenticated, isInitializing } = useAuth();
const location = useLocation();
@@ -15,7 +17,7 @@ export function ProtectedRoute() {
role="status"
aria-busy="true"
>
- Restoring your session…
+ {t("session.restoring")}
{/* Atmospheric background — three rose/saffron blur orbs at
@@ -77,7 +79,7 @@ export function AuthShell({
- .NET 10 Starter Kit
+ {t("shell.tagline")}
@@ -95,10 +97,10 @@ export function AuthShell({
- Encrypted in transit · JWT-secured session
+ {t("shell.encrypted")}
- fullstackhero Administration
+ {t("shell.footer")}
diff --git a/clients/dashboard/src/components/auth/demo-accounts-dialog.tsx b/clients/dashboard/src/components/auth/demo-accounts-dialog.tsx
index db5391adca..4b8a195686 100644
--- a/clients/dashboard/src/components/auth/demo-accounts-dialog.tsx
+++ b/clients/dashboard/src/components/auth/demo-accounts-dialog.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { ArrowUpRight } from "lucide-react";
import {
Dialog,
@@ -30,6 +31,7 @@ interface DemoAccountsDialogProps {
}
export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsDialogProps) {
+ const { t } = useTranslation("auth");
const tenants = DEMO_ACCOUNT_GROUPS;
const [activeIdx, setActiveIdx] = useState(0);
@@ -48,9 +50,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
return (
- Demo accounts
+ {t("demo.dialogTitle")}
- Pick a demo tenant and account to sign in with.
+ {t("demo.dialogDescription")}
{/* Atmospheric gradients */}
@@ -71,14 +73,14 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
- Live demo
+ {t("demo.liveDemo")}
- Step into any role.
+ {t("demo.title")}
- Explore fullstackhero as any user across the demo tenants — we'll sign you in instantly.
+ {t("demo.subtitle")}
@@ -96,9 +98,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
{/* Footer */}
- demo only
+ {t("demo.demoOnly")}
·
- Resets with every reseed.
+ {t("demo.resets")}
esc
@@ -122,10 +124,11 @@ function TenantRail({
activeIdx: number;
onSelect: (idx: number) => void;
}) {
+ const { t } = useTranslation("auth");
return (
{tenants.map((tenant, i) => {
const isActive = i === activeIdx;
@@ -165,7 +168,7 @@ function TenantRail({
{tenant.tenantLabel}
- {tenant.accounts.length} users
+ {t("demo.userCount", { count: tenant.accounts.length })}
@@ -184,15 +187,16 @@ function UserPane({
tenant: DemoTenant;
onPick: (account: DemoAccount) => void;
}) {
+ const { t } = useTranslation("auth");
return (
- Users
+ {t("demo.users")}
- tap to sign in
+ {t("demo.tapToSignIn")}
diff --git a/clients/dashboard/src/components/auth/inactivity-dialog.tsx b/clients/dashboard/src/components/auth/inactivity-dialog.tsx
index fbee548cb4..cde62e8dcf 100644
--- a/clients/dashboard/src/components/auth/inactivity-dialog.tsx
+++ b/clients/dashboard/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/dashboard/src/components/command-palette/command-palette-dialog.tsx b/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx
index f05a195946..f247d9d35b 100644
--- a/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx
+++ b/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx
@@ -1,4 +1,5 @@
import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Command } from "cmdk";
import {
@@ -79,6 +80,7 @@ export function CommandPaletteDialog({
open: boolean;
onOpenChange: (next: boolean) => void;
}) {
+ const { t } = useTranslation("commandPalette");
const navigate = useNavigate();
const { user, logout } = useAuth();
const { setMode, setAccent } = useTheme();
@@ -101,28 +103,28 @@ export function CommandPaletteDialog({
};
const allGroups: ActionGroup[] = [
{
- heading: "Navigate",
+ heading: t("group.navigate"),
items: [
{
id: "nav-overview",
- label: "Overview",
- hint: "Tenant telemetry & usage",
+ label: t("nav.overview.label"),
+ hint: t("nav.overview.hint"),
Icon: LayoutDashboard,
keywords: ["home", "dashboard"],
perform: go("/"),
},
{
id: "nav-activity",
- label: "Live activity",
- hint: "Real-time event stream",
+ label: t("nav.activity.label"),
+ hint: t("nav.activity.hint"),
Icon: Activity,
keywords: ["events", "sse", "log"],
perform: go("/activity"),
},
{
id: "nav-chat",
- label: "Chat",
- hint: "Channels & direct messages",
+ label: t("nav.chat.label"),
+ hint: t("nav.chat.hint"),
Icon: MessageSquare,
keywords: ["messages", "dm", "channel", "conversation"],
perform: go("/chat"),
@@ -130,8 +132,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-files",
- label: "Files",
- hint: "My uploaded assets",
+ label: t("nav.files.label"),
+ hint: t("nav.files.hint"),
Icon: Folder,
keywords: ["storage", "uploads", "documents"],
perform: go("/files"),
@@ -139,8 +141,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-users",
- label: "Users",
- hint: "Identity directory",
+ label: t("nav.users.label"),
+ hint: t("nav.users.hint"),
Icon: Users,
keywords: ["identity", "people", "members", "team"],
perform: go("/identity/users"),
@@ -148,8 +150,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-roles",
- label: "Roles",
- hint: "Permissions & role assignment",
+ label: t("nav.roles.label"),
+ hint: t("nav.roles.hint"),
Icon: ShieldCheck,
keywords: ["identity", "permissions", "rbac"],
perform: go("/identity/roles"),
@@ -157,8 +159,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-groups",
- label: "Groups",
- hint: "Org groups & membership",
+ label: t("nav.groups.label"),
+ hint: t("nav.groups.hint"),
Icon: Users,
keywords: ["identity", "teams", "org"],
perform: go("/identity/groups"),
@@ -166,8 +168,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-products",
- label: "Products",
- hint: "Catalog inventory",
+ label: t("nav.products.label"),
+ hint: t("nav.products.hint"),
Icon: Package,
keywords: ["catalog", "sku", "inventory", "stock"],
perform: go("/catalog/products"),
@@ -175,8 +177,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-brands",
- label: "Brands",
- hint: "Catalog brands",
+ label: t("nav.brands.label"),
+ hint: t("nav.brands.hint"),
Icon: Tag,
keywords: ["catalog"],
perform: go("/catalog/brands"),
@@ -184,8 +186,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-categories",
- label: "Categories",
- hint: "Catalog categories",
+ label: t("nav.categories.label"),
+ hint: t("nav.categories.hint"),
Icon: Boxes,
keywords: ["catalog"],
perform: go("/catalog/categories"),
@@ -193,8 +195,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-tickets",
- label: "Tickets",
- hint: "Support requests",
+ label: t("nav.tickets.label"),
+ hint: t("nav.tickets.hint"),
Icon: LifeBuoy,
keywords: ["support", "issues", "helpdesk"],
perform: go("/tickets"),
@@ -202,8 +204,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-invoices",
- label: "Invoices",
- hint: "Billing history",
+ label: t("nav.invoices.label"),
+ hint: t("nav.invoices.hint"),
Icon: Receipt,
keywords: ["billing", "payment"],
perform: go("/invoices"),
@@ -211,16 +213,16 @@ export function CommandPaletteDialog({
},
{
id: "nav-health",
- label: "Health",
- hint: "Readiness probe & dependencies",
+ label: t("nav.health.label"),
+ hint: t("nav.health.hint"),
Icon: HeartPulse,
keywords: ["status", "uptime", "system", "ready", "redis", "postgres"],
perform: go("/system/health"),
},
{
id: "nav-audits",
- label: "Audit trail",
- hint: "Activity, security, entity-change events",
+ label: t("nav.audits.label"),
+ hint: t("nav.audits.hint"),
Icon: ScrollText,
keywords: ["audit", "log", "compliance", "security", "trace", "correlation"],
perform: go("/system/audits"),
@@ -228,8 +230,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-trash",
- label: "Trash",
- hint: "Soft-deleted records",
+ label: t("nav.trash.label"),
+ hint: t("nav.trash.hint"),
Icon: ScrollText,
keywords: ["recycle", "deleted", "restore"],
perform: go("/system/trash"),
@@ -237,8 +239,8 @@ export function CommandPaletteDialog({
},
{
id: "nav-sessions",
- label: "Sessions",
- hint: "Active user sessions",
+ label: t("nav.sessions.label"),
+ hint: t("nav.sessions.hint"),
Icon: Shield,
keywords: ["devices", "logins"],
perform: go("/system/sessions"),
@@ -246,7 +248,7 @@ export function CommandPaletteDialog({
},
{
id: "nav-settings",
- label: "Settings",
+ label: t("nav.settings.label"),
Icon: SettingsIcon,
keywords: ["preferences", "config"],
perform: go("/settings"),
@@ -254,12 +256,12 @@ export function CommandPaletteDialog({
],
},
{
- heading: "Create",
+ heading: t("group.create"),
items: [
{
id: "create-user",
- label: "Create user",
- hint: "Register a new account",
+ label: t("create.user.label"),
+ hint: t("create.user.hint"),
Icon: Plus,
keywords: ["new", "invite", "register", "identity"],
perform: go("/identity/users?action=create"),
@@ -267,8 +269,8 @@ export function CommandPaletteDialog({
},
{
id: "create-role",
- label: "Create role",
- hint: "Define a new permission set",
+ label: t("create.role.label"),
+ hint: t("create.role.hint"),
Icon: Plus,
keywords: ["new", "permissions", "rbac"],
perform: go("/identity/roles?action=create"),
@@ -276,8 +278,8 @@ export function CommandPaletteDialog({
},
{
id: "create-group",
- label: "Create group",
- hint: "Organize members",
+ label: t("create.group.label"),
+ hint: t("create.group.hint"),
Icon: Plus,
keywords: ["new", "team", "org"],
perform: go("/identity/groups?action=create"),
@@ -285,8 +287,8 @@ export function CommandPaletteDialog({
},
{
id: "create-product",
- label: "Create product",
- hint: "Add to catalog",
+ label: t("create.product.label"),
+ hint: t("create.product.hint"),
Icon: Plus,
keywords: ["new", "catalog", "sku"],
perform: go("/catalog/products?action=create"),
@@ -294,8 +296,8 @@ export function CommandPaletteDialog({
},
{
id: "create-brand",
- label: "Create brand",
- hint: "Add a catalog brand",
+ label: t("create.brand.label"),
+ hint: t("create.brand.hint"),
Icon: Plus,
keywords: ["new", "catalog"],
perform: go("/catalog/brands?action=create"),
@@ -303,8 +305,8 @@ export function CommandPaletteDialog({
},
{
id: "create-category",
- label: "Create category",
- hint: "Add a catalog category",
+ label: t("create.category.label"),
+ hint: t("create.category.hint"),
Icon: Plus,
keywords: ["new", "catalog"],
perform: go("/catalog/categories?action=create"),
@@ -312,8 +314,8 @@ export function CommandPaletteDialog({
},
{
id: "create-ticket",
- label: "Create ticket",
- hint: "File a support request",
+ label: t("create.ticket.label"),
+ hint: t("create.ticket.hint"),
Icon: Plus,
keywords: ["new", "support", "issue"],
perform: go("/tickets?action=create"),
@@ -321,8 +323,8 @@ export function CommandPaletteDialog({
},
{
id: "create-channel",
- label: "Create chat channel",
- hint: "Start a new conversation space",
+ label: t("create.channel.label"),
+ hint: t("create.channel.hint"),
Icon: Plus,
keywords: ["new", "chat", "channel"],
perform: go("/chat?action=create-channel"),
@@ -330,8 +332,8 @@ export function CommandPaletteDialog({
},
{
id: "create-file",
- label: "Upload file",
- hint: "Add to your storage",
+ label: t("create.file.label"),
+ hint: t("create.file.hint"),
Icon: Plus,
keywords: ["new", "upload", "attach"],
perform: go("/files?action=upload"),
@@ -340,42 +342,42 @@ export function CommandPaletteDialog({
],
},
{
- heading: "Account",
+ heading: t("group.account"),
items: [
{
id: "acc-profile",
- label: "Profile",
- hint: "Name, email, contact",
+ label: t("account.profile.label"),
+ hint: t("account.profile.hint"),
Icon: UserRound,
perform: go("/settings/profile"),
},
{
id: "acc-security",
- label: "Security",
- hint: "Password, 2FA, sessions",
+ label: t("account.security.label"),
+ hint: t("account.security.hint"),
Icon: Shield,
keywords: ["password", "2fa", "sessions"],
perform: go("/settings/security"),
},
{
id: "acc-keys",
- label: "API keys",
- hint: "Generate & rotate",
+ label: t("account.keys.label"),
+ hint: t("account.keys.hint"),
Icon: KeyRound,
keywords: ["token", "credentials"],
perform: go("/settings/api-keys"),
},
{
id: "acc-notifications",
- label: "Notifications",
- hint: "Email preferences",
+ label: t("account.notifications.label"),
+ hint: t("account.notifications.hint"),
Icon: Sparkles,
perform: go("/settings/notifications"),
},
{
id: "acc-appearance",
- label: "Appearance",
- hint: "Theme, accent, font, density",
+ label: t("account.appearance.label"),
+ hint: t("account.appearance.hint"),
Icon: Palette,
keywords: ["theme", "font", "density", "dark", "light"],
perform: go("/settings/appearance"),
@@ -383,25 +385,25 @@ export function CommandPaletteDialog({
],
},
{
- heading: "Theme",
+ heading: t("group.theme"),
items: [
{
id: "theme-light",
- label: "Switch to light",
+ label: t("theme.light"),
Icon: Sun,
keywords: ["bright", "day"],
perform: () => setMode("light"),
},
{
id: "theme-dark",
- label: "Switch to dark",
+ label: t("theme.dark"),
Icon: Moon,
keywords: ["night", "oled"],
perform: () => setMode("dark"),
},
{
id: "theme-system",
- label: "Follow system theme",
+ label: t("theme.system"),
Icon: Monitor,
keywords: ["auto"],
perform: () => setMode("system"),
@@ -409,10 +411,10 @@ export function CommandPaletteDialog({
],
},
{
- heading: "Accent",
+ heading: t("group.accent"),
items: accents.map((a) => ({
id: `accent-${a.id}`,
- label: `Set accent: ${a.label}`,
+ label: t("accent.set", { name: a.label }),
hint: a.description,
Icon: Palette,
keywords: ["color", "brand", a.id],
@@ -420,12 +422,12 @@ export function CommandPaletteDialog({
})),
},
{
- heading: "Session",
+ heading: t("group.session"),
items: [
{
id: "sess-logout",
- label: "Sign out",
- hint: "End this session",
+ label: t("session.logout.label"),
+ hint: t("session.logout.hint"),
Icon: LogOut,
keywords: ["logout", "exit", "quit"],
perform: () => {
@@ -441,7 +443,7 @@ export function CommandPaletteDialog({
return allGroups
.map((g) => ({ ...g, items: g.items.filter(visible) }))
.filter((g) => g.items.length > 0);
- }, [navigate, onOpenChange, setMode, setAccent, logout, permissions]);
+ }, [navigate, onOpenChange, setMode, setAccent, logout, permissions, t]);
return (
@@ -451,9 +453,9 @@ export function CommandPaletteDialog({
"bg-[var(--color-popover)]",
)}
>
- Command palette
+ {t("ui.srTitle")}
- Search across pages, account actions, theme and accent. Use arrow keys to navigate; Enter to select.
+ {t("ui.srDescription")}
- No matches
+ {t("ui.emptyTitle")}
- Try a different keyword — page name, entity, theme, accent, or sign-out.
+ {t("ui.emptyBody")}
@@ -512,11 +514,11 @@ export function CommandPaletteDialog({
↑
↓
- navigate
+ {t("ui.navigate")}
↵
- select
+ {t("ui.select")}
diff --git a/clients/dashboard/src/components/file/file-dropzone.tsx b/clients/dashboard/src/components/file/file-dropzone.tsx
index 7ed0934e1d..137d8a6e5e 100644
--- a/clients/dashboard/src/components/file/file-dropzone.tsx
+++ b/clients/dashboard/src/components/file/file-dropzone.tsx
@@ -1,4 +1,6 @@
import { useCallback, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
import { AlertCircle, CheckCircle2, CloudUpload, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -25,6 +27,7 @@ type Props = {
* the user can keep dropping files without leaving the surface.
*/
export function FileDropzone({ options, onUploaded, disabled, accept, className }: Props) {
+ const { t } = useTranslation("files");
const inputRef = useRef(null);
const [dragOver, setDragOver] = useState(false);
const { upload, progress, isUploading, reset, cancel } = useFileUpload(options);
@@ -46,7 +49,7 @@ export function FileDropzone({ options, onUploaded, disabled, accept, className
// then reset so the surface is immediately ready for the next file.
// The user explicitly chose this continuous-flow over the "Upload
// another" success card — they were finding the extra click friction.
- toast.success("File uploaded", {
+ toast.success(t("dropzone.toastUploaded"), {
description: `${asset.originalFileName} · ${formatBytes(asset.sizeBytes)}`,
});
reset();
@@ -57,7 +60,7 @@ export function FileDropzone({ options, onUploaded, disabled, accept, className
if (inputRef.current) inputRef.current.value = "";
}
},
- [upload, onUploaded, reset],
+ [upload, onUploaded, reset, t],
);
const onDrop = useCallback(
@@ -121,10 +124,10 @@ export function FileDropzone({ options, onUploaded, disabled, accept, className
- {captionFor(status, progress?.fileName)}
+ {captionFor(status, t, progress?.fileName)}
- {detailFor(status, progress, options)}
+ {detailFor(status, progress, options, t)}
@@ -143,7 +146,7 @@ export function FileDropzone({ options, onUploaded, disabled, accept, className
}}
>
- Dismiss
+ {t("dropzone.dismiss")}
)}
@@ -159,7 +162,7 @@ export function FileDropzone({ options, onUploaded, disabled, accept, className
reset();
}}
>
- Cancel
+ {t("dropzone.cancel")}
)}
@@ -196,20 +199,20 @@ function DropzoneIcon({ status }: { status: string | undefined }) {
);
}
-function captionFor(status: string | undefined, fileName?: string): string {
+function captionFor(status: string | undefined, t: TFunction, fileName?: string): string {
switch (status) {
case "preparing":
- return "Preparing upload…";
+ return t("dropzone.caption.preparing");
case "uploading":
- return `Uploading ${fileName ?? "…"}`;
+ return fileName ? t("dropzone.caption.uploading", { name: fileName }) : t("dropzone.caption.uploadingFallback");
case "finalizing":
- return "Finalizing…";
+ return t("dropzone.caption.finalizing");
case "done":
- return `Uploaded ${fileName ?? "file"}`;
+ return fileName ? t("dropzone.caption.done", { name: fileName }) : t("dropzone.caption.doneFallback");
case "error":
- return "Upload failed";
+ return t("dropzone.caption.error");
default:
- return "Drop a file or click to browse";
+ return t("dropzone.caption.idle");
}
}
@@ -217,28 +220,33 @@ function detailFor(
status: string | undefined,
progress: ReturnType["progress"],
options: UploadOptions,
+ t: TFunction,
): string {
if (status === "error" && progress?.error) return progress.error;
if (status === "done" && progress?.fileAsset) {
return `${progress.fileAsset.contentType} · ${formatBytes(progress.fileAsset.sizeBytes)}`;
}
if (status === "uploading" && progress) {
- return `${formatBytes(progress.loaded)} of ${formatBytes(progress.totalBytes)}`;
+ return t("dropzone.detail.uploading", {
+ loaded: formatBytes(progress.loaded),
+ total: formatBytes(progress.totalBytes),
+ });
}
if (options.allowedExtensions && options.allowedExtensions.length > 0) {
const ext = options.allowedExtensions.join(", ");
- const cap = options.maxBytes ? ` · up to ${formatBytes(options.maxBytes)}` : "";
- return `Allowed: ${ext}${cap}`;
+ const cap = options.maxBytes ? t("dropzone.detail.upTo", { size: formatBytes(options.maxBytes) }) : "";
+ return t("dropzone.detail.allowed", { ext }) + cap;
}
- return typeof options.category === "string" ? options.category : "Drop a file";
+ return typeof options.category === "string" ? options.category : t("dropzone.detail.dropAFile");
}
function ProgressBar({ percent, loaded, total }: { percent: number; loaded: number; total: number }) {
+ const { t } = useTranslation("files");
return (
deleteFile(fileAssetId!),
onSuccess: () => {
- toast.success("File deleted");
+ toast.success(t("preview.toastDeleted"));
onDeleted?.(fileAssetId!);
},
onError: (err) => {
@@ -86,7 +89,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }:
err instanceof ApiRequestError
? (err.problem?.detail ?? err.problem?.title ?? err.message)
: (err as Error).message;
- toast.error("Delete failed", { description: detail });
+ toast.error(t("preview.toastDeleteFailed"), { description: detail });
setConfirmingDelete(false);
},
});
@@ -107,8 +110,8 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }:
metaQuery.refetch();
toast.success(
dto.visibility === Visibility.Public
- ? "File is now public to your tenant"
- : "File is now private",
+ ? t("preview.toastNowPublic")
+ : t("preview.toastNowPrivate"),
);
},
onError: (err) => {
@@ -116,7 +119,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }:
err instanceof ApiRequestError
? (err.problem?.detail ?? err.problem?.title ?? err.message)
: (err as Error).message;
- toast.error("Visibility change failed", { description: detail });
+ toast.error(t("preview.toastVisibilityFailed"), { description: detail });
},
});
@@ -149,7 +152,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }:
- {metaQuery.data?.originalFileName ?? "File"}
+ {metaQuery.data?.originalFileName ?? t("preview.fallbackName")}
{metaQuery.data && (
@@ -164,7 +167,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }:
message={
metaQuery.error instanceof ApiRequestError
? (metaQuery.error.problem?.detail ?? metaQuery.error.message)
- : "Couldn't load file metadata."
+ : t("preview.errorMeta")
}
/>
) : !metaQuery.data ? (
@@ -196,7 +199,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }:
confirmingDelete ? (
- Delete this file?
+ {t("preview.confirmDelete")}
setConfirmingDelete(false)}
disabled={deleteMutation.isPending}
>
- Cancel
+ {t("preview.cancel")}
)}
- {deleteMutation.isPending ? "Deleting…" : "Confirm delete"}
+ {deleteMutation.isPending ? t("preview.deleting") : t("preview.confirmDeleteButton")}
) : (
@@ -228,7 +231,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }:
className="text-[var(--color-destructive)] hover:bg-[oklch(from_var(--color-destructive)_l_c_h_/_0.08)] hover:text-[var(--color-destructive)]"
>
- Delete
+ {t("preview.delete")}
)
) : (
@@ -241,7 +244,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }:
)}
- Close
+ {t("preview.close")}
@@ -259,6 +262,7 @@ function Preview({
downloadUrl?: string;
onUrlError: () => void;
}) {
+ const { t } = useTranslation("files");
// For private files we need the presigned URL; for public we use the durable URL.
const url = file.publicUrl ?? downloadUrl ?? null;
const isAvailable = file.status === FileAssetStatus.Available;
@@ -269,8 +273,8 @@ function Preview({
{file.status === FileAssetStatus.PendingUpload
- ? "Upload not yet finalized."
- : "This file is quarantined and cannot be previewed."}
+ ? t("preview.notFinalized")
+ : t("preview.quarantined")}
);
@@ -284,7 +288,7 @@ function Preview({
return (
- Preview link expired or unreachable.
+ {t("preview.expired")}
- Retry
+ {t("preview.retry")}
);
@@ -331,12 +335,12 @@ function Preview({
@@ -344,6 +348,7 @@ function Preview({
}
function TextPreview({ url }: { url: string }) {
+ const { t } = useTranslation("files");
const [text, setText] = useState
(null);
const [err, setErr] = useState(null);
@@ -356,7 +361,7 @@ function TextPreview({ url }: { url: string }) {
const t = await res.text();
if (!cancelled) setText(t.slice(0, 64 * 1024)); // cap at 64 KiB to keep the modal snappy
} catch (e) {
- if (!cancelled) setErr(e instanceof Error ? e.message : "Failed to fetch");
+ if (!cancelled) setErr(e instanceof Error ? e.message : t("preview.fetchFailed"));
}
})();
return () => {
@@ -365,7 +370,7 @@ function TextPreview({ url }: { url: string }) {
}, [url]);
if (err) {
- return ;
+ return ;
}
if (text === null) {
return ;
@@ -391,21 +396,22 @@ function MetadataPanel({
// Uploader name resolved via the existing identity cache. For files older than
// the createdByUserId rollout this comes back empty — fall back to a hyphen so
// the row reads cleanly rather than as a broken loader.
+ const { t } = useTranslation("files");
const uploader = useUserDisplay(file.createdByUserId || null);
const uploaderLabel = file.createdByUserId
? uploader.loading
- ? "Loading…"
+ ? t("preview.meta.uploaderLoading")
: uploader.name
: "—";
const rows: Array<[string, React.ReactNode, string?]> = [
- ["File ID", {file.id}, file.id],
- ["Owner type", file.ownerType, file.ownerType],
- ["Uploaded by", uploaderLabel, uploaderLabel],
- ["Content type", file.contentType, file.contentType],
- ["Size", formatBytes(file.sizeBytes), undefined],
- ["Status", statusLabel(file.status), undefined],
- ["Created", new Date(file.createdAtUtc).toLocaleString(), undefined],
+ [t("preview.meta.fileId"), {file.id}, file.id],
+ [t("preview.meta.ownerType"), file.ownerType, file.ownerType],
+ [t("preview.meta.uploadedBy"), uploaderLabel, uploaderLabel],
+ [t("preview.meta.contentType"), file.contentType, file.contentType],
+ [t("preview.meta.size"), formatBytes(file.sizeBytes), undefined],
+ [t("preview.meta.status"), statusLabel(file.status, t), undefined],
+ [t("preview.meta.created"), new Date(file.createdAtUtc).toLocaleString(), undefined],
];
const isPublic = file.visibility === Visibility.Public;
@@ -418,7 +424,7 @@ function MetadataPanel({
- Visibility
+ {t("preview.meta.visibility")}
- {isPublic ? "Public" : "Private"}
+ {isPublic ? t("visibility.public") : t("visibility.private")}
{isPublic
- ? "Everyone in your tenant can find this file under Shared."
- : "Only you can preview or download this file."}
+ ? t("preview.meta.publicHint")
+ : t("preview.meta.privateHint")}
{isUploader ? (
@@ -441,11 +447,11 @@ function MetadataPanel({
onChangeVisibility(checked ? Visibility.Public : Visibility.Private)
}
disabled={visibilityPending}
- aria-label="Toggle public visibility"
+ aria-label={t("preview.meta.toggleAria")}
/>
) : (
- Read-only
+ {t("preview.meta.readOnly")}
)}
@@ -470,6 +476,7 @@ function MetadataPanel({
// private files so we don't reuse the inline URL the iframe is consuming. For public
// files there's no inline/attachment distinction — both buttons use the same publicUrl.
function DownloadButton({ file }: { file: FileAssetDto }) {
+ const { t } = useTranslation("files");
const [busy, setBusy] = useState(false);
const handle = async () => {
if (busy) return;
@@ -489,21 +496,21 @@ function DownloadButton({ file }: { file: FileAssetDto }) {
return (
{busy ? : }
- Download
+ {t("preview.download")}
);
}
-function statusLabel(status: FileAssetStatusValue): string {
+function statusLabel(status: FileAssetStatusValue, t: TFunction): string {
switch (status) {
case FileAssetStatus.PendingUpload:
- return "Pending upload";
+ return t("preview.status.pending");
case FileAssetStatus.Available:
- return "Available";
+ return t("preview.status.available");
case FileAssetStatus.Quarantined:
- return "Quarantined";
+ return t("preview.status.quarantined");
default:
- return `Unknown (${status})`;
+ return t("preview.status.unknown", { status });
}
}
diff --git a/clients/dashboard/src/components/file/image-input.tsx b/clients/dashboard/src/components/file/image-input.tsx
index 204d3c6a1d..a525b2a28d 100644
--- a/clients/dashboard/src/components/file/image-input.tsx
+++ b/clients/dashboard/src/components/file/image-input.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { Image as ImageIcon, Loader2, Upload, X, Link as LinkIcon } from "lucide-react";
import { toast } from "sonner";
@@ -45,6 +46,7 @@ export function ImageInput({
shape = "square",
className,
}: Props) {
+ const { t } = useTranslation("files");
const [mode, setMode] = useState<"upload" | "url">("upload");
const { upload, progress, isUploading, reset } = useFileUpload({
ownerType,
@@ -60,7 +62,7 @@ export function ImageInput({
mutationFn: async (fileAssetId: string) => {
const dto = await getFileMetadata(fileAssetId);
if (!dto.publicUrl) {
- throw new Error("Server returned no publicUrl for this file.");
+ throw new Error(t("imageInput.noPublicUrl"));
}
return dto.publicUrl;
},
@@ -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.toastUploaded"));
// 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);
}
};
@@ -108,10 +110,10 @@ export function ImageInput({
{/* Mode toggle */}
setMode("upload")} icon={ }>
- Upload
+ {t("imageInput.upload")}
setMode("url")} icon={ }>
- Paste URL
+ {t("imageInput.pasteUrl")}
@@ -144,12 +146,12 @@ export function ImageInput({
{isWorking
?
: }
- {showImage ? "Replace image" : "Choose image"}
+ {showImage ? t("imageInput.replace") : t("imageInput.choose")}
{showImage && !isWorking && (
onChange("")}>
- Remove
+ {t("imageInput.remove")}
)}
{isUploading && progress && (
@@ -163,15 +165,15 @@ export function ImageInput({
type="url"
value={value}
onChange={(e) => onChange(e.target.value)}
- placeholder="https://…"
+ placeholder={t("imageInput.urlPlaceholder")}
maxLength={512}
/>
)}
{mode === "upload"
- ? `JPG/PNG/WebP/GIF · up to ${formatBytes(maxBytes)}`
- : "Direct link to an image you host elsewhere."}
+ ? t("imageInput.hintUpload", { size: formatBytes(maxBytes) })
+ : t("imageInput.hintUrl")}
diff --git a/clients/dashboard/src/components/file/product-image-manager.tsx b/clients/dashboard/src/components/file/product-image-manager.tsx
index f8091f84c9..eb8eae5b3e 100644
--- a/clients/dashboard/src/components/file/product-image-manager.tsx
+++ b/clients/dashboard/src/components/file/product-image-manager.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Star, StarOff, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
@@ -43,6 +44,7 @@ type Props = {
* - Clicking an image opens a fullscreen preview modal.
*/
export function ProductImageManager({ productId, images, invalidateKey, className }: Props) {
+ const { t } = useTranslation("files");
const queryClient = useQueryClient();
const [previewId, setPreviewId] = useState(null);
const [pendingRemove, setPendingRemove] = useState(null);
@@ -63,27 +65,27 @@ export function ProductImageManager({ productId, images, invalidateKey, classNam
void queryClient.invalidateQueries({ queryKey: invalidateKey });
},
onError: (e: unknown) => {
- toast.error(extract(e, "Failed to attach image"));
+ toast.error(extract(e, t("pim.errAttach")));
},
});
const thumbnailMutation = useMutation({
mutationFn: (imageId: string) => setProductThumbnail(productId, imageId),
onSuccess: () => {
- toast.success("Cover image updated");
+ toast.success(t("pim.toastCoverUpdated"));
void queryClient.invalidateQueries({ queryKey: invalidateKey });
},
- onError: (e: unknown) => toast.error(extract(e, "Failed to set cover")),
+ onError: (e: unknown) => toast.error(extract(e, t("pim.errSetCover"))),
});
const removeMutation = useMutation({
mutationFn: (imageId: string) => removeProductImage(productId, imageId),
onSuccess: () => {
- toast.success("Image removed");
+ toast.success(t("pim.toastImageRemoved"));
void queryClient.invalidateQueries({ queryKey: invalidateKey });
setPendingRemove(null);
},
- onError: (e: unknown) => toast.error(extract(e, "Failed to remove image")),
+ onError: (e: unknown) => toast.error(extract(e, t("pim.errRemove"))),
});
const handlePick = () => {
@@ -99,11 +101,11 @@ export function ProductImageManager({ productId, images, invalidateKey, classNam
const asset = await upload(file);
const meta = await getFileMetadata(asset.id);
if (!meta.publicUrl) {
- throw new Error("Server returned no publicUrl for the uploaded image.");
+ throw new Error(t("pim.noPublicUrl"));
}
await attachMutation.mutateAsync({ fileAssetId: asset.id, url: meta.publicUrl });
} catch (e) {
- toast.error(extract(e, `Upload failed: ${file.name}`));
+ toast.error(extract(e, t("pim.errUploadNamed", { name: file.name })));
}
}
reset();
@@ -122,7 +124,7 @@ export function ProductImageManager({ productId, images, invalidateKey, classNam
{isUploading || attachMutation.isPending
?
: }
- Upload images
+ {t("pim.upload")}
{progress && progress.status !== "done" && (
@@ -130,14 +132,14 @@ export function ProductImageManager({ productId, images, invalidateKey, classNam
)}
- {sorted.length} image{sorted.length === 1 ? "" : "s"} · JPG / PNG / WebP / GIF · up to 10 MB
+ {t("pim.count", { count: sorted.length })}
{/* Gallery grid */}
{sorted.length === 0 ? (
- No images yet. Upload one to set the product's cover.
+ {t("pim.empty")}
) : (
@@ -178,6 +180,7 @@ function ImageTile({
onRemove: () => void;
busy: boolean;
}) {
+ const { t } = useTranslation("files");
return (
- Cover
+ {t("pim.cover")}
)}
@@ -219,8 +222,8 @@ function ImageTile({
onSetThumbnail();
}}
disabled={busy}
- title="Set as cover"
- aria-label="Set as cover"
+ title={t("pim.setCover")}
+ aria-label={t("pim.setCover")}
className="bg-[var(--color-overlay)] text-[var(--color-overlay-foreground)] hover:bg-[oklch(0_0_0/0.65)]"
>
@@ -235,8 +238,8 @@ function ImageTile({
onRemove();
}}
disabled={busy}
- title="Remove image"
- aria-label="Remove image"
+ title={t("pim.removeImage")}
+ aria-label={t("pim.removeImage")}
className="bg-[var(--color-overlay)] text-[var(--color-overlay-foreground)] hover:bg-[var(--color-destructive)]"
>
@@ -247,13 +250,14 @@ function ImageTile({
}
function PreviewDialog({ image, onClose }: { image: ProductImageDto | null; onClose: () => void }) {
+ const { t } = useTranslation("files");
return (
(o ? undefined : onClose())}>
- Image preview
+ {t("pim.previewTitle")}
- {image?.isThumbnail ? "Current cover image." : "Click outside to close."}
+ {image?.isThumbnail ? t("pim.currentCover") : t("pim.clickOutside")}
{image && (
@@ -267,7 +271,7 @@ function PreviewDialog({ image, onClose }: { image: ProductImageDto | null; onCl
)}
- Close
+ {t("pim.close")}
@@ -286,28 +290,29 @@ function RemoveDialog({
onConfirm: () => void;
busy: boolean;
}) {
+ const { t } = useTranslation("files");
return (
(o ? undefined : onCancel())}>
- Remove image
+ {t("pim.removeImage")}
- Detach this image?
+ {t("pim.detachTitle")}
- The image is removed from this product. {image?.isThumbnail
- ? "It's currently the cover — another image will be promoted automatically."
+ {t("pim.detachBody")}{image?.isThumbnail
+ ? t("pim.detachCoverNote")
: ""}
- Cancel
+ {t("pim.cancel")}
- {busy ? "Removing…" : "Remove"}
+ {busy ? t("pim.removing") : t("pim.remove")}
diff --git a/clients/dashboard/src/components/identity/user-picker.tsx b/clients/dashboard/src/components/identity/user-picker.tsx
index 1b83b98f28..88b5fd5751 100644
--- a/clients/dashboard/src/components/identity/user-picker.tsx
+++ b/clients/dashboard/src/components/identity/user-picker.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Loader2, Search, UserCheck, X } from "lucide-react";
import { Input } from "@/components/ui/input";
@@ -26,7 +27,7 @@ export function UserPicker({
value,
onChange,
initialSelected,
- placeholder = "Search by name or email…",
+ placeholder,
disabled,
}: {
value: string | null;
@@ -35,6 +36,7 @@ export function UserPicker({
placeholder?: string;
disabled?: boolean;
}) {
+ const { t } = useTranslation("tickets");
const [selected, setSelected] = useState(initialSelected ?? null);
const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");
@@ -128,11 +130,11 @@ export function UserPicker({
variant="ghost"
size="sm"
onClick={clear}
- aria-label="Clear selection"
+ aria-label={t("userPicker.clearSelection")}
className="shrink-0"
>
- Clear
+ {t("userPicker.clear")}
)}
@@ -145,7 +147,7 @@ export function UserPicker({
/>
{
setQuery(e.target.value);
@@ -169,14 +171,14 @@ export function UserPicker({
{resultsQuery.isFetching && results.length === 0 ? (
- Searching for "{debounced}"…
+ {t("userPicker.searching", { term: debounced })}
) : results.length === 0 ? (
- No users match "{debounced}".
+ {t("userPicker.noMatch", { term: debounced })}
) : (
-
+
{results.map((u) => (
@@ -32,7 +34,7 @@ export function AppShell() {
"focus:ring-[var(--color-ring)] focus:ring-offset-2 focus:ring-offset-[var(--color-background)]",
)}
>
- Skip to main content
+ {t("skipToContent")}
diff --git a/clients/dashboard/src/components/layout/expiry-banner.tsx b/clients/dashboard/src/components/layout/expiry-banner.tsx
index 902cf58311..341a003860 100644
--- a/clients/dashboard/src/components/layout/expiry-banner.tsx
+++ b/clients/dashboard/src/components/layout/expiry-banner.tsx
@@ -1,8 +1,10 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Clock, X } from "lucide-react";
import { getMyStatus, type TenantStatusDto } from "@/api/billing";
import { useAuth } from "@/auth/use-auth";
+import { formatDate } from "@/lib/list-helpers";
import { cn } from "@/lib/cn";
// Days within which an "Active" subscription nearing its validUpto starts
@@ -14,7 +16,7 @@ type BannerView =
| {
kind: "grace";
daysLeft: number;
- graceEndsLabel: string;
+ graceEndsUtc: string | null;
}
| { kind: "expired" }
| {
@@ -22,12 +24,6 @@ type BannerView =
daysLeft: number;
};
-const dateFmt = new Intl.DateTimeFormat("en-US", {
- month: "short",
- day: "2-digit",
- year: "numeric",
-});
-
/**
* Whole calendar-ish days from `now` until `iso`, never negative. Uses a
* ceil so "23 hours left" still reads as "1 day".
@@ -48,9 +44,7 @@ function deriveBannerView(
return {
kind: "grace",
daysLeft: daysUntil(status.graceEndsUtc, now),
- graceEndsLabel: status.graceEndsUtc
- ? dateFmt.format(new Date(status.graceEndsUtc))
- : "soon",
+ graceEndsUtc: status.graceEndsUtc ?? null,
};
}
@@ -69,10 +63,6 @@ function deriveBannerView(
return { kind: "none" };
}
-function pluralizeDays(n: number): string {
- return `${n} day${n === 1 ? "" : "s"}`;
-}
-
/**
* Global subscription health bar. Shows a warning while the tenant is in
* its post-expiry grace window, and a softer info note when an active
@@ -80,6 +70,7 @@ function pluralizeDays(n: number): string {
* session — it reappears on reload while the condition still holds.
*/
export function ExpiryBanner() {
+ const { t } = useTranslation("common");
const { user } = useAuth();
const [dismissed, setDismissed] = useState(false);
@@ -109,10 +100,15 @@ export function ExpiryBanner() {
: "var(--color-info)";
const Icon = isExpired || isGrace ? AlertTriangle : Clock;
const message = isExpired
- ? "Your subscription has expired. Contact your operator to renew and restore full access."
+ ? t("expiryBanner.expired")
: isGrace
- ? `Your subscription expired — ${pluralizeDays(view.daysLeft)} of grace left (until ${view.graceEndsLabel}). Contact your operator to renew.`
- : `Your subscription expires in ${pluralizeDays(view.daysLeft)}.`;
+ ? t("expiryBanner.grace", {
+ days: t("expiryBanner.days", { count: view.daysLeft }),
+ date: view.graceEndsUtc ? formatDate(view.graceEndsUtc) : t("expiryBanner.graceSoon"),
+ })
+ : t("expiryBanner.nearing", {
+ days: t("expiryBanner.days", { count: view.daysLeft }),
+ });
return (
setDismissed(true)}
- aria-label="Dismiss subscription notice"
- title="Dismiss"
+ aria-label={t("expiryBanner.dismissNotice")}
+ title={t("expiryBanner.dismiss")}
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors hover:bg-[oklch(from_var(--color-foreground)_l_c_h_/_0.06)]"
style={{ color: tone }}
>
diff --git a/clients/dashboard/src/components/layout/impersonation-banner.tsx b/clients/dashboard/src/components/layout/impersonation-banner.tsx
index 73a77a5511..46f649e586 100644
--- a/clients/dashboard/src/components/layout/impersonation-banner.tsx
+++ b/clients/dashboard/src/components/layout/impersonation-banner.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { ArrowRight, LogOut, ShieldAlert, UserCog } from "lucide-react";
@@ -25,6 +26,7 @@ import { cn } from "@/lib/cn";
* left ribbon, slightly bolder copy).
*/
export function ImpersonationBanner() {
+ const { t } = useTranslation("common");
const { impersonation, user, stopImpersonation } = useAuth();
const navigate = useNavigate();
const [pending, setPending] = useState(false);
@@ -43,16 +45,16 @@ export function ImpersonationBanner() {
// admin app) → land on /login. Otherwise the operator's own session was
// restored → back to the dashboard home.
if (result.signedOut) {
- toast.success("Impersonation ended");
+ toast.success(t("impersonationBanner.toastEnded"));
navigate("/login", { replace: true });
} else {
- toast.success("Returned to your session");
+ toast.success(t("impersonationBanner.toastReturned"));
navigate("/", { replace: true });
}
},
onError: (err) => {
- toast.error("Could not end impersonation cleanly", {
- description: err instanceof Error ? err.message : "Restored local session.",
+ toast.error(t("impersonationBanner.toastErrorTitle"), {
+ description: err instanceof Error ? err.message : t("impersonationBanner.toastErrorDesc"),
});
navigate("/", { replace: true });
},
@@ -60,7 +62,7 @@ export function ImpersonationBanner() {
if (!impersonation) return null;
- const subjectLabel = user?.name ?? user?.email ?? "Unknown";
+ const subjectLabel = user?.name ?? user?.email ?? t("unknownUser");
const tenantLabel = user?.tenant ?? "—";
const actorLabel = impersonation.actorName ?? impersonation.actorUserId.slice(0, 8) + "…";
const actorTenantLabel = impersonation.actorTenant ?? "—";
@@ -71,8 +73,8 @@ export function ImpersonationBanner() {
// flip between warning / destructive without scattering conditionals.
const tone = isCrossTenant ? "var(--color-destructive)" : "var(--color-warning)";
const metaLabel = isCrossTenant
- ? "Cross-tenant impersonation"
- : "Impersonating";
+ ? t("impersonationBanner.crossTenant")
+ : t("impersonationBanner.impersonating");
return (
- · operator
+ · {t("impersonationBanner.operator")}
{actorLabel}
@@ -172,7 +174,7 @@ export function ImpersonationBanner() {
}}
>
- {pending ? "Ending…" : "End impersonation"}
+ {pending ? t("impersonationBanner.ending") : t("impersonationBanner.end")}
);
diff --git a/clients/dashboard/src/components/layout/mobile-nav.tsx b/clients/dashboard/src/components/layout/mobile-nav.tsx
index 5ac6f81630..9d3ee0781f 100644
--- a/clients/dashboard/src/components/layout/mobile-nav.tsx
+++ b/clients/dashboard/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 {
Sheet,
@@ -61,6 +62,7 @@ export function MobileNavProvider({ children }: { children: ReactNode }) {
* or any non-NavLink navigation while the drawer is open).
*/
export function MobileNavRoot() {
+ const { t } = useTranslation("common");
const { open, setOpen } = useMobileNav();
const location = useLocation();
@@ -87,9 +89,9 @@ export function MobileNavRoot() {
{/* Radix Dialog requires a Title for the accessible name; keep it
visually hidden so the drawer chrome is unchanged. */}
- Primary navigation
+ {t("nav.primary")}
- Site sections and account links.
+ {t("nav.primaryDescription")}
{/* Brand row — matches Topbar height so the drawer top aligns
with the rest of the chrome. */}
@@ -129,12 +131,13 @@ export function MobileNavRoot() {
* the first child so it sits at the leading edge on small screens).
*/
export function MobileNavTrigger({ className }: { className?: string }) {
+ const { t } = useTranslation("common");
const { setOpen } = useMobileNav();
const onClick = useCallback(() => setOpen(true), [setOpen]);
return (
;
/**
* Permission required to see this item. Items without a `perm` are visible to
@@ -43,7 +44,8 @@ export type NavSpec = {
export type NavSection = {
id: string;
- caption: string;
+ /** `common` namespace key resolved at render time. */
+ captionKey: string;
/** Section-level icon used as a fallback when the sidebar is
* collapsed and the section is rendered as a stack of item icons. */
icon: React.ComponentType<{ className?: string }>;
@@ -53,76 +55,76 @@ export type NavSection = {
// Top-level items live OUTSIDE any section. Overview opens the app;
// Settings is account-scoped and lives at the very bottom.
export const topNavTop: NavSpec[] = [
- { to: "/", label: "Overview", icon: LayoutDashboard },
+ { to: "/", labelKey: "nav.overview", icon: LayoutDashboard },
// Each gate mirrors the permission the page's primary list endpoint enforces
// server-side (Chat → channels list, Files → /files/mine). Same convention
// as trash-permissions.ts: if the endpoint's permission changes, mirror it.
- { to: "/chat", label: "Chat", icon: MessageCircle, perm: "Permissions.Chat.Channels.View" },
- { to: "/files", label: "My Files", icon: FolderOpen, perm: "Permissions.Files.Upload" },
+ { to: "/chat", labelKey: "nav.chat", icon: MessageCircle, perm: "Permissions.Chat.Channels.View" },
+ { to: "/files", labelKey: "nav.myFiles", icon: FolderOpen, perm: "Permissions.Files.Upload" },
];
export const topNavBottom: NavSpec[] = [
- { to: "/settings", label: "Settings", icon: Settings },
+ { to: "/settings", labelKey: "nav.settings", icon: Settings },
];
// Section accordion. Single-select — only one section open at a time.
export const sections: NavSection[] = [
{
id: "operations",
- caption: "Operations",
+ captionKey: "nav.section.operations",
icon: Activity,
items: [
// Live activity is SSE-backed; the stream is auth-only (no permission), so no gate.
- { to: "/activity", label: "Live activity", icon: Activity },
- { to: "/subscription", label: "Subscription", icon: CreditCard, perm: "Permissions.Billing.View" },
- { to: "/wallet", label: "WhatsApp wallet", icon: Wallet, perm: "Permissions.Billing.View" },
- { to: "/invoices", label: "Invoices", icon: Receipt, perm: "Permissions.Billing.View" },
+ { to: "/activity", labelKey: "nav.liveActivity", icon: Activity },
+ { to: "/subscription", labelKey: "nav.subscription", icon: CreditCard, perm: "Permissions.Billing.View" },
+ { to: "/wallet", labelKey: "nav.wallet", icon: Wallet, perm: "Permissions.Billing.View" },
+ { to: "/invoices", labelKey: "nav.invoices", icon: Receipt, perm: "Permissions.Billing.View" },
],
},
{
id: "catalog",
- caption: "Catalog",
+ captionKey: "nav.section.catalog",
icon: Package,
items: [
- { to: "/catalog/products", label: "Products", icon: Package, perm: "Permissions.Catalog.Products.View" },
- { to: "/catalog/brands", label: "Brands", icon: Tags, perm: "Permissions.Catalog.Brands.View" },
- { to: "/catalog/categories", label: "Categories", icon: FolderTree, perm: "Permissions.Catalog.Categories.View" },
+ { to: "/catalog/products", labelKey: "nav.products", icon: Package, perm: "Permissions.Catalog.Products.View" },
+ { to: "/catalog/brands", labelKey: "nav.brands", icon: Tags, perm: "Permissions.Catalog.Brands.View" },
+ { to: "/catalog/categories", labelKey: "nav.categories", icon: FolderTree, perm: "Permissions.Catalog.Categories.View" },
],
},
{
id: "helpdesk",
- caption: "Helpdesk",
+ captionKey: "nav.section.helpdesk",
icon: Ticket,
items: [
- { to: "/tickets", label: "Tickets", icon: Ticket, perm: "Permissions.Tickets.View" },
+ { to: "/tickets", labelKey: "nav.tickets", icon: Ticket, perm: "Permissions.Tickets.View" },
],
},
{
id: "identity",
- caption: "Identity",
+ captionKey: "nav.section.identity",
icon: Users,
items: [
// Gate the identity-management pages on a manage permission (not View): View Users/Roles/Groups
// are IsBasic so every member holds them (the chat/user picker relies on Users.View), but only
// managers should see these admin pages. Basic lacks the *.Update perms, so the items hide for them.
- { to: "/identity/users", label: "Users", icon: Users, perm: "Permissions.Users.Update" },
- { to: "/identity/roles", label: "Roles", icon: ShieldCheck, perm: "Permissions.Roles.Update" },
- { to: "/identity/groups", label: "Groups", icon: UsersRound, perm: "Permissions.Groups.Update" },
+ { to: "/identity/users", labelKey: "nav.users", icon: Users, perm: "Permissions.Users.Update" },
+ { to: "/identity/roles", labelKey: "nav.roles", icon: ShieldCheck, perm: "Permissions.Roles.Update" },
+ { to: "/identity/groups", labelKey: "nav.groups", icon: UsersRound, perm: "Permissions.Groups.Update" },
],
},
{
id: "system",
- caption: "System",
+ captionKey: "nav.section.system",
icon: HeartPulse,
items: [
// Health hits the anonymous /health/ready probe — visible to everyone.
- { to: "/system/health", label: "Health", icon: HeartPulse },
- { to: "/system/audits", label: "Audit trail", icon: ScrollText, perm: "Permissions.AuditTrails.View" },
- { to: "/system/sessions", label: "Sessions", icon: Wifi, perm: "Permissions.Sessions.ViewAll" },
+ { to: "/system/health", labelKey: "nav.health", icon: HeartPulse },
+ { to: "/system/audits", labelKey: "nav.audits", icon: ScrollText, perm: "Permissions.AuditTrails.View" },
+ { to: "/system/sessions", labelKey: "nav.sessions", icon: Wifi, perm: "Permissions.Sessions.ViewAll" },
// Trash fronts five tabs, each gated on a different resource's restore /
// view-trash permission. Show the entry if the user can reach any tab; the
// page hides the individual tabs they can't (see trash-permissions.ts).
- { to: "/system/trash", label: "Trash", icon: Trash2, anyPerm: ALL_TRASH_PERMISSIONS },
+ { to: "/system/trash", labelKey: "nav.trash", icon: Trash2, anyPerm: ALL_TRASH_PERMISSIONS },
],
},
];
diff --git a/clients/dashboard/src/components/layout/sidebar.tsx b/clients/dashboard/src/components/layout/sidebar.tsx
index 710705b04b..6a6d64a23e 100644
--- a/clients/dashboard/src/components/layout/sidebar.tsx
+++ b/clients/dashboard/src/components/layout/sidebar.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import {
ChevronDown,
PanelLeftClose,
@@ -44,6 +45,7 @@ function useCollapsedSidebar() {
}
export function Sidebar() {
+ const { t } = useTranslation("common");
const { collapsed, toggle } = useCollapsedSidebar();
const location = useLocation();
@@ -73,7 +75,7 @@ export function Sidebar() {
return (
hero
- Dashboard
+ {t("brand.dashboard")}
)}
@@ -114,9 +116,9 @@ export function Sidebar() {
void;
onNavigate?: () => void;
}) {
+ const { t } = useTranslation("common");
const SectionIcon = section.icon;
return (
-
{section.caption}
+
{t(section.captionKey)}
void;
}) {
+ const { t } = useTranslation("common");
const Icon = item.icon;
+ const label = t(item.labelKey);
return (
cn(
@@ -432,7 +437,7 @@ function NavItemLink({
{!collapsed && (
- {item.label}
+ {label}
)}
{/* Tooltip in collapsed mode — surfaces on hover OR keyboard
@@ -450,7 +455,7 @@ function NavItemLink({
"group-hover/nav:opacity-100 group-focus-visible/nav:opacity-100",
)}
>
- {item.label}
+ {label}
)}
>
diff --git a/clients/dashboard/src/components/layout/topbar.tsx b/clients/dashboard/src/components/layout/topbar.tsx
index 0383d28e30..c05492f9bb 100644
--- a/clients/dashboard/src/components/layout/topbar.tsx
+++ b/clients/dashboard/src/components/layout/topbar.tsx
@@ -1,10 +1,12 @@
import { useEffect, 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,
KeyRound,
+ Languages,
LogOut,
Monitor,
Moon,
@@ -36,7 +38,10 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar } from "@/components/ui/avatar";
-import { getMyProfile } from "@/api/identity";
+import { getMyProfile, updateMyProfile } from "@/api/identity";
+import { refreshAccessToken } from "@/lib/api-client";
+import { formatNumber } from "@/lib/list-helpers";
+import i18n, { SUPPORTED } from "@/i18n";
import { useAuth } from "@/auth/use-auth";
import { useSseStatus } from "@/sse/sse-context";
import { useTheme } from "@/components/theme/theme-provider";
@@ -119,6 +124,37 @@ function ThemeMenuItem({
);
}
+/** Language-pick row — mirrors ThemeMenuItem but keeps the menu open on
+ * select so the section label re-localizes in place instead of the menu
+ * closing before the switch is visible. */
+function LanguageMenuItem({
+ label,
+ active,
+ onSelect,
+}: {
+ label: string;
+ active: boolean;
+ onSelect: () => void;
+}) {
+ return (
+ {
+ e.preventDefault();
+ onSelect();
+ }}
+ className="!my-0 flex cursor-pointer items-center gap-2.5 rounded-md !px-2.5 !py-1.5"
+ >
+
+
+ {label}
+
+ {active && (
+
+ )}
+
+ );
+}
+
/** Simple icon + label menu item — used by the Account quick links. */
function SimpleMenuItem({
icon: Icon,
@@ -148,6 +184,9 @@ function SimpleMenuItem({
export function Topbar() {
const { user, logout } = useAuth();
+ // useTranslation subscribes this component to `languageChanged`, so the menu
+ // labels and the active-locale check re-render the instant we switch.
+ const { t } = useTranslation();
// Shared with the Profile settings page (same query key), so changing the
// photo there invalidates this and the topbar avatar updates live.
const { data: profile } = useQuery({
@@ -156,12 +195,43 @@ export function Topbar() {
staleTime: 5 * 60 * 1000,
});
const avatarUrl = profile?.imageUrl ?? null;
+
+ // Hydrate the UI language from the server-persisted locale once the profile
+ // loads, so a locale chosen on another device carries over on this one.
+ const persistedLocale = profile?.locale;
+ useEffect(() => {
+ if (persistedLocale && persistedLocale !== i18n.language) {
+ void i18n.changeLanguage(persistedLocale);
+ }
+ }, [persistedLocale]);
+
const { status: sseStatus, eventCount } = useSseStatus();
const { mode, setMode } = useTheme();
const { setOpen: setPaletteOpen } = useCommandPalette();
const navigate = useNavigate();
const [confirmOpen, setConfirmOpen] = useState(false);
+ const updateProfile = useMutation({
+ mutationFn: updateMyProfile,
+ onSuccess: () => {
+ // Re-mint the JWT so the fresh `locale` claim is issued. 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. The locale travels through the
+ // mutation argument (never closed-over state). updateMyProfile echoes the
+ // current name/phone from the profile it reads, so a locale-only save does
+ // not wipe them. We deliberately do NOT invalidate the profile query on
+ // success — a refetch would revert the language mid-switch.
+ const onSelectLanguage = (tag: string) => {
+ void i18n.changeLanguage(tag);
+ updateProfile.mutate({ locale: tag });
+ };
+
const onConfirmSignOut = () => {
setConfirmOpen(false);
logout();
@@ -171,19 +241,22 @@ export function Topbar() {
if (sseStatus === "connected") {
return {
color: "var(--color-success)",
- text: `Connected · ${new Intl.NumberFormat("en-US").format(eventCount)} events`,
+ text: t("common:presence.connected", {
+ count: eventCount,
+ formatted: formatNumber(eventCount),
+ }),
};
}
if (sseStatus === "error") {
- return { color: "var(--color-destructive)", text: "Stream offline" };
+ return { color: "var(--color-destructive)", text: t("common:presence.offline") };
}
if (sseStatus === "connecting") {
- return { color: "var(--color-muted-foreground)", text: "Connecting…" };
+ return { color: "var(--color-muted-foreground)", text: t("common:presence.connecting") };
}
if (sseStatus === "reconnecting") {
- return { color: "var(--color-warning)", text: "Reconnecting…" };
+ return { color: "var(--color-warning)", text: t("common:presence.reconnecting") };
}
- return { color: "var(--color-muted-foreground)", text: "Idle" };
+ return { color: "var(--color-muted-foreground)", text: t("common:presence.idle") };
})();
return (
@@ -207,7 +280,7 @@ export function Topbar() {
setPaletteOpen(true)}
- aria-label="Open command palette"
+ aria-label={t("common:commandPalette.open")}
className={cn(
"grid h-9 w-9 cursor-pointer place-items-center rounded-md md:hidden",
"text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-foreground)]",
@@ -223,7 +296,7 @@ export function Topbar() {
setPaletteOpen(true)}
- title="Open command palette"
+ title={t("common:commandPalette.open")}
className={cn(
"hidden h-8 cursor-pointer items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)] px-2.5 text-xs",
"text-[var(--color-muted-foreground)]",
@@ -233,7 +306,7 @@ export function Topbar() {
)}
>
- Search
+ {t("common:search")}
⌘K
@@ -260,7 +333,7 @@ export function Topbar() {
- {user?.name ?? user?.email ?? "Unknown"}
+ {user?.name ?? user?.email ?? t("common:unknownUser")}
{user?.tenant ?? "—"}
@@ -299,7 +372,7 @@ export function Topbar() {
{/* User info header — name + email, plain warm-paper */}
- {user?.name ?? user?.email ?? "Unknown"}
+ {user?.name ?? user?.email ?? t("common:unknownUser")}
{user?.email && user.name && (
@@ -325,24 +398,41 @@ export function Topbar() {
{/* Theme — three simple menu items with a check on the active one */}
- Theme
+ {t("common:theme.title")}
- setMode("light")} />
- setMode("dark")} />
- setMode("system")} />
+ setMode("light")} />
+ setMode("dark")} />
+ setMode("system")} />
+
+
+
+
+ {/* Language — one item per supported locale, check on the active one */}
+
+ {t("common:language")}
+
+
+ {SUPPORTED.map((tag) => (
+ onSelectLanguage(tag)}
+ />
+ ))}
{/* Account quick actions */}
- Account
+ {t("common:account.title")}
- navigate("/settings/profile")} />
- navigate("/settings")} />
- navigate("/settings/api-keys")} />
+ navigate("/settings/profile")} />
+ navigate("/settings")} />
+ navigate("/settings/api-keys")} />
@@ -355,7 +445,7 @@ export function Topbar() {
className="!my-0 cursor-pointer rounded-md !px-2.5 !py-1.5"
>
-
Sign out
+
{t("common:account.signOut")}
@@ -365,10 +455,9 @@ export function Topbar() {
- Sign out of fullstackhero?
+ {t("common:signOut.title")}
- You'll need to sign in again to access this tenant. Any unsaved
- work in this session will be lost.
+ {t("common:signOut.description")}
@@ -376,7 +465,7 @@ export function Topbar() {
- {user?.name ?? user?.email ?? "Unknown"}
+ {user?.name ?? user?.email ?? t("common:unknownUser")}
{user?.email && user.name && (
@@ -395,7 +484,7 @@ export function Topbar() {
size="sm"
onClick={() => setConfirmOpen(false)}
>
- Cancel
+ {t("common:signOut.cancel")}
- Sign out
+ {t("common:signOut.confirm")}
diff --git a/clients/dashboard/src/components/list/combobox.tsx b/clients/dashboard/src/components/list/combobox.tsx
index 2a1f3726e9..71f5d4f9b9 100644
--- a/clients/dashboard/src/components/list/combobox.tsx
+++ b/clients/dashboard/src/components/list/combobox.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
import { Check, ChevronDown, Search, X } from "lucide-react";
import {
DropdownMenu,
@@ -63,6 +64,7 @@ export function Combobox({
id?: string;
className?: string;
}) {
+ const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const inputRef = useRef
(null);
@@ -169,7 +171,7 @@ export function Combobox({
inputRef.current?.focus();
}}
className="grid h-5 w-5 cursor-pointer place-items-center rounded text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)] hover:text-[var(--color-foreground)]"
- aria-label="Clear filter"
+ aria-label={t("list.clearFilter")}
>
@@ -194,7 +196,7 @@ export function Combobox({
{filtered.length === 0 ? (
- No matches.
+ {t("list.noMatches")}
) : (
filtered.map((opt) => (
diff --git a/clients/dashboard/src/components/list/entity-shell.tsx b/clients/dashboard/src/components/list/entity-shell.tsx
index 5b373ef198..d05772c418 100644
--- a/clients/dashboard/src/components/list/entity-shell.tsx
+++ b/clients/dashboard/src/components/list/entity-shell.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/cn";
@@ -30,6 +31,7 @@ export function EntityPageHeader({
/** Action buttons rendered on the right (stack full-width on mobile). */
children?: React.ReactNode;
}) {
+ const { t } = useTranslation();
return (
@@ -41,7 +43,7 @@ export function EntityPageHeader({
{total !== undefined && total !== null && (
- {total} {total === 1 ? unit : `${unit}s`}
+ {t(`unit.${unit}`, { count: total })}
)}
@@ -75,13 +77,14 @@ export function EntitySearch({
placeholder?: string;
autoFocus?: boolean;
}) {
+ const { t } = useTranslation();
return (
onChange(e.target.value)}
autoFocus={autoFocus}
@@ -97,11 +100,11 @@ export function EntitySearch({
{value && (
onChange("")}
- aria-label="Clear search"
+ aria-label={t("list.clearSearch")}
className="absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer text-[11px] font-medium text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-foreground)]"
type="button"
>
- Clear
+ {t("list.clear")}
)}
@@ -172,18 +175,19 @@ export function EntityPager({
onPrev: () => void;
onNext: () => void;
}) {
+ const { t } = useTranslation();
if (totalPages <= 1) return null;
return (
- Page {page} of {totalPages}
+ {t("list.pageOf", { page, total: totalPages })}
@@ -192,7 +196,7 @@ export function EntityPager({
type="button"
disabled={!hasNext}
onClick={onNext}
- aria-label="Next page"
+ aria-label={t("list.nextPage")}
className="grid size-8 cursor-pointer place-items-center rounded-lg text-[var(--color-muted-foreground)] transition-colors hover:bg-[oklch(from_var(--color-muted)_l_c_h_/_0.5)] hover:text-[var(--color-foreground)] disabled:cursor-not-allowed disabled:opacity-30"
>
@@ -478,9 +482,10 @@ export function EntityListLoading({
desktopColumns: string;
mobile?: boolean;
}) {
+ const { t } = useTranslation();
return (
-
Loading…
+
{t("list.loading")}
{mobile && (
{Array.from({ length: rows }).map((_, i) => (
diff --git a/clients/dashboard/src/components/list/error-band.tsx b/clients/dashboard/src/components/list/error-band.tsx
index bece1ab583..26cc11c9c5 100644
--- a/clients/dashboard/src/components/list/error-band.tsx
+++ b/clients/dashboard/src/components/list/error-band.tsx
@@ -1,13 +1,16 @@
+import { useTranslation } from "react-i18next";
+
/**
* Inline error band displayed between the toolbar and the list when a
* query has failed. Picks up the destructive token + the mono-caps
* "failure ·" eyebrow used elsewhere in the dashboard.
*/
export function ErrorBand({ message }: { message: string }) {
+ const { t } = useTranslation("common");
return (
- Failure ·{" "}
+ {t("errorBand.failure")} ·{" "}
{message}
diff --git a/clients/dashboard/src/components/list/field.tsx b/clients/dashboard/src/components/list/field.tsx
index ae9c67d2dc..89294904a7 100644
--- a/clients/dashboard/src/components/list/field.tsx
+++ b/clients/dashboard/src/components/list/field.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
/**
@@ -20,6 +21,7 @@ export function Field({
required?: boolean;
children: React.ReactNode;
}) {
+ const { t } = useTranslation("common");
return (
·
- required
+ {t("field.required")}
>
)}
diff --git a/clients/dashboard/src/components/notifications/chat-global-notifier.tsx b/clients/dashboard/src/components/notifications/chat-global-notifier.tsx
index 54214eecc3..6e111f41af 100644
--- a/clients/dashboard/src/components/notifications/chat-global-notifier.tsx
+++ b/clients/dashboard/src/components/notifications/chat-global-notifier.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -83,6 +84,7 @@ function ChatToast({
onView: () => void;
onDismiss: () => void;
}) {
+ const { t } = useTranslation("notifications");
const queryClient = useQueryClient();
const author = useUserDisplay(payload.authorUserId);
// Read the channel from cache rather than firing a fresh fetch — the
@@ -92,12 +94,12 @@ function ChatToast({
const channel = channels?.find((c) => c.id === payload.channelId);
const channelLabel = !channel
- ? "a channel"
+ ? t("toast.aChannel")
: channel.type === ChannelType.Channel
- ? `#${channel.name ?? "channel"}`
+ ? `#${channel.name ?? t("toast.channelFallback")}`
: channel.type === ChannelType.DirectMessage
- ? "direct message"
- : "group chat";
+ ? t("toast.directMessage")
+ : t("toast.groupChat");
const ChannelIcon = !channel
? MessageCircle
: channel.type === ChannelType.Channel
@@ -141,7 +143,7 @@ function ChatToast({
- just now
+ {t("toast.justNow")}
@@ -193,7 +195,7 @@ function ChatToast({
) : (
- (attachment or empty body)
+ {t("toast.emptyBody")}
)}
@@ -207,8 +209,8 @@ function ChatToast({
e.stopPropagation();
onDismiss();
}}
- aria-label="Dismiss notification"
- title="Dismiss"
+ aria-label={t("toast.dismissAria")}
+ title={t("toast.dismiss")}
className={cn(
"absolute right-2 top-2 grid h-6 w-6 cursor-pointer place-items-center rounded-md",
"text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-foreground)]",
diff --git a/clients/dashboard/src/components/notifications/chat-unread-badge.tsx b/clients/dashboard/src/components/notifications/chat-unread-badge.tsx
index 2094f449a5..3bb38f5b28 100644
--- a/clients/dashboard/src/components/notifications/chat-unread-badge.tsx
+++ b/clients/dashboard/src/components/notifications/chat-unread-badge.tsx
@@ -1,4 +1,5 @@
import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { MessageCircle } from "lucide-react";
@@ -18,6 +19,7 @@ import { cn } from "@/lib/cn";
* - the composer invalidates it when the user sends
*/
export function ChatUnreadBadge() {
+ const { t } = useTranslation("notifications");
const navigate = useNavigate();
const { data: channels } = useQuery({
queryKey: ["chat", "my-channels"],
@@ -33,8 +35,8 @@ export function ChatUnreadBadge() {
return (
0 ? `, ${unread} unread message${unread === 1 ? "" : "s"}` : ""}`}
- title={unread > 0 ? `${unread} unread chat message${unread === 1 ? "" : "s"}` : "Chat"}
+ aria-label={unread > 0 ? t("badge.ariaUnread", { count: unread }) : t("badge.chat")}
+ title={unread > 0 ? t("badge.titleUnread", { count: unread }) : t("badge.chat")}
onClick={() => navigate("/chat")}
className={cn(
"relative grid h-9 w-9 cursor-pointer place-items-center rounded-md",
diff --git a/clients/dashboard/src/components/notifications/notification-bell.tsx b/clients/dashboard/src/components/notifications/notification-bell.tsx
index d6917f4d50..df008879b7 100644
--- a/clients/dashboard/src/components/notifications/notification-bell.tsx
+++ b/clients/dashboard/src/components/notifications/notification-bell.tsx
@@ -1,6 +1,8 @@
import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import i18n from "@/i18n";
import { Bell, Check, MessageCircle } from "lucide-react";
import {
getUnreadCount,
@@ -26,6 +28,7 @@ import { cn } from "@/lib/cn";
* without a refetch.
*/
export function NotificationBell() {
+ const { t } = useTranslation("notifications");
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -90,8 +93,8 @@ export function NotificationBell() {
0 ? `, ${unread} unread` : ""}`}
- title="Notifications"
+ aria-label={unread > 0 ? t("bell.ariaUnread", { count: unread }) : t("bell.aria")}
+ title={t("bell.title")}
className={cn(
"relative grid h-9 w-9 cursor-pointer place-items-center rounded-md",
"text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-foreground)]",
@@ -121,12 +124,12 @@ export function NotificationBell() {
- Notifications
+ {t("header.title")}
{unread === 0
- ? "All caught up"
- : `${unread} unread · ${inbox.length} loaded`}
+ ? t("header.allCaughtUp")
+ : t("header.summary", { unread, loaded: inbox.length })}
{unread > 0 && (
@@ -144,21 +147,21 @@ export function NotificationBell() {
)}
>
- Mark all read
+ {t("markAllRead")}
)}
- Recent
+ {t("recent")}
{inboxQuery.isLoading ? (
- Loading…
+ {t("loading")}
) : inbox.length === 0 ? (
- Nothing yet. Mentions and channel updates will appear here.
+ {t("empty")}
) : (
@@ -181,7 +184,7 @@ export function NotificationBell() {
}}
className="text-[11px] text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-foreground)]"
>
- Settings ↗
+ {t("settings")}
@@ -258,14 +261,14 @@ function NotificationRow({
function relativeTime(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime();
const seconds = Math.max(0, Math.round(diffMs / 1000));
- if (seconds < 60) return "just now";
+ if (seconds < 60) return i18n.t("notifications:rel.justNow");
const minutes = Math.round(seconds / 60);
- if (minutes < 60) return `${minutes}m`;
+ if (minutes < 60) return i18n.t("notifications:rel.minutes", { n: minutes });
const hours = Math.round(minutes / 60);
- if (hours < 24) return `${hours}h`;
+ if (hours < 24) return i18n.t("notifications:rel.hours", { n: hours });
const days = Math.round(hours / 24);
- if (days < 7) return `${days}d`;
+ if (days < 7) return i18n.t("notifications:rel.days", { n: days });
const weeks = Math.round(days / 7);
- if (weeks < 5) return `${weeks}w`;
- return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" });
+ if (weeks < 5) return i18n.t("notifications:rel.weeks", { n: weeks });
+ return new Date(iso).toLocaleDateString(i18n.language, { month: "short", day: "numeric" });
}
diff --git a/clients/dashboard/src/components/realtime/realtime-status-pill.tsx b/clients/dashboard/src/components/realtime/realtime-status-pill.tsx
index 64203c5fe8..bbe2f88a25 100644
--- a/clients/dashboard/src/components/realtime/realtime-status-pill.tsx
+++ b/clients/dashboard/src/components/realtime/realtime-status-pill.tsx
@@ -1,14 +1,7 @@
+import { useTranslation } from "react-i18next";
import { useRealtime } from "@/realtime/realtime-context";
import { cn } from "@/lib/cn";
-const LABEL: Record = {
- idle: "Offline",
- connecting: "Connecting",
- connected: "Live",
- reconnecting: "Reconnecting",
- error: "Offline",
-};
-
/**
* Compact connection-state indicator backed by the shared SignalR hub. Mono
* caption + colored dot — green when live, amber pulsing while reconnecting,
@@ -26,14 +19,22 @@ export function RealtimeStatusPill({
className?: string;
announce?: boolean;
}) {
+ const { t } = useTranslation("common");
const { status } = useRealtime();
- const label = LABEL[status] ?? "Offline";
+ const labels: Record = {
+ idle: t("realtimeStatus.offline"),
+ connecting: t("realtimeStatus.connecting"),
+ connected: t("realtimeStatus.live"),
+ reconnecting: t("realtimeStatus.reconnecting"),
+ error: t("realtimeStatus.offline"),
+ };
+ const label = labels[status] ?? t("realtimeStatus.offline");
return (
{label}
diff --git a/clients/dashboard/src/components/route-error.tsx b/clients/dashboard/src/components/route-error.tsx
index 505c847928..c928418809 100644
--- a/clients/dashboard/src/components/route-error.tsx
+++ b/clients/dashboard/src/components/route-error.tsx
@@ -1,3 +1,5 @@
+import { useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,17 +14,18 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
* exposing the call stack to end users gives nothing actionable and leaks internals.
*/
export function RouteError() {
+ const { t } = useTranslation();
const error = useRouteError();
const navigate = useNavigate();
- const { title, detail } = describe(error);
+ const { title, detail } = describe(error, t);
const showDetail = import.meta.env.DEV && detail;
return (
- Something went wrong
+ {t("routeError.title")}
{title}
{showDetail && (
@@ -33,15 +36,15 @@ export function RouteError() {
)}
- navigate(0)}>Reload
- navigate("/")}>Go home
+ navigate(0)}>{t("routeError.reload")}
+ navigate("/")}>{t("routeError.goHome")}
);
}
-function describe(error: unknown): { title: string; detail?: string } {
+function describe(error: unknown, t: TFunction): { title: string; detail?: string } {
if (isRouteErrorResponse(error)) {
return {
title: `${error.status} ${error.statusText}`,
@@ -51,5 +54,5 @@ function describe(error: unknown): { title: string; detail?: string } {
if (error instanceof Error) {
return { title: error.message, detail: error.stack };
}
- return { title: "Unexpected error", detail: String(error) };
+ return { title: t("routeError.unexpected"), detail: String(error) };
}
diff --git a/clients/dashboard/src/components/ui/dialog.tsx b/clients/dashboard/src/components/ui/dialog.tsx
index c97bd31bb5..a8831116b9 100644
--- a/clients/dashboard/src/components/ui/dialog.tsx
+++ b/clients/dashboard/src/components/ui/dialog.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { useTranslation } from "react-i18next";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/cn";
@@ -45,7 +46,9 @@ DialogOverlay.displayName = "DialogOverlay";
export const DialogContent = React.forwardRef<
React.ComponentRef,
React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
+>(({ className, children, ...props }, ref) => {
+ const { t } = useTranslation();
+ return (
-));
+ );
+});
DialogContent.displayName = "DialogContent";
export function DialogHeader({ className, ...props }: React.HTMLAttributes) {
@@ -153,7 +157,9 @@ export const SheetContent = React.forwardRef<
/** Render the built-in close button. Defaults to true. */
showClose?: boolean;
}
->(({ className, children, side = "right", showClose = true, ...props }, ref) => (
+>(({ className, children, side = "right", showClose = true, ...props }, ref) => {
+ const { t } = useTranslation();
+ return (
-));
+ );
+});
SheetContent.displayName = "SheetContent";
// Aliases — when authors prefer reading //
diff --git a/clients/dashboard/src/env.ts b/clients/dashboard/src/env.ts
index e56dd35480..e021c159db 100644
--- a/clients/dashboard/src/env.ts
+++ b/clients/dashboard/src/env.ts
@@ -15,6 +15,8 @@ type RuntimeConfig = {
inactivityIdleMs: number;
/** Warning-countdown length (ms) before auto sign-out. */
inactivityWarningMs: number;
+ /** Per-deployment default UI language (BCP 47); the i18n fallback when nothing is persisted/detected. */
+ defaultLanguage: string;
};
// Dashboard defaults: 20 minutes idle, then a 60-second warning.
@@ -41,6 +43,7 @@ export async function loadRuntimeConfig(): Promise {
demoMode: cfg.demoMode ?? false,
inactivityIdleMs: positiveOr(cfg.inactivityIdleMs, DEFAULT_INACTIVITY_IDLE_MS),
inactivityWarningMs: positiveOr(cfg.inactivityWarningMs, DEFAULT_INACTIVITY_WARNING_MS),
+ defaultLanguage: cfg.defaultLanguage ?? "en-US",
};
}
@@ -59,4 +62,5 @@ export const env = {
get demoMode(): boolean { return get().demoMode; },
get inactivityIdleMs(): number { return get().inactivityIdleMs; },
get inactivityWarningMs(): number { return get().inactivityWarningMs; },
+ get defaultLanguage(): string { return get().defaultLanguage; },
};
diff --git a/clients/dashboard/src/i18n.ts b/clients/dashboard/src/i18n.ts
new file mode 100644
index 0000000000..dc3018deb2
--- /dev/null
+++ b/clients/dashboard/src/i18n.ts
@@ -0,0 +1,107 @@
+import i18n from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { initReactI18next } from "react-i18next";
+import enCommon from "@/locales/en-US/common.json";
+import ptCommon from "@/locales/pt-BR/common.json";
+import enAuth from "@/locales/en-US/auth.json";
+import ptAuth from "@/locales/pt-BR/auth.json";
+import enSettings from "@/locales/en-US/settings.json";
+import ptSettings from "@/locales/pt-BR/settings.json";
+import enIdentity from "@/locales/en-US/identity.json";
+import ptIdentity from "@/locales/pt-BR/identity.json";
+import enOverview from "@/locales/en-US/overview.json";
+import ptOverview from "@/locales/pt-BR/overview.json";
+import enSubscription from "@/locales/en-US/subscription.json";
+import ptSubscription from "@/locales/pt-BR/subscription.json";
+import enActivity from "@/locales/en-US/activity.json";
+import ptActivity from "@/locales/pt-BR/activity.json";
+import enCatalog from "@/locales/en-US/catalog.json";
+import ptCatalog from "@/locales/pt-BR/catalog.json";
+import enTickets from "@/locales/en-US/tickets.json";
+import ptTickets from "@/locales/pt-BR/tickets.json";
+import enFiles from "@/locales/en-US/files.json";
+import ptFiles from "@/locales/pt-BR/files.json";
+import enAudits from "@/locales/en-US/audits.json";
+import ptAudits from "@/locales/pt-BR/audits.json";
+import enCommandPalette from "@/locales/en-US/commandPalette.json";
+import ptCommandPalette from "@/locales/pt-BR/commandPalette.json";
+import enNotifications from "@/locales/en-US/notifications.json";
+import ptNotifications from "@/locales/pt-BR/notifications.json";
+import enSystem from "@/locales/en-US/system.json";
+import ptSystem from "@/locales/pt-BR/system.json";
+import enChat from "@/locales/en-US/chat.json";
+import ptChat from "@/locales/pt-BR/chat.json";
+import enHealth from "@/locales/en-US/health.json";
+import ptHealth from "@/locales/pt-BR/health.json";
+
+// Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries).
+export const SUPPORTED = ["en-US", "pt-BR"] as const;
+
+type Catalog = Record;
+
+// Translation catalogs keyed by namespace, then by locale. To add a namespace
+// in a later wave: import its two JSON files and add one row here — `resources`,
+// the namespace list, and parity tests all derive from this single map, so no
+// other wiring changes.
+const catalogs: Record> = {
+ common: { "en-US": enCommon, "pt-BR": ptCommon },
+ auth: { "en-US": enAuth, "pt-BR": ptAuth },
+ settings: { "en-US": enSettings, "pt-BR": ptSettings },
+ identity: { "en-US": enIdentity, "pt-BR": ptIdentity },
+ overview: { "en-US": enOverview, "pt-BR": ptOverview },
+ subscription: { "en-US": enSubscription, "pt-BR": ptSubscription },
+ activity: { "en-US": enActivity, "pt-BR": ptActivity },
+ catalog: { "en-US": enCatalog, "pt-BR": ptCatalog },
+ tickets: { "en-US": enTickets, "pt-BR": ptTickets },
+ files: { "en-US": enFiles, "pt-BR": ptFiles },
+ audits: { "en-US": enAudits, "pt-BR": ptAudits },
+ commandPalette: { "en-US": enCommandPalette, "pt-BR": ptCommandPalette },
+ notifications: { "en-US": enNotifications, "pt-BR": ptNotifications },
+ system: { "en-US": enSystem, "pt-BR": ptSystem },
+ chat: { "en-US": enChat, "pt-BR": ptChat },
+ health: { "en-US": enHealth, "pt-BR": ptHealth },
+};
+
+export const NAMESPACES = Object.keys(catalogs);
+
+// Pivot the namespace-first map into i18next's locale-first `resources` shape:
+// { "en-US": { common: {…} }, "pt-BR": { common: {…} } }.
+const resources = Object.fromEntries(
+ SUPPORTED.map((lng) => [
+ lng,
+ Object.fromEntries(Object.entries(catalogs).map(([ns, byLng]) => [ns, byLng[lng]])),
+ ]),
+);
+
+// i18next's nonExplicitSupportedLngs does NOT rewrite pt-PT->pt-BR. Normalize explicitly via
+// convertDetectedLanguage: map any variant onto a canonical tag by its language part.
+const CANON: Record = { pt: "pt-BR", en: "en-US" };
+const toCanonical = (lng: string) =>
+ (SUPPORTED as readonly string[]).includes(lng) ? lng : (CANON[lng.split("-")[0]] ?? lng);
+
+// Called from main.tsx AFTER loadRuntimeConfig(), so fallbackLng reads the per-deployment
+// default: the browser/persisted locale wins, the deployment default is only the fallback.
+export function initI18n(deploymentDefault: string) {
+ return i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ fallbackLng: (SUPPORTED as readonly string[]).includes(deploymentDefault)
+ ? deploymentDefault
+ : "en-US",
+ supportedLngs: [...SUPPORTED],
+ ns: NAMESPACES,
+ defaultNS: "common",
+ interpolation: { escapeValue: false },
+ detection: {
+ // NO cookie — localStorage only (the library default; key i18nextLng).
+ order: ["querystring", "localStorage", "navigator"],
+ caches: ["localStorage"],
+ lookupQuerystring: "culture",
+ convertDetectedLanguage: toCanonical, // pt/pt-PT->pt-BR, en/en-GB->en-US
+ },
+ });
+}
+
+export default i18n;
diff --git a/clients/dashboard/src/lib/api-client.ts b/clients/dashboard/src/lib/api-client.ts
index 417eab0ca8..d5a3ed0138 100644
--- a/clients/dashboard/src/lib/api-client.ts
+++ b/clients/dashboard/src/lib/api-client.ts
@@ -1,4 +1,5 @@
import { env } from "@/env";
+import i18n from "@/i18n";
import { tokenStore } from "@/auth/token-store";
import { decodeJwt } from "@/auth/jwt";
@@ -186,6 +187,13 @@ export async function apiFetch(
mergedHeaders.set("tenant", tenant);
}
+ // Tell the backend which culture to localize responses in. The active UI
+ // locale drives it; the backend resolution chain still falls through to its
+ // own default for anything unsupported.
+ if (!mergedHeaders.has("Accept-Language")) {
+ mergedHeaders.set("Accept-Language", i18n.language || "en-US");
+ }
+
const url = path.startsWith("http") ? path : `${env.apiBase}${path}`;
const initialTimer = withTimeout({ ...rest, headers: mergedHeaders }, timeoutMs, signal);
let response: Response;
diff --git a/clients/dashboard/src/lib/list-helpers.ts b/clients/dashboard/src/lib/list-helpers.ts
index 186e63b044..36a01528cb 100644
--- a/clients/dashboard/src/lib/list-helpers.ts
+++ b/clients/dashboard/src/lib/list-helpers.ts
@@ -1,32 +1,60 @@
+import i18n from "@/i18n";
import { ApiRequestError } from "@/lib/api-client";
-const dateLong = new Intl.DateTimeFormat("en-US", {
- month: "short",
- day: "2-digit",
- year: "numeric",
-});
+// Formatters read the active UI locale by default; callers may pin an explicit
+// locale. Building an Intl formatter per row is wasteful in long ledgers and
+// the locale only changes on a language switch, so cache one formatter per
+// (locale) — the cache re-fills lazily under the new locale after a switch.
+const activeLocale = (locale?: string) => locale ?? i18n.language ?? "en-US";
-export function formatDate(iso: string | null | undefined) {
+const dateLongByLocale = new Map();
+function dateLongFor(locale: string) {
+ let fmt = dateLongByLocale.get(locale);
+ if (!fmt) {
+ fmt = new Intl.DateTimeFormat(locale, {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ });
+ dateLongByLocale.set(locale, fmt);
+ }
+ return fmt;
+}
+
+// "3:42 PM" — local wall-clock time. Intl renders in the browser's timezone.
+const timeShortByLocale = new Map();
+function timeShortFor(locale: string) {
+ let fmt = timeShortByLocale.get(locale);
+ if (!fmt) {
+ fmt = new Intl.DateTimeFormat(locale, {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ timeShortByLocale.set(locale, fmt);
+ }
+ return fmt;
+}
+
+export function formatDate(iso: string | null | undefined, locale?: string) {
if (!iso) return "—";
- return dateLong.format(new Date(iso));
+ return dateLongFor(activeLocale(locale)).format(new Date(iso));
}
// "APR 30 2026" — mono-caps tabular form for ledger/registry rows.
-export function formatDateMono(iso: string | null | undefined) {
+export function formatDateMono(iso: string | null | undefined, locale?: string) {
if (!iso) return "—";
- return dateLong.format(new Date(iso)).toUpperCase().replace(",", "");
+ return dateLongFor(activeLocale(locale)).format(new Date(iso)).toUpperCase().replace(",", "");
}
-// "3:42 PM" — local wall-clock time. Intl renders in the browser's timezone.
-const timeShort = new Intl.DateTimeFormat("en-US", {
- hour: "numeric",
- minute: "2-digit",
-});
-
// "APR 30 2026 · 3:42 PM" — date + local time for audit/detail panels.
-export function formatDateTimeMono(iso: string | null | undefined) {
+export function formatDateTimeMono(iso: string | null | undefined, locale?: string) {
if (!iso) return "—";
- return `${formatDateMono(iso)} · ${timeShort.format(new Date(iso))}`;
+ return `${formatDateMono(iso, locale)} · ${timeShortFor(activeLocale(locale)).format(new Date(iso))}`;
+}
+
+// Locale-grouped integer/decimal (e.g. en-US "1,234" · pt-BR "1.234").
+export function formatNumber(value: number, locale?: string) {
+ return new Intl.NumberFormat(activeLocale(locale)).format(value);
}
// "3d ago", "2mo ago" — terse relative time for the secondary line.
@@ -35,17 +63,17 @@ export function formatRelative(iso: string | null | undefined) {
const diffMs = Date.now() - new Date(iso).getTime();
if (Number.isNaN(diffMs) || diffMs < 0) return "";
const sec = Math.floor(diffMs / 1000);
- if (sec < 60) return "just now";
+ if (sec < 60) return i18n.t("relative.justNow");
const min = Math.floor(sec / 60);
- if (min < 60) return `${min}m ago`;
+ if (min < 60) return i18n.t("relative.minutesAgo", { n: min });
const hr = Math.floor(min / 60);
- if (hr < 24) return `${hr}h ago`;
+ if (hr < 24) return i18n.t("relative.hoursAgo", { n: hr });
const day = Math.floor(hr / 24);
- if (day < 30) return `${day}d ago`;
+ if (day < 30) return i18n.t("relative.daysAgo", { n: day });
const mo = Math.floor(day / 30);
- if (mo < 12) return `${mo}mo ago`;
+ if (mo < 12) return i18n.t("relative.monthsAgo", { n: mo });
const yr = Math.floor(day / 365);
- return `${yr}y ago`;
+ return i18n.t("relative.yearsAgo", { n: yr });
}
export function pad2(n: number) {
@@ -61,9 +89,9 @@ export function slugify(value: string) {
return s;
}
-export function formatMoney(amount: number, currency: string) {
+export function formatMoney(amount: number, currency: string, locale?: string) {
try {
- return new Intl.NumberFormat(undefined, {
+ return new Intl.NumberFormat(activeLocale(locale), {
style: "currency",
currency,
}).format(amount);
diff --git a/clients/dashboard/src/lib/ticket-enums.ts b/clients/dashboard/src/lib/ticket-enums.ts
index f3f388ef0c..f03e3e7fad 100644
--- a/clients/dashboard/src/lib/ticket-enums.ts
+++ b/clients/dashboard/src/lib/ticket-enums.ts
@@ -1,14 +1,15 @@
import type { TicketPriority, TicketStatus } from "@/api/tickets";
import type { EntityStatusTone } from "@/components/list";
-// Shared label + tone maps for ticket status/priority, used by the tickets list and
-// the ticket detail page so the two never drift.
+// Shared tone maps + i18n key maps for ticket status/priority, used by the tickets list
+// and the ticket detail page so the two never drift. Labels are resolved at each call
+// site via t(STATUS_LABEL_KEY[status]) against the "tickets" namespace.
-export const STATUS_LABEL: Record = {
- Open: "Open",
- InProgress: "In progress",
- Resolved: "Resolved",
- Closed: "Closed",
+export const STATUS_LABEL_KEY: Record = {
+ Open: "status.Open",
+ InProgress: "status.InProgress",
+ Resolved: "status.Resolved",
+ Closed: "status.Closed",
};
export const STATUS_TONE: Record = {
@@ -18,11 +19,11 @@ export const STATUS_TONE: Record = {
Closed: "default",
};
-export const PRIORITY_LABEL: Record = {
- Low: "Low",
- Medium: "Medium",
- High: "High",
- Critical: "Critical",
+export const PRIORITY_LABEL_KEY: Record = {
+ Low: "priority.Low",
+ Medium: "priority.Medium",
+ High: "priority.High",
+ Critical: "priority.Critical",
};
export const PRIORITY_TONE: Record = {
diff --git a/clients/dashboard/src/locales/en-US/activity.json b/clients/dashboard/src/locales/en-US/activity.json
new file mode 100644
index 0000000000..a9d9ce4848
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/activity.json
@@ -0,0 +1,21 @@
+{
+ "title": "Live activity",
+ "unit": "event",
+ "description": "Full event log streamed from the API over Server-Sent Events.",
+ "ariaLabel": "Activity events",
+ "status.streaming": "streaming",
+ "status.offline": "offline",
+ "status.idle": "idle",
+ "status.connecting": "connecting",
+ "status.reconnecting": "reconnecting",
+ "shown_one": "{{count}} event shown",
+ "shown_other": "{{count}} events shown",
+ "totalCount": "{{total}} total",
+ "empty.listeningTitle": "Listening for activity",
+ "empty.noEventsTitle": "No events yet",
+ "empty.listeningBody": "The stream is open. Events will appear here as the backend publishes them.",
+ "empty.offlineBody": "The activity stream is not connected. Events will queue once the connection comes online.",
+ "col.action": "Action",
+ "col.entity": "Entity",
+ "col.time": "Time"
+}
diff --git a/clients/dashboard/src/locales/en-US/audits.json b/clients/dashboard/src/locales/en-US/audits.json
new file mode 100644
index 0000000000..315cc22b79
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/audits.json
@@ -0,0 +1,93 @@
+{
+ "eventType.none": "Unknown",
+ "eventType.entity": "Entity",
+ "eventType.security": "Security",
+ "eventType.activity": "Activity",
+ "eventType.exception": "Exception",
+ "severity.none": "—",
+ "severity.trace": "Trace",
+ "severity.debug": "Debug",
+ "severity.info": "Info",
+ "severity.warn": "Warn",
+ "severity.error": "Error",
+ "severity.critical": "Critical",
+ "tag.piiMasked": "PII masked",
+ "tag.quotaExceeded": "Quota exceeded",
+ "tag.sampled": "Sampled",
+ "tag.retained": "Retained",
+ "tag.healthCheck": "Health check",
+ "tag.auth": "Auth",
+ "tag.authz": "Authz",
+ "page.title": "Audit trail",
+ "page.unit": "event",
+ "page.description": "Activity, security, entity-change, and exception events across the platform. Window enforced server-side; max 90 days.",
+ "page.refresh": "Refresh",
+ "page.searchPlaceholder": "Search payload, source, user…",
+ "page.count_one": "{{count}} event found",
+ "page.count_other": "{{count}} events found",
+ "page.loadError": "Failed to load audits",
+ "empty.title": "No audits in this window",
+ "empty.body": "Try widening the time range or relaxing the filters. Activity events arrive as soon as the platform handles a request.",
+ "empty.reset": "Reset filters",
+ "col.actor": "Actor",
+ "col.event": "Event",
+ "col.severity": "Severity",
+ "col.timestamp": "Timestamp",
+ "actor.system": "System",
+ "summary.window": "Window {{range}}",
+ "summary.events": "events",
+ "summary.legend.activity": "Activity",
+ "summary.legend.entity": "Entity",
+ "summary.legend.security": "Security",
+ "summary.legend.exception": "Exception",
+ "summary.severity.warn": "Warn",
+ "summary.severity.err": "Err",
+ "summary.topSources": "Top sources",
+ "filter.advanced": "Advanced",
+ "filter.hideSystemTitle": "Hide the per-request system Activity events (api.*) so only meaningful audits show",
+ "filter.hideSystem": "Hide system activity",
+ "filter.clearSearch": "Clear search",
+ "filter.reset": "Reset",
+ "filter.type": "Type",
+ "filter.severity": "Severity",
+ "filter.field.source": "Source",
+ "filter.field.sourcePlaceholder": "api.identity.RegisterUser",
+ "filter.field.user": "User ID",
+ "filter.field.userPlaceholder": "00000000-0000-…",
+ "filter.field.correlation": "Correlation",
+ "filter.field.correlationPlaceholder": "0HMxxxx…",
+ "filter.field.trace": "Trace",
+ "filter.field.tracePlaceholder": "hex traceparent",
+ "filter.tags": "Tags",
+ "drawer.srTitle": "Audit detail",
+ "drawer.srDescription": "Full payload, identifiers, and related actions for the selected audit event.",
+ "drawer.close": "Close",
+ "drawer.eventFallback": "Audit event",
+ "drawer.utc": "UTC",
+ "drawer.section.identity": "Identity",
+ "drawer.section.trace": "Trace",
+ "drawer.section.payload": "Payload",
+ "drawer.section.pipeline": "Pipeline",
+ "drawer.section.related": "Related events",
+ "drawer.field.tenant": "Tenant",
+ "drawer.field.user": "User",
+ "drawer.field.userId": "User ID",
+ "drawer.field.source": "Source",
+ "drawer.field.traceId": "Trace ID",
+ "drawer.field.spanId": "Span ID",
+ "drawer.field.correlationId": "Correlation ID",
+ "drawer.field.requestId": "Request ID",
+ "drawer.field.occurred": "Occurred",
+ "drawer.field.received": "Received",
+ "drawer.field.sinkDelay": "Sink delay",
+ "drawer.field.auditId": "Audit ID",
+ "drawer.allByCorrelation": "All by correlation",
+ "drawer.allByTrace": "All by trace",
+ "drawer.copy": "Copy",
+ "drawer.copied": "Copied",
+ "drawer.related.count": "{{count}} on this correlation",
+ "drawer.related.empty": "No other events share this correlation. The full lifecycle of this request is contained in the payload above.",
+ "drawer.related.thisEvent": "this event",
+ "drawer.error.title": "Could not load audit",
+ "drawer.error.body": "The server returned an error fetching this audit. The record may have been purged by the retention job."
+}
diff --git a/clients/dashboard/src/locales/en-US/auth.json b/clients/dashboard/src/locales/en-US/auth.json
new file mode 100644
index 0000000000..e48293302e
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/auth.json
@@ -0,0 +1,113 @@
+{
+ "shell.tagline": ".NET 10 Starter Kit",
+ "shell.encrypted": "Encrypted in transit · JWT-secured session",
+ "shell.footer": "fullstackhero Administration",
+
+ "login.title": "Welcome back",
+ "login.subtitle": "Sign in to your account",
+ "login.inactivityNotice": "You were signed out due to inactivity.",
+ "login.tenant": "Tenant",
+ "login.tenantPlaceholder": "root",
+ "login.email": "Email",
+ "login.emailPlaceholder": "name@example.com",
+ "login.password": "Password",
+ "login.passwordPlaceholder": "Enter your password",
+ "login.forgot": "Forgot?",
+ "login.errorFallback": "Login failed",
+ "login.showPassword": "Show password",
+ "login.hidePassword": "Hide password",
+ "login.submit": "Sign in",
+ "login.submitting": "Signing in…",
+ "login.demoButton": "Sign in with a demo account",
+
+ "demo.dialogTitle": "Demo accounts",
+ "demo.dialogDescription": "Pick a demo tenant and account to sign in with.",
+ "demo.liveDemo": "Live demo",
+ "demo.title": "Step into any role.",
+ "demo.subtitle": "Explore fullstackhero as any user across the demo tenants — we'll sign you in instantly.",
+ "demo.tenantsAria": "Demo tenants",
+ "demo.userCount_one": "{{count}} user",
+ "demo.userCount_other": "{{count}} users",
+ "demo.users": "Users",
+ "demo.tapToSignIn": "tap to sign in",
+ "demo.demoOnly": "demo only",
+ "demo.resets": "Resets with every reseed.",
+
+ "inactivity.seconds": "seconds",
+ "inactivity.title": "Still there?",
+ "inactivity.description": "You've been inactive for a while. We'll sign you out shortly to keep your account secure.",
+ "inactivity.signOutNow": "Sign out now",
+ "inactivity.stay": "I'm here",
+
+ "confirm.malformed": "This confirmation link is missing required parameters. It may have been clipped by your email client.",
+ "confirm.successFallback": "Your email is confirmed. You can now sign in.",
+ "confirm.backLink": "← Back to sign in",
+ "confirm.verifyingLead": "Verifying your",
+ "confirm.verifyingAccent": "email…",
+ "confirm.verifyingBody": "One moment — checking the confirmation token with the server.",
+ "confirm.successLead": "Email",
+ "confirm.successAccent": "confirmed",
+ "confirm.continueSignIn": "Continue to sign in",
+ "confirm.errorLead": "Couldn't",
+ "confirm.errorAccent": "confirm",
+ "confirm.errorTrail": " your email",
+ "confirm.errorHint": "The link may have expired or been used already. If you've signed in since this email was sent, you can ignore it.",
+ "confirm.backToSignIn": "Back to sign in",
+ "confirm.resetInstead": "Reset password instead",
+
+ "forgot.titleLead": "Reset your",
+ "forgot.titleAccent": "password",
+ "forgot.subtitle": "Enter the email you sign in with. We'll send a one-time link.",
+ "forgot.tenant": "Tenant",
+ "forgot.tenantPlaceholder": "root",
+ "forgot.email": "Email",
+ "forgot.emailPlaceholder": "you@example.com",
+ "forgot.submit": "Send reset link",
+ "forgot.submitting": "Sending link…",
+ "forgot.successLead": "Check your",
+ "forgot.successAccent": "inbox",
+ "forgot.successPre": "If an account exists for",
+ "forgot.successMid": "in tenant",
+ "forgot.successPost": ", a one-time reset link is on its way. The link expires in 30 minutes.",
+ "forgot.tip1": "Didn't get it? Wait a minute, then check spam.",
+ "forgot.tip2": "Still nothing? Confirm the email + tenant and try again.",
+ "forgot.tryDifferent": "Try a different address",
+ "forgot.backToSignIn": "Back to sign in",
+ "forgot.remembered": "Remembered it?",
+ "forgot.signIn": "Sign in",
+
+ "reset.incompleteLead": "This link is",
+ "reset.incompleteAccent": "incomplete",
+ "reset.incompletePre": "The reset link is missing one of",
+ "reset.fieldToken": "token",
+ "reset.fieldEmail": "email",
+ "reset.fieldTenant": "tenant",
+ "reset.or": "or",
+ "reset.incompletePost": "Some email clients clip long URLs — try copy-pasting the full link from the original email into your browser's address bar.",
+ "reset.requestNewLink": "Request a new link",
+ "reset.backToSignIn": "Back to sign in",
+ "reset.titleLead": "Set a new",
+ "reset.titleAccent": "password",
+ "reset.subtitlePre": "Resetting password for",
+ "reset.subtitleMid": "on",
+ "reset.subtitlePost": ".",
+ "reset.newPassword": "New password",
+ "reset.newPasswordPlaceholder": "At least 8 characters",
+ "reset.confirmPassword": "Confirm password",
+ "reset.confirmPasswordPlaceholder": "Re-enter password",
+ "reset.showPassword": "Show password",
+ "reset.hidePassword": "Hide password",
+ "reset.strength_weak": "Weak",
+ "reset.strength_fair": "Fair",
+ "reset.strength_strong": "Strong",
+ "reset.matches": "Passwords match",
+ "reset.notMatchYet": "Doesn't match yet",
+ "reset.errMismatch": "Passwords don't match.",
+ "reset.errTooShort": "Use at least 8 characters.",
+ "reset.submit": "Set new password",
+ "reset.submitting": "Updating password…",
+ "reset.toastTitle": "Password updated",
+ "reset.toastDesc": "Sign in with your new password to continue.",
+ "reset.changedMind": "Changed your mind?",
+ "reset.signIn": "Sign in"
+}
diff --git a/clients/dashboard/src/locales/en-US/catalog.json b/clients/dashboard/src/locales/en-US/catalog.json
new file mode 100644
index 0000000000..c07cfb6d9c
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/catalog.json
@@ -0,0 +1,204 @@
+{
+ "products.title": "Products",
+ "products.description": "Browse and manage the catalog. Each product carries a SKU, brand, category, price, and live stock count.",
+ "products.searchPlaceholder": "Search by name, SKU, or slug…",
+ "products.count_one": "{{count}} product found",
+ "products.count_other": "{{count}} products found",
+ "brands.title": "Brands",
+ "brands.unit": "brand",
+ "brands.description": "Curate the maker imprints behind every product. Each brand carries its own slug, story, and logo.",
+ "brands.searchPlaceholder": "Search by name or slug…",
+ "brands.count_one": "{{count}} brand found",
+ "brands.count_other": "{{count}} brands found",
+ "categories.title": "Categories",
+ "categories.unit": "category",
+ "categories.description": "Group products into shelves. Categories nest under parents to form the taxonomy customers browse.",
+ "categories.searchPlaceholder": "Search by name or slug…",
+ "categories.count_one": "{{count}} category found",
+ "categories.count_other": "{{count}} categories found",
+ "filter.brand": "Brand",
+ "filter.category": "Category",
+ "filter.activeLabel": "Active filter",
+ "filter.all": "All",
+ "filter.active": "Active",
+ "filter.hidden": "Hidden",
+ "col.product": "Product",
+ "col.sku": "SKU",
+ "col.brand": "Brand",
+ "col.price": "Price",
+ "col.slug": "Slug",
+ "col.created": "Created",
+ "col.category": "Category",
+ "badge.hidden": "Hidden",
+ "badge.active": "Active",
+ "aria.openProduct": "Open product {{name}}",
+ "aria.editProduct": "Edit {{name}}",
+ "aria.deleteProduct": "Delete {{name}}",
+ "aria.editBrand": "Edit brand {{name}}",
+ "aria.editCategory": "Edit category {{name}}",
+ "row.under": "under {{name}}",
+ "row.parentFallback": "(parent)",
+ "row.root": "root",
+ "action.newProduct": "New product",
+ "action.addProduct": "Add product",
+ "action.newBrand": "New brand",
+ "action.addBrand": "Add brand",
+ "action.newCategory": "New category",
+ "action.addCategory": "Add category",
+ "action.clear": "Clear",
+ "action.clearFilters": "Clear filters",
+ "action.clearSearch": "Clear search",
+ "action.cancel": "Cancel",
+ "action.saving": "Saving…",
+ "action.saveChanges": "Save changes",
+ "action.deleting": "Deleting…",
+ "action.changePrice": "Change price",
+ "action.adjustStock": "Adjust stock",
+ "action.adjusting": "Adjusting…",
+ "action.refresh": "Refresh",
+ "action.edit": "Edit",
+ "action.delete": "Delete",
+ "empty.products.searchTitle": "No products found",
+ "empty.products.title": "No products yet",
+ "empty.products.searchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the filters.",
+ "empty.products.searchBody": "No products match the current filters.",
+ "empty.products.body": "Add your first product to start selling. Each carries its own SKU, price, stock, and image.",
+ "empty.brands.searchTitle": "No brands found",
+ "empty.brands.title": "No brands yet",
+ "empty.brands.searchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the search.",
+ "empty.brands.searchBody": "No brands match the current filters.",
+ "empty.brands.body": "Add your first brand to start building the catalog. Each brand carries its own slug, description, and logo.",
+ "empty.categories.searchTitle": "No categories found",
+ "empty.categories.title": "No categories yet",
+ "empty.categories.searchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the search.",
+ "empty.categories.searchBody": "No categories match the current filters.",
+ "empty.categories.body": "Categories give your catalog its tree. Create root shelves, then nest sub-shelves under them.",
+ "toast.productCreated": "Product created",
+ "toast.productUpdated": "Product updated",
+ "toast.productDeleted": "Product deleted",
+ "toast.brandCreated": "Brand created",
+ "toast.brandUpdated": "Brand updated",
+ "toast.brandDeleted": "Brand deleted",
+ "toast.categoryCreated": "Category created",
+ "toast.categoryUpdated": "Category updated",
+ "toast.categoryDeleted": "Category deleted",
+ "toast.priceUpdated": "Price updated",
+ "toast.stockAdjusted": "Stock adjusted",
+ "toast.createFailed": "Create failed",
+ "toast.updateFailed": "Update failed",
+ "toast.deleteFailed": "Delete failed",
+ "toast.priceChangeFailed": "Price change failed",
+ "toast.adjustmentFailed": "Adjustment failed",
+ "field.name": "Name",
+ "field.sku": "SKU",
+ "field.brand": "Brand",
+ "field.category": "Category",
+ "field.price": "Price",
+ "field.currency": "Currency",
+ "field.stock": "Stock",
+ "field.description": "Description",
+ "field.slug": "Slug",
+ "field.parent": "Parent",
+ "field.logoUrl": "Logo URL",
+ "field.newAmount": "New amount",
+ "placeholder.productName": "Classic Cotton Tee",
+ "placeholder.sku": "ACM-TS-001",
+ "placeholder.selectBrand": "Select a brand…",
+ "placeholder.selectCategory": "Select a category…",
+ "placeholder.productDescription": "100% organic cotton crew-neck.",
+ "placeholder.brandName": "Acme Goods",
+ "placeholder.brandDescription": "Quality essentials for the modern home.",
+ "placeholder.logoUrl": "https://…",
+ "placeholder.categoryName": "Outdoor",
+ "placeholder.categoryDescription": "Gear for the great outdoors.",
+ "placeholder.noParent": "No parent (root)",
+ "hint.skuFixed": "SKU is fixed after creation.",
+ "hint.skuNew": "Stock-keeping unit. Becomes the canonical identifier.",
+ "hint.description": "Shown on listing and product detail pages.",
+ "hint.slug": "Auto-derived from the name. Used in URLs.",
+ "hint.brandDescription": "Shown on listing and product detail pages.",
+ "hint.logoUrl": "Optional. Public URL to the brand's logo image.",
+ "hint.parent": "Optional. Leave empty to make this a root category.",
+ "hint.categoryDescription": "Shown on category browse pages.",
+ "parentOption.label": "Parent category",
+ "visibility.title": "Visibility",
+ "visibility.listed": "Listed for customers.",
+ "visibility.hidden": "Hidden from listings.",
+ "visibility.active": "Active",
+ "productEditor.editTitle": "Edit product",
+ "productEditor.addTitle": "Add a product",
+ "productEditor.editDesc": "Update details for {{name}}. Use the inline price/stock chips on the row to change those — they emit domain events.",
+ "productEditor.addDesc": "Add a product to your catalog. Price and stock can be adjusted inline after creation.",
+ "brandEditor.editTitle": "Edit brand",
+ "brandEditor.addTitle": "Add a brand",
+ "brandEditor.editDesc": "Update details for {{name}}. The slug is re-derived from the name.",
+ "brandEditor.addDesc": "Add a brand to your catalog. The slug is generated automatically from the name.",
+ "categoryEditor.editTitle": "Edit category",
+ "categoryEditor.addTitle": "Add a category",
+ "categoryEditor.editDesc": "Update details for {{name}}. The slug is re-derived from the name.",
+ "categoryEditor.addDesc": "Add a category to your catalog. The slug is generated automatically from the name.",
+ "price.title": "Change price",
+ "price.emitsPrefix": "Emits a ",
+ "price.emitsSuffix": " domain event for {{name}}.",
+ "price.emitsDetailPrefix": "{{name}} — emits a ",
+ "price.emitsDetailSuffix": " domain event.",
+ "price.was": "Was",
+ "price.becomes": "Becomes",
+ "stock.title": "Adjust stock",
+ "stock.emitsPrefix": "Add or remove units for {{name}}. Emits a ",
+ "stock.emitsSuffix": " event.",
+ "stock.emitsDetailPrefix": "{{name}} — add or remove units. Emits a ",
+ "stock.emitsDetailSuffix": " event.",
+ "stock.current": "Current",
+ "stock.becomes": "Becomes",
+ "stock.deltaLabel": "Delta",
+ "stock.negativePrefix": "Stock cannot go negative. Maximum decrement is ",
+ "stock.negativeSuffix": ".",
+ "stock.negativeDetailPrefix": "Stock cannot go negative. The maximum decrement here is ",
+ "delete.productTitle": "Delete product",
+ "delete.productBody": ". The product will no longer appear in any listing or report.",
+ "delete.brandTitle": "Delete brand",
+ "delete.brandBody": ". Products referencing this brand will need to be reassigned.",
+ "delete.categoryTitle": "Delete category",
+ "delete.categoryBody": ". Categories with child categories cannot be deleted — move or delete the children first.",
+ "delete.removesPrefix": "This permanently removes ",
+ "delete.createdOn": "(created {{date}})",
+ "delete.dateOnly": "({{date}})",
+ "detail.back": "Back to products",
+ "detail.section.pricing": "Pricing",
+ "detail.section.inventory": "Inventory",
+ "detail.section.identifiers": "Identifiers",
+ "detail.section.description": "Description",
+ "detail.section.descriptionDesc": "Customer-facing copy shown on the product page.",
+ "detail.section.images": "Images",
+ "detail.section.imagesDesc": "Drop more to add. Star one to make it the cover.",
+ "detail.section.audit": "Audit",
+ "detail.stat.price": "price",
+ "detail.stat.outOfStock": "out of stock",
+ "detail.stat.low": "low (< {{n}})",
+ "detail.stat.inStock": "in stock",
+ "detail.stat.images": "images",
+ "detail.meta.created": "Created {{rel}}",
+ "detail.meta.updated": "Updated {{rel}}",
+ "detail.pricing.listed": "Listed price · {{currency}}",
+ "detail.inventory.outOfStock": "Out of stock",
+ "detail.inventory.below": "Below {{n}} units",
+ "detail.inventory.unitsOnHand": "Units on hand",
+ "detail.id.sku": "SKU",
+ "detail.id.slug": "Slug",
+ "detail.id.productId": "Product ID",
+ "detail.id.brandId": "Brand ID",
+ "detail.id.categoryId": "Category ID",
+ "detail.audit.created": "Created",
+ "detail.audit.revised": "Revised",
+ "detail.audit.never": "Never",
+ "detail.audit.noEdits": "no edits since creation",
+ "detail.audit.status": "Status",
+ "detail.audit.active": "Active",
+ "detail.audit.hidden": "Hidden",
+ "detail.description.empty": "No description on file. Customers will see a blank description on the product page until you add one.",
+ "detail.notFound.title": "Product not found",
+ "detail.notFound.body": "It may have been deleted, or the link may be wrong.",
+ "detail.editor.title": "Edit product",
+ "detail.editor.desc": "Update details for {{name}}. Use the price/stock actions in the sidebar to change those — they emit domain events."
+}
diff --git a/clients/dashboard/src/locales/en-US/chat.json b/clients/dashboard/src/locales/en-US/chat.json
new file mode 100644
index 0000000000..b3cd6741a7
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/chat.json
@@ -0,0 +1,216 @@
+{
+ "util.unnamedChannel": "(unnamed channel)",
+ "util.directMessage": "Direct message",
+ "util.emptyGroup": "Empty group",
+ "day.today": "Today",
+ "day.yesterday": "Yesterday",
+ "unnamed": "(unnamed)",
+
+ "page.emptyTitle": "Pick a conversation",
+ "page.emptyBody": "Choose a channel on the left to jump in. Channels are public to your tenant; DMs are private to the people in them. Mentions land in the notification bell, top right.",
+ "page.hintSend": "Send",
+ "page.hintNewline": "Newline",
+ "page.hintMention": "Mention",
+ "page.channelUnreachable": "That channel isn't reachable. It may have been archived or you're no longer a member.",
+ "page.loadingChannel": "Loading channel…",
+ "page.backToChannels": "Back to channels",
+ "page.member_one": "{{count}} member",
+ "page.member_other": "{{count}} members",
+ "page.searchMessages": "Search messages",
+ "page.channelSettings": "Channel settings",
+ "page.olderThanWindow": "That message is older than the loaded window.",
+
+ "rail.brand": "Chat",
+ "rail.filterPlaceholder": "Filter channels…",
+ "rail.clearFilter": "Clear filter",
+ "rail.sectionChannels": "Channels",
+ "rail.newChannel": "New channel",
+ "rail.sectionDms": "Direct Messages",
+ "rail.newDm": "New direct message",
+ "rail.loading": "Loading…",
+ "rail.noMatches": "No matches.",
+ "rail.noChannels": "No channels yet.",
+ "rail.noConversations": "No conversations.",
+ "rail.footerFiltered": "{{shown}} of {{total}}",
+ "rail.footerCount_one": "{{count}} channel",
+ "rail.footerCount_other": "{{count}} channels",
+ "rail.unreadAria": "{{n}} unread",
+ "rail.createTitle": "Create a channel",
+ "rail.createDescription": "Channels are where teams talk. Anyone in your tenant can join public channels.",
+ "rail.nameLabel": "Name",
+ "rail.namePlaceholder": "engineering, design-feedback…",
+ "rail.descLabel": "Description (optional)",
+ "rail.descPlaceholder": "What's this channel about?",
+ "rail.privateLabel": "Private",
+ "rail.privateHint": "Only invited members can find or join this channel.",
+ "rail.cancel": "Cancel",
+ "rail.createSubmit": "Create channel",
+ "rail.dmTitle": "New direct message",
+ "rail.dmDescription": "Find someone in your tenant and we'll open a DM with them. Existing DMs are reused — you won't create duplicates.",
+ "rail.dmSearchLabel": "Search",
+ "rail.dmSearchPlaceholder": "Name, username, or email…",
+ "rail.dmSearching": "Searching…",
+ "rail.dmLoadingPeople": "Loading people…",
+ "rail.dmNoMatch": "No one matches \"{{term}}\".",
+ "rail.dmNoTeammates": "No teammates in your tenant yet.",
+ "rail.dmSearchResults": "Search results",
+ "rail.dmSuggested": "Suggested",
+
+ "composer.replyPlaceholder": "Type your reply…",
+ "composer.messageChannel": "Message #{{name}}",
+ "composer.messageOther": "Message {{name}}",
+ "composer.ariaChannel": "Message channel {{name}}",
+ "composer.ariaOther": "Message {{name}}",
+ "composer.ariaReply": "Type your reply",
+ "composer.attachFile": "Attach a file",
+ "composer.sendMessage": "Send message",
+ "composer.hintReply": "Enter to send · Shift+Enter for newline · Esc to clear",
+ "composer.hintDefault": "Enter to send · Shift+Enter for newline · @ + 2 chars to mention",
+ "composer.sendFailedRetry": "Send failed — retry?",
+ "composer.replaceAttachment": "Replace the existing attachment first.",
+ "composer.attachError": "Couldn't attach that file.",
+ "composer.sendFailed": "Message failed to send — try again.",
+ "composer.replyingTo": "Replying to",
+ "composer.noTextEmpty": "(no text — attachment or empty)",
+ "composer.clearReplyAria": "Clear reply context",
+ "composer.clearReply": "Clear reply",
+ "composer.uploadPreparing": "Preparing…",
+ "composer.uploadUploading": "Uploading… {{percent}}%",
+ "composer.uploadFinalizing": "Finalizing…",
+ "composer.uploadFailed": "Upload failed",
+ "composer.uploadDone": "Done",
+ "composer.uploadProgressAria": "Upload progress",
+ "composer.uploadCancelAria": "Cancel upload",
+ "composer.uploadCancel": "Cancel",
+ "composer.readyToSend": "ready to send",
+ "composer.removeAttachment": "Remove attachment",
+
+ "typing.one": "{{name}} is typing…",
+ "typing.two": "{{a}} and {{b}} are typing…",
+ "typing.many": "{{a}} and {{count}} others are typing…",
+
+ "pinned.bar_one": "{{count}} pinned message",
+ "pinned.bar_other": "{{count}} pinned messages",
+ "pinned.countAria_one": "{{count}} pinned message, click to review",
+ "pinned.countAria_other": "{{count}} pinned messages, click to review",
+ "pinned.panelTitle": "Pinned messages",
+ "pinned.noText": "(no text)",
+ "pinned.attachment": "📎 {{name}}",
+ "pinned.attachmentMore": "📎 {{name}} (+{{count}})",
+
+ "search.close": "Close search",
+ "search.placeholder": "Search messages in this channel",
+ "search.clear": "Clear search",
+ "search.searching": "Searching…",
+ "search.noMatches": "No matches for \"{{term}}\".",
+ "search.footer_one": "{{count}} match · click to jump · Esc to close",
+ "search.footer_other": "{{count}} matches · click to jump · Esc to close",
+ "search.footerEsc": "Esc to close",
+
+ "mention.title": "Mention",
+ "mention.hint": "↑↓ navigate · Enter select · Esc cancel",
+ "mention.searching": "Searching…",
+ "mention.listboxAria": "Mention suggestions",
+
+ "message.edited": "· edited",
+ "message.pinned": "Pinned",
+ "message.deleted": "[message deleted]",
+ "message.replyingTo": "Replying to",
+ "message.aMessage": "a message",
+ "message.noTextParent": "(no text — attachment or empty)",
+ "message.seen": "Seen",
+ "message.seenEveryone": "Seen by everyone",
+ "message.seenByN": "Seen by {{n}}",
+ "message.mentionTitle": "Mention of {{username}}",
+ "message.mentionAria": "Mention of {{username}}, click to open profile",
+ "message.you": "you",
+ "message.lookingUp": "Looking up…",
+ "message.userNotFound": "User not found.",
+ "message.loadUserError": "Couldn't load this user.",
+ "message.copyHandle": "Copy @{{username}}",
+ "message.opening": "Opening…",
+ "message.openDm": "Open DM",
+ "message.thatsYou": "That's you",
+ "message.copied": "Copied {{text}}",
+ "message.copyFailed": "Couldn't copy to clipboard",
+ "message.openDmFailed": "Couldn't open DM",
+ "message.reactAria": "React with {{emoji}}",
+ "message.reactionAddAria": "Add {{emoji}} reaction, {{n}} so far",
+ "message.reactionRemoveAria": "Remove your {{emoji}} reaction, {{n}} so far",
+ "message.react": "React",
+ "message.reply": "Reply",
+ "message.unpin": "Unpin",
+ "message.pin": "Pin",
+ "message.edit": "Edit",
+ "message.delete": "Delete",
+ "message.toastDeleted": "Message deleted",
+ "message.deleteFailed": "Couldn't delete the message",
+ "message.unpinned": "Unpinned",
+ "message.pinnedToast": "Pinned",
+ "message.pinUpdateFailed": "Couldn't update the pin",
+ "message.deleteTitle": "Delete this message?",
+ "message.deleteDescription": "This can't be undone. Everyone in the channel will see the message disappear in real time.",
+ "message.deleteNoPreview": "No text preview — attachments or empty body.",
+ "message.deleteCancel": "Cancel",
+ "message.deleting": "Deleting…",
+ "message.deleteConfirm": "Delete message",
+ "message.editAria": "Edit message",
+ "message.editHint": "Enter to save · Shift+Enter for newline · Esc to cancel",
+ "message.editCancel": "Cancel",
+ "message.editSaving": "Saving…",
+ "message.editSave": "Save",
+
+ "list.loading": "Loading messages…",
+ "list.emptyTitle": "No messages yet",
+ "list.emptyBody": "This is the very beginning of the conversation. Send the first message to break the silence.",
+ "list.loadingOlder": "Loading older…",
+ "list.beginning": "Beginning of the conversation",
+ "list.logAria": "Channel messages",
+ "list.new": "New",
+ "list.jumpAria_one": "Jump to latest, {{count}} unseen message",
+ "list.jumpAria_other": "Jump to latest, {{count}} unseen messages",
+ "list.jump_one": "new message",
+ "list.jump_other": "new messages",
+
+ "settings.title": "Channel settings",
+ "settings.description": "Manage the channel name, members, and lifecycle.",
+ "settings.general": "General",
+ "settings.nameLabel": "Name",
+ "settings.descLabel": "Description",
+ "settings.descPlaceholder": "What's this channel about?",
+ "settings.privateLabel": "Private",
+ "settings.privateHint": "Only invited members can find or join this channel.",
+ "settings.members": "Members",
+ "settings.danger": "Danger zone",
+ "settings.archiveTitle": "Archive channel",
+ "settings.archiveHint": "Hides the channel for everyone. Restore from the admin panel if you change your mind.",
+ "settings.archiveConfirm": "Archive this channel for everyone?",
+ "settings.archiving": "Archiving…",
+ "settings.archive": "Archive",
+ "settings.leaveTitle": "Leave channel",
+ "settings.leaveHint": "You'll stop receiving messages. Rejoin via channel discovery if it's public.",
+ "settings.leaveConfirm": "Leave this channel?",
+ "settings.leaving": "Leaving…",
+ "settings.leave": "Leave",
+ "settings.close": "Close",
+ "settings.saving": "Saving…",
+ "settings.saveChanges": "Save changes",
+ "settings.toastUpdated": "Channel updated.",
+ "settings.toastUpdateFailed": "Couldn't save channel changes.",
+ "settings.toastArchived": "Channel archived.",
+ "settings.toastArchiveFailed": "Couldn't archive the channel.",
+ "settings.toastLeft": "You left the channel.",
+ "settings.toastLeaveFailed": "Couldn't leave the channel.",
+ "settings.you": "you",
+ "settings.admin": "admin",
+ "settings.removeConfirm": "Remove {{name}} from the channel?",
+ "settings.removeAria": "Remove {{name}}",
+ "settings.toastMemberRemoved": "Member removed.",
+ "settings.toastRemoveFailed": "Couldn't remove the member.",
+ "settings.addMember": "Add member",
+ "settings.addPlaceholder": "Name, username, or email…",
+ "settings.addSearching": "Searching…",
+ "settings.noMatchesOutside": "No matches outside the current member list.",
+ "settings.toastMemberAdded": "Member added.",
+ "settings.toastAddFailed": "Couldn't add the member."
+}
diff --git a/clients/dashboard/src/locales/en-US/commandPalette.json b/clients/dashboard/src/locales/en-US/commandPalette.json
new file mode 100644
index 0000000000..58bfe09aa3
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/commandPalette.json
@@ -0,0 +1,83 @@
+{
+ "group.navigate": "Navigate",
+ "group.create": "Create",
+ "group.account": "Account",
+ "group.theme": "Theme",
+ "group.accent": "Accent",
+ "group.session": "Session",
+ "nav.overview.label": "Overview",
+ "nav.overview.hint": "Tenant telemetry & usage",
+ "nav.activity.label": "Live activity",
+ "nav.activity.hint": "Real-time event stream",
+ "nav.chat.label": "Chat",
+ "nav.chat.hint": "Channels & direct messages",
+ "nav.files.label": "Files",
+ "nav.files.hint": "My uploaded assets",
+ "nav.users.label": "Users",
+ "nav.users.hint": "Identity directory",
+ "nav.roles.label": "Roles",
+ "nav.roles.hint": "Permissions & role assignment",
+ "nav.groups.label": "Groups",
+ "nav.groups.hint": "Org groups & membership",
+ "nav.products.label": "Products",
+ "nav.products.hint": "Catalog inventory",
+ "nav.brands.label": "Brands",
+ "nav.brands.hint": "Catalog brands",
+ "nav.categories.label": "Categories",
+ "nav.categories.hint": "Catalog categories",
+ "nav.tickets.label": "Tickets",
+ "nav.tickets.hint": "Support requests",
+ "nav.invoices.label": "Invoices",
+ "nav.invoices.hint": "Billing history",
+ "nav.health.label": "Health",
+ "nav.health.hint": "Readiness probe & dependencies",
+ "nav.audits.label": "Audit trail",
+ "nav.audits.hint": "Activity, security, entity-change events",
+ "nav.trash.label": "Trash",
+ "nav.trash.hint": "Soft-deleted records",
+ "nav.sessions.label": "Sessions",
+ "nav.sessions.hint": "Active user sessions",
+ "nav.settings.label": "Settings",
+ "create.user.label": "Create user",
+ "create.user.hint": "Register a new account",
+ "create.role.label": "Create role",
+ "create.role.hint": "Define a new permission set",
+ "create.group.label": "Create group",
+ "create.group.hint": "Organize members",
+ "create.product.label": "Create product",
+ "create.product.hint": "Add to catalog",
+ "create.brand.label": "Create brand",
+ "create.brand.hint": "Add a catalog brand",
+ "create.category.label": "Create category",
+ "create.category.hint": "Add a catalog category",
+ "create.ticket.label": "Create ticket",
+ "create.ticket.hint": "File a support request",
+ "create.channel.label": "Create chat channel",
+ "create.channel.hint": "Start a new conversation space",
+ "create.file.label": "Upload file",
+ "create.file.hint": "Add to your storage",
+ "account.profile.label": "Profile",
+ "account.profile.hint": "Name, email, contact",
+ "account.security.label": "Security",
+ "account.security.hint": "Password, 2FA, sessions",
+ "account.keys.label": "API keys",
+ "account.keys.hint": "Generate & rotate",
+ "account.notifications.label": "Notifications",
+ "account.notifications.hint": "Email preferences",
+ "account.appearance.label": "Appearance",
+ "account.appearance.hint": "Theme, accent, font, density",
+ "theme.light": "Switch to light",
+ "theme.dark": "Switch to dark",
+ "theme.system": "Follow system theme",
+ "accent.set": "Set accent: {{name}}",
+ "session.logout.label": "Sign out",
+ "session.logout.hint": "End this session",
+ "ui.searchPlaceholder": "Type a command or search…",
+ "ui.searchAria": "Search commands",
+ "ui.srTitle": "Command palette",
+ "ui.srDescription": "Search across pages, account actions, theme and accent. Use arrow keys to navigate; Enter to select.",
+ "ui.emptyTitle": "No matches",
+ "ui.emptyBody": "Try a different keyword — page name, entity, theme, accent, or sign-out.",
+ "ui.navigate": "navigate",
+ "ui.select": "select"
+}
diff --git a/clients/dashboard/src/locales/en-US/common.json b/clients/dashboard/src/locales/en-US/common.json
new file mode 100644
index 0000000000..33e56d8979
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/common.json
@@ -0,0 +1,136 @@
+{
+ "language": "Language",
+ "language.enUS": "English (US)",
+ "language.ptBR": "Português (BR)",
+ "search": "Search",
+ "commandPalette.open": "Open command palette",
+ "profileMenu.open": "Open profile menu",
+ "unknownUser": "Unknown",
+ "skipToContent": "Skip to main content",
+ "brand.dashboard": "Dashboard",
+ "presence.connected_one": "Connected · {{formatted}} event",
+ "presence.connected_other": "Connected · {{formatted}} events",
+ "presence.offline": "Stream offline",
+ "presence.connecting": "Connecting…",
+ "presence.reconnecting": "Reconnecting…",
+ "presence.idle": "Idle",
+ "theme.title": "Theme",
+ "theme.light": "Light",
+ "theme.dark": "Dark",
+ "theme.system": "System",
+ "account.title": "Account",
+ "account.profile": "Profile",
+ "account.settings": "Settings",
+ "account.apiKeys": "API keys",
+ "account.signOut": "Sign out",
+ "signOut.title": "Sign out of fullstackhero?",
+ "signOut.description": "You'll need to sign in again to access this tenant. Any unsaved work in this session will be lost.",
+ "signOut.cancel": "Cancel",
+ "signOut.confirm": "Sign out",
+ "sidebar.collapse": "Collapse sidebar",
+ "sidebar.expand": "Expand sidebar",
+ "nav.primary": "Primary navigation",
+ "nav.primaryDescription": "Site sections and account links.",
+ "nav.openMenu": "Open navigation menu",
+ "nav.overview": "Overview",
+ "nav.chat": "Chat",
+ "nav.myFiles": "My Files",
+ "nav.settings": "Settings",
+ "nav.section.operations": "Operations",
+ "nav.section.catalog": "Catalog",
+ "nav.section.helpdesk": "Helpdesk",
+ "nav.section.identity": "Identity",
+ "nav.section.system": "System",
+ "nav.liveActivity": "Live activity",
+ "nav.subscription": "Subscription",
+ "nav.wallet": "WhatsApp wallet",
+ "nav.invoices": "Invoices",
+ "nav.products": "Products",
+ "nav.brands": "Brands",
+ "nav.categories": "Categories",
+ "nav.tickets": "Tickets",
+ "nav.users": "Users",
+ "nav.roles": "Roles",
+ "nav.groups": "Groups",
+ "nav.health": "Health",
+ "nav.audits": "Audit trail",
+ "nav.sessions": "Sessions",
+ "nav.trash": "Trash",
+ "expiryBanner.expired": "Your subscription has expired. Contact your operator to renew and restore full access.",
+ "expiryBanner.grace": "Your subscription expired — {{days}} of grace left (until {{date}}). Contact your operator to renew.",
+ "expiryBanner.nearing": "Your subscription expires in {{days}}.",
+ "expiryBanner.days_one": "{{count}} day",
+ "expiryBanner.days_other": "{{count}} days",
+ "expiryBanner.graceSoon": "soon",
+ "expiryBanner.dismiss": "Dismiss",
+ "expiryBanner.dismissNotice": "Dismiss subscription notice",
+ "impersonationBanner.crossTenant": "Cross-tenant impersonation",
+ "impersonationBanner.impersonating": "Impersonating",
+ "impersonationBanner.operator": "operator",
+ "impersonationBanner.end": "End impersonation",
+ "impersonationBanner.ending": "Ending…",
+ "impersonationBanner.toastEnded": "Impersonation ended",
+ "impersonationBanner.toastReturned": "Returned to your session",
+ "impersonationBanner.toastErrorTitle": "Could not end impersonation cleanly",
+ "impersonationBanner.toastErrorDesc": "Restored local session.",
+ "backToSignIn": "Back to sign in",
+ "relative.justNow": "just now",
+ "relative.secondsAgo": "{{n}}s ago",
+ "relative.minutesAgo": "{{n}}m ago",
+ "relative.hoursAgo": "{{n}}h ago",
+ "relative.daysAgo": "{{n}}d ago",
+ "relative.monthsAgo": "{{n}}mo ago",
+ "relative.yearsAgo": "{{n}}y ago",
+ "notFound.title": "Page not found",
+ "notFound.body": "We couldn't find anything at that address. It may be a stale link, a renamed route, or a path that never existed.",
+ "notFound.requested": "Requested",
+ "notFound.backHome": "Back home",
+ "notFound.goBack": "Go back to the previous page",
+ "tenantDeactivated.title": "Tenant deactivated",
+ "tenantDeactivated.body": "This tenant has been deactivated and is no longer available. If you think this is a mistake, please contact your administrator.",
+ "impersonationEnded.title": "Impersonation ended",
+ "impersonationEnded.body": "The operator's access to this session was revoked or has expired. Sign in with your own account to continue.",
+ "close": "Close",
+ "list.clear": "Clear",
+ "list.clearSearch": "Clear search",
+ "list.clearFilter": "Clear filter",
+ "list.noMatches": "No matches.",
+ "routeError.title": "Something went wrong",
+ "routeError.reload": "Reload",
+ "routeError.goHome": "Go home",
+ "routeError.unexpected": "Unexpected error",
+ "list.loading": "Loading…",
+ "list.pageOf": "Page {{page}} of {{total}}",
+ "list.prevPage": "Previous page",
+ "list.nextPage": "Next page",
+ "unit.item_one": "{{count}} item",
+ "unit.item_other": "{{count}} items",
+ "unit.event_one": "{{count}} event",
+ "unit.event_other": "{{count}} events",
+ "unit.brand_one": "{{count}} brand",
+ "unit.brand_other": "{{count}} brands",
+ "unit.category_one": "{{count}} category",
+ "unit.category_other": "{{count}} categories",
+ "unit.file_one": "{{count}} file",
+ "unit.file_other": "{{count}} files",
+ "unit.group_one": "{{count}} group",
+ "unit.group_other": "{{count}} groups",
+ "unit.invoice_one": "{{count}} invoice",
+ "unit.invoice_other": "{{count}} invoices",
+ "unit.role_one": "{{count}} role",
+ "unit.role_other": "{{count}} roles",
+ "unit.user_one": "{{count}} user",
+ "unit.user_other": "{{count}} users",
+ "unit.session_one": "{{count}} session",
+ "unit.session_other": "{{count}} sessions",
+ "unit.ticket_one": "{{count}} ticket",
+ "unit.ticket_other": "{{count}} tickets",
+ "realtimeStatus.offline": "Offline",
+ "realtimeStatus.connecting": "Connecting",
+ "realtimeStatus.live": "Live",
+ "realtimeStatus.reconnecting": "Reconnecting",
+ "realtimeStatus.title": "Realtime: {{label}}",
+ "session.restoring": "Restoring your session…",
+ "field.required": "required",
+ "errorBand.failure": "Failure"
+}
diff --git a/clients/dashboard/src/locales/en-US/files.json b/clients/dashboard/src/locales/en-US/files.json
new file mode 100644
index 0000000000..95599df5bc
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/files.json
@@ -0,0 +1,130 @@
+{
+ "page.title": "Files",
+ "page.unit": "file",
+ "page.description": "Drop images, documents, or archives. Uploads are private to you by default; flip a file to public from its preview to make it visible to the rest of the tenant under Shared.",
+ "tabs.aria": "File scopes",
+ "tabs.mine": "My files",
+ "tabs.shared": "Shared in tenant",
+ "search.placeholder": "Search by filename…",
+ "search.aria": "Search files",
+ "search.clear": "Clear search",
+ "chip.all": "All",
+ "chip.images": "Images",
+ "chip.documents": "Documents",
+ "chip.archives": "Archives",
+ "chip.other": "Other",
+ "error.mine": "Couldn't load your files.",
+ "error.shared": "Couldn't load shared files.",
+ "empty.mine.title": "No files yet",
+ "empty.mine.body": "Drop a file above to get started. Your uploads are private by default.",
+ "empty.shared.title": "Nothing shared yet",
+ "empty.shared.body": "When a teammate flips one of their files to public, it shows up here for everyone in the tenant.",
+ "empty.refresh": "Refresh",
+ "empty.noMatch.title": "No matches",
+ "empty.noMatch.body": "Nothing matches the current filter.",
+ "empty.noMatch.bodyTerm": "Nothing matches the current filter \"{{term}}\".",
+ "empty.noMatch.reset": "Reset filters",
+ "col.filename": "Filename",
+ "col.uploadedBy": "Uploaded by",
+ "col.visibility": "Visibility",
+ "col.size": "Size",
+ "col.uploaded": "Uploaded",
+ "count.showing_one": "Showing {{shown}} of {{count}} file",
+ "count.showing_other": "Showing {{shown}} of {{count}} files",
+ "count.total_one": "{{count}} file",
+ "count.total_other": "{{count}} files",
+ "visibility.public": "Public",
+ "visibility.private": "Private",
+ "card.by": "by",
+ "preview.fallbackName": "File",
+ "preview.toastDeleted": "File deleted",
+ "preview.toastDeleteFailed": "Delete failed",
+ "preview.toastNowPublic": "File is now public to your tenant",
+ "preview.toastNowPrivate": "File is now private",
+ "preview.toastVisibilityFailed": "Visibility change failed",
+ "preview.errorMeta": "Couldn't load file metadata.",
+ "preview.confirmDelete": "Delete this file?",
+ "preview.cancel": "Cancel",
+ "preview.deleting": "Deleting…",
+ "preview.confirmDeleteButton": "Confirm delete",
+ "preview.delete": "Delete",
+ "preview.close": "Close",
+ "preview.notFinalized": "Upload not yet finalized.",
+ "preview.quarantined": "This file is quarantined and cannot be previewed.",
+ "preview.expired": "Preview link expired or unreachable.",
+ "preview.retry": "Retry",
+ "preview.notAvailable": "Preview not available for this file type.",
+ "preview.openNewTab": "Open in new tab",
+ "preview.textError": "Couldn't fetch text body: {{err}}",
+ "preview.fetchFailed": "Failed to fetch",
+ "preview.meta.fileId": "File ID",
+ "preview.meta.ownerType": "Owner type",
+ "preview.meta.uploadedBy": "Uploaded by",
+ "preview.meta.contentType": "Content type",
+ "preview.meta.size": "Size",
+ "preview.meta.status": "Status",
+ "preview.meta.created": "Created",
+ "preview.meta.uploaderLoading": "Loading…",
+ "preview.meta.visibility": "Visibility",
+ "preview.meta.publicHint": "Everyone in your tenant can find this file under Shared.",
+ "preview.meta.privateHint": "Only you can preview or download this file.",
+ "preview.meta.readOnly": "Read-only",
+ "preview.meta.toggleAria": "Toggle public visibility",
+ "preview.status.pending": "Pending upload",
+ "preview.status.available": "Available",
+ "preview.status.quarantined": "Quarantined",
+ "preview.status.unknown": "Unknown ({{status}})",
+ "preview.download": "Download",
+ "dropzone.toastUploaded": "File uploaded",
+ "dropzone.dismiss": "Dismiss",
+ "dropzone.cancel": "Cancel",
+ "dropzone.progressAria": "Upload progress",
+ "dropzone.caption.preparing": "Preparing upload…",
+ "dropzone.caption.uploading": "Uploading {{name}}",
+ "dropzone.caption.uploadingFallback": "Uploading …",
+ "dropzone.caption.finalizing": "Finalizing…",
+ "dropzone.caption.done": "Uploaded {{name}}",
+ "dropzone.caption.doneFallback": "Uploaded file",
+ "dropzone.caption.error": "Upload failed",
+ "dropzone.caption.idle": "Drop a file or click to browse",
+ "dropzone.detail.uploading": "{{loaded}} of {{total}}",
+ "dropzone.detail.allowed": "Allowed: {{ext}}",
+ "dropzone.detail.upTo": " · up to {{size}}",
+ "dropzone.detail.dropAFile": "Drop a file",
+ "imageInput.upload": "Upload",
+ "imageInput.pasteUrl": "Paste URL",
+ "imageInput.replace": "Replace image",
+ "imageInput.choose": "Choose image",
+ "imageInput.remove": "Remove",
+ "imageInput.urlPlaceholder": "https://…",
+ "imageInput.hintUpload": "JPG/PNG/WebP/GIF · up to {{size}}",
+ "imageInput.hintUrl": "Direct link to an image you host elsewhere.",
+ "imageInput.toastUploaded": "Image uploaded",
+ "imageInput.uploadFailed": "Upload failed",
+ "imageInput.noPublicUrl": "Server returned no publicUrl for this file.",
+ "pim.upload": "Upload images",
+ "pim.count_one": "{{count}} image · JPG / PNG / WebP / GIF · up to 10 MB",
+ "pim.count_other": "{{count}} images · JPG / PNG / WebP / GIF · up to 10 MB",
+ "pim.empty": "No images yet. Upload one to set the product's cover.",
+ "pim.toastCoverUpdated": "Cover image updated",
+ "pim.toastImageRemoved": "Image removed",
+ "pim.errAttach": "Failed to attach image",
+ "pim.errSetCover": "Failed to set cover",
+ "pim.errRemove": "Failed to remove image",
+ "pim.errUploadNamed": "Upload failed: {{name}}",
+ "pim.noPublicUrl": "Server returned no publicUrl for the uploaded image.",
+ "pim.cover": "Cover",
+ "pim.setCover": "Set as cover",
+ "pim.removeImage": "Remove image",
+ "pim.previewImage": "Preview image",
+ "pim.previewTitle": "Image preview",
+ "pim.currentCover": "Current cover image.",
+ "pim.clickOutside": "Click outside to close.",
+ "pim.close": "Close",
+ "pim.detachTitle": "Detach this image?",
+ "pim.detachBody": "The image is removed from this product. ",
+ "pim.detachCoverNote": "It's currently the cover — another image will be promoted automatically.",
+ "pim.cancel": "Cancel",
+ "pim.removing": "Removing…",
+ "pim.remove": "Remove"
+}
diff --git a/clients/dashboard/src/locales/en-US/health.json b/clients/dashboard/src/locales/en-US/health.json
new file mode 100644
index 0000000000..841913a043
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/health.json
@@ -0,0 +1,48 @@
+{
+ "eyebrow": "System · Health",
+ "title": "Health",
+ "subtitlePre": "Live readiness probe across every registered dependency. Polled every ",
+ "subtitleSuffix": ".",
+ "pauseTitle": "Pause auto-refresh",
+ "resumeTitle": "Resume auto-refresh",
+ "live": "Live",
+ "paused": "Paused",
+ "refresh": "Refresh",
+ "dependencies": "Dependencies",
+ "checksTotal_one": "{{count}} check · total",
+ "checksTotal_other": "{{count}} checks · total",
+ "hero.Healthy.headline": "All systems operational",
+ "hero.Healthy.subline": "Every dependency is responding within tolerance.",
+ "hero.Degraded.headline": "Partial degradation",
+ "hero.Degraded.subline": "One or more checks are reporting elevated latency or warnings.",
+ "hero.Unhealthy.headline": "Disruption detected",
+ "hero.Unhealthy.subline": "At least one critical dependency is unreachable. Investigate the failing checks below.",
+ "status.Healthy": "Healthy",
+ "status.Degraded": "Degraded",
+ "status.Unhealthy": "Unhealthy",
+ "http": "HTTP {{code}}",
+ "vital.checks": "Checks",
+ "vital.checksHint": "passing",
+ "vital.roundTrip": "Round-trip",
+ "vital.roundTripHint": "end-to-end",
+ "vital.slowest": "Slowest",
+ "vital.slowestHint": "single check",
+ "vital.lastPoll": "Last poll",
+ "recentPolls": "Recent polls",
+ "sessionCount": "{{n}} / {{limit}} this session",
+ "historyAria": "Recent poll history",
+ "historyTick": "{{status}} · {{time}}",
+ "historyNoData": "no data",
+ "noDescription": "No description registered.",
+ "latencyBudget": "Latency budget",
+ "latencyBudgetValue": "{{value}} / 500 ms",
+ "latencyBudgetHint": "Scaled against a soft 500 ms readiness budget. Bars in the upper third are early-warning territory.",
+ "detail": "Detail",
+ "noDetails": "No additional details reported.",
+ "moreDetails": "+{{count}} more",
+ "error.title": "Health endpoint unreachable",
+ "error.default": "The browser couldn't reach /health/ready. The API may be down, or a network policy is blocking the request.",
+ "empty.title": "No checks registered",
+ "empty.bodyPre": "Modules can register dependency checks via ",
+ "empty.bodyPost": ". None are reporting yet."
+}
diff --git a/clients/dashboard/src/locales/en-US/identity.json b/clients/dashboard/src/locales/en-US/identity.json
new file mode 100644
index 0000000000..b1879afbcf
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/identity.json
@@ -0,0 +1,347 @@
+{
+ "unnamedUser": "Unnamed user",
+ "backToUsers": "Back to users",
+ "notFound": "User not found.",
+ "member": "Member",
+ "cancel": "Cancel",
+
+ "badge.active": "Active",
+ "badge.inactive": "Inactive",
+ "badge.emailConfirmed": "Email confirmed",
+ "badge.emailPending": "Email pending",
+
+ "actions.impersonate": "Impersonate",
+ "actions.impersonateDisabledTitle": "Cannot impersonate an inactive user",
+ "actions.sending": "Sending…",
+ "actions.resendConfirmation": "Resend confirmation",
+ "actions.confirming": "Confirming…",
+ "actions.confirmEmail": "Confirm email",
+ "actions.deactivate": "Deactivate",
+ "actions.reactivate": "Reactivate",
+ "actions.delete": "Delete",
+
+ "stat.role_one": "role",
+ "stat.role_other": "roles",
+ "stat.session_one": "session",
+ "stat.session_other": "sessions",
+
+ "card.title": "Identity card",
+ "card.description": "Read-only here. Members update their own profile from settings.",
+ "field.username": "Username",
+ "field.email": "Email",
+ "field.firstName": "First name",
+ "field.lastName": "Last name",
+ "field.phone": "Phone",
+ "field.id": "ID",
+
+ "roles.title": "Role assignment",
+ "roles.description": "Toggle which roles apply. Changes are staged until saved.",
+ "roles.pending_one": "{{count}} pending",
+ "roles.pending_other": "{{count}} pending",
+ "roles.discard": "Discard",
+ "roles.saving": "Saving…",
+ "roles.save": "Save changes",
+ "roles.emptyPre": "No roles defined.",
+ "roles.emptyLink": "Create one",
+ "roles.emptyPost": "to start assigning access.",
+ "roles.untitled": "Untitled role",
+ "roles.modified": "modified",
+ "roles.toggleAria": "Toggle {{name}}",
+ "roles.roleFallback": "role",
+ "roles.updated": "Roles updated",
+ "roles.updatedDesc_one": "{{count}} role changed.",
+ "roles.updatedDesc_other": "{{count}} roles changed.",
+ "roles.updateFailed": "Update failed",
+
+ "status.deactivated": "User deactivated",
+ "status.reactivated": "User reactivated",
+ "status.changeFailed": "Status change failed",
+ "status.deactivateTitle": "Deactivate user?",
+ "status.reactivateTitle": "Reactivate user?",
+ "status.deactivateDesc": "{{name}} will not be able to sign in until reactivated. Existing sessions remain unless revoked.",
+ "status.reactivateDesc": "{{name}} will regain sign-in access immediately.",
+ "status.working": "Working…",
+
+ "email.confirmed": "Email confirmed",
+ "email.confirmFailed": "Confirm email failed",
+ "email.resent": "Confirmation email sent",
+ "email.resendFailed": "Couldn't send confirmation email",
+
+ "delete.deleted": "User deleted",
+ "delete.failed": "Delete failed",
+ "delete.title": "Delete this member",
+ "delete.descPre": "This permanently removes",
+ "delete.descPost": "They will lose access immediately. This cannot be undone.",
+ "delete.deleting": "Deleting…",
+ "delete.confirm": "Delete user",
+
+ "sessions.revoked": "Session revoked",
+ "sessions.revokeFailed": "Revoke failed",
+ "sessions.revokedCount_one": "Revoked {{count}} session",
+ "sessions.revokedCount_other": "Revoked {{count}} sessions",
+ "sessions.revokeAllFailed": "Revoke-all failed",
+ "sessions.unknownBrowser": "Unknown browser",
+ "sessions.unknownOs": "Unknown OS",
+ "sessions.title": "Active sessions",
+ "sessions.loading": "Loading sessions…",
+ "sessions.summary": "{{active}} active · {{total}} total recorded",
+ "sessions.revokeAll": "Revoke all",
+ "sessions.empty": "No sessions on file. The user hasn't signed in recently.",
+ "sessions.badgeActive": "Active",
+ "sessions.badgeEnded": "Ended",
+ "sessions.lastSeen": "last seen {{date}}",
+ "sessions.started": "started {{date}}",
+ "sessions.revoking": "Revoking…",
+ "sessions.revoke": "Revoke",
+ "sessions.revokeAllTitle": "Revoke all sessions for {{name}}?",
+ "sessions.revokeAllDesc": "Every active session — desktop, mobile, browser tab — will be ended immediately. The user will need to sign in again on each device.",
+ "sessions.revokingAll": "Revoking…",
+
+ "impersonate.started": "Impersonation started",
+ "impersonate.startedDesc": "You're now acting as this user. Use the banner to end.",
+ "impersonate.failed": "Impersonation failed",
+ "impersonate.title": "Impersonate {{name}}?",
+ "impersonate.description": "You'll act as this user across the dashboard. Every action you take will be attributed to them in audit logs (with your operator id preserved as the actor). End impersonation from the banner at the top of the page when you're done.",
+ "impersonate.reasonLabel": "Reason (optional, recorded in the audit log)",
+ "impersonate.reasonPlaceholder": "Investigating a bug report from this user…",
+ "impersonate.starting": "Starting…",
+ "impersonate.start": "Start impersonation",
+
+ "field.name": "Name",
+ "field.description": "Description",
+ "field.password": "Password",
+ "field.confirm": "Confirm",
+
+ "rolesList.title": "Roles",
+ "rolesList.description": "Roles bundle permissions into named bands of authority. Assign them from the user detail page.",
+ "rolesList.newRole": "New role",
+ "rolesList.searchPlaceholder": "Search by name or description…",
+ "rolesList.emptyFoundTitle": "No roles found",
+ "rolesList.emptyTitle": "No roles yet",
+ "rolesList.emptySearchBody": "Nothing matches \"{{term}}\". Try a different term or clear the search.",
+ "rolesList.emptyBody": "Create the first role to start grouping permissions. Without roles, members default to no access.",
+ "rolesList.clearSearch": "Clear search",
+ "rolesList.addRole": "Add role",
+ "rolesList.found_one": "{{count}} role found",
+ "rolesList.found_other": "{{count}} roles found",
+ "rolesList.colName": "Name",
+ "rolesList.colDescription": "Description",
+ "rolesList.colPermissions": "Permissions",
+ "rolesList.system": "System",
+ "rolesList.noDescription": "No description on file.",
+ "rolesList.openAria": "Open role {{name}}",
+ "rolesList.permNone": "—",
+ "rolesList.perm_one": "{{count}} permission",
+ "rolesList.perm_other": "{{count}} permissions",
+ "rolesList.createTitle": "Create a role",
+ "rolesList.createDescription": "Roles bundle permissions. After creating, you'll be taken to the editor to assign permissions and tune access.",
+ "rolesList.namePlaceholder": "Manager",
+ "rolesList.descHint": "Shown to admins when assigning roles. Plain English helps.",
+ "rolesList.descPlaceholder": "Can manage users and assign roles",
+ "rolesList.creating": "Creating…",
+ "rolesList.createSubmit": "Create role",
+ "rolesList.toastCreated": "Role created",
+ "rolesList.toastCreatedDesc": "Now configure {{name}}'s permissions.",
+ "rolesList.toastCreateFailed": "Create failed",
+
+ "groupsList.title": "Groups",
+ "groupsList.description": "Groups bundle members and roles into reusable cohorts. Add a user to a group to grant every role attached to that group.",
+ "groupsList.newGroup": "New group",
+ "groupsList.searchPlaceholder": "Search by name or description…",
+ "groupsList.emptyFoundTitle": "No groups found",
+ "groupsList.emptyTitle": "No groups yet",
+ "groupsList.emptySearchBodyTerm": "Nothing matches \"{{term}}\". Try a different term.",
+ "groupsList.emptySearchBodyNoTerm": "No groups match the current filters.",
+ "groupsList.emptyBody": "Create the first group to bundle members and roles. Useful for teams, departments, or feature cohorts.",
+ "groupsList.clearSearch": "Clear search",
+ "groupsList.addGroup": "Add group",
+ "groupsList.found_one": "{{count}} group found",
+ "groupsList.found_other": "{{count}} groups found",
+ "groupsList.colGroup": "Group",
+ "groupsList.colComposition": "Composition",
+ "groupsList.colFlags": "Flags",
+ "groupsList.default": "Default",
+ "groupsList.system": "System",
+ "groupsList.noDescription": "No description on file.",
+ "groupsList.openAria": "Open group {{name}}",
+ "groupsList.member_one": "{{count}} member",
+ "groupsList.member_other": "{{count}} members",
+ "groupsList.role_one": "{{count}} role",
+ "groupsList.role_other": "{{count}} roles",
+ "groupsList.createTitle": "Create a group",
+ "groupsList.createDescription": "Groups bundle members and roles. After creating, you'll be taken to the editor to attach roles and add members.",
+ "groupsList.namePlaceholder": "Engineering",
+ "groupsList.descHint": "Helps admins understand what this cohort represents.",
+ "groupsList.descPlaceholder": "Engineers across web, mobile, and platform.",
+ "groupsList.defaultLabel": "Default group",
+ "groupsList.defaultHint": "Newly registered users join automatically.",
+ "groupsList.creating": "Creating…",
+ "groupsList.createSubmit": "Create group",
+ "groupsList.toastCreated": "Group created",
+ "groupsList.toastCreatedDesc": "Add members and roles next.",
+ "groupsList.toastCreateFailed": "Create failed",
+
+ "usersList.title": "Users",
+ "usersList.description": "Every member with access to this tenant. Register newcomers, review status, and manage roles.",
+ "usersList.registerUser": "Register user",
+ "usersList.searchPlaceholder": "Search by name, username, or email…",
+ "usersList.filterAccountStatus": "Account status",
+ "usersList.filterAll": "All",
+ "usersList.filterActive": "Active",
+ "usersList.filterInactive": "Inactive",
+ "usersList.filterEmailStatus": "Email status",
+ "usersList.filterAnyEmail": "Any email",
+ "usersList.filterConfirmed": "Confirmed",
+ "usersList.filterPending": "Pending",
+ "usersList.filterRole": "Role",
+ "usersList.emptyFoundTitle": "No users found",
+ "usersList.emptyTitle": "No users yet",
+ "usersList.emptySearchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the filters.",
+ "usersList.emptySearchBodyNoTerm": "No users match the current filters.",
+ "usersList.emptyBody": "Register the first member to seed this tenant. They'll receive a confirmation email if email confirmation is enabled.",
+ "usersList.clearFilters": "Clear filters",
+ "usersList.found_one": "{{count}} user found",
+ "usersList.found_other": "{{count}} users found",
+ "usersList.colName": "Name",
+ "usersList.colUsername": "Username",
+ "usersList.colStatus": "Status",
+ "usersList.noEmail": "no email",
+ "usersList.noEmailFile": "no email on file",
+ "usersList.openAria": "Open user {{name}}",
+ "usersList.badgeConfirmed": "Confirmed",
+ "usersList.badgePending": "Pending",
+ "usersList.registerTitle": "Register a member",
+ "usersList.registerDescription": "Add a new user to this tenant. Username and email must be unique. Passwords need an uppercase letter, lowercase letter, and a digit.",
+ "usersList.firstPlaceholder": "Ada",
+ "usersList.lastPlaceholder": "Lovelace",
+ "usersList.usernameHint": "Used at sign-in. Lowercase, no spaces.",
+ "usersList.usernamePlaceholder": "ada.lovelace",
+ "usersList.emailPlaceholder": "ada@example.com",
+ "usersList.phoneHint": "Optional contact number.",
+ "usersList.phonePlaceholder": "+44 …",
+ "usersList.confirmMismatch": "Passwords don't match.",
+ "usersList.registering": "Registering…",
+ "usersList.registerSubmit": "Register user",
+ "usersList.toastRegistered": "User registered",
+ "usersList.toastRegisteredDesc": "An email confirmation may be required before sign-in.",
+ "usersList.toastRegisterFailed": "Registration failed",
+
+ "roleDetail.back": "Back to roles",
+ "roleDetail.notFound": "Role not found.",
+ "roleDetail.catalogError": "Couldn't load the permission catalog: {{error}}",
+ "roleDetail.system": "System",
+ "roleDetail.subtitleSystem": "Built-in role managed by the framework.",
+ "roleDetail.subtitleCustom": "Custom role.",
+ "roleDetail.deleteRole": "Delete role",
+ "roleDetail.deleteSystemTitle": "System roles cannot be deleted.",
+ "roleDetail.statPermissions": "permissions",
+ "roleDetail.readOnlyTitle": "Built-in role — read only",
+ "roleDetail.readOnlyBody": " ships with the framework. Its name, description, and permissions are managed centrally so the seed contract and the runtime permission syncer stay in agreement. Create a custom role if you need a different set of grants.",
+ "roleDetail.detailsTitle": "Role details",
+ "roleDetail.detailsDescription": "The display label and a one-line summary admins will see at assignment time.",
+ "roleDetail.descPlaceholder": "Short description for this role",
+ "roleDetail.permsTitle": "Permissions",
+ "roleDetail.permsDescription": "Search, filter, and toggle individual permissions — or seed a sensible default from a preset. Some root-level permissions may be filtered server-side.",
+ "roleDetail.presetBasic": "Basic",
+ "roleDetail.presetAll": "All",
+ "roleDetail.presetClear": "Clear",
+ "roleDetail.unsaved": "Unsaved changes",
+ "roleDetail.allSaved": "All changes saved",
+ "roleDetail.discard": "Discard",
+ "roleDetail.saving": "Saving…",
+ "roleDetail.saveChanges": "Save changes",
+ "roleDetail.searchPlaceholder": "Search by resource, action, or description…",
+ "roleDetail.searchAria": "Search permissions",
+ "roleDetail.clearSearch": "Clear search",
+ "roleDetail.filterAll": "All",
+ "roleDetail.filterEnabled": "Enabled",
+ "roleDetail.filterModified": "Modified",
+ "roleDetail.filterBasic": "Basic",
+ "roleDetail.enabled": "enabled",
+ "roleDetail.modified": "{{n}} modified",
+ "roleDetail.showingMatches_one": "showing {{count}} match",
+ "roleDetail.showingMatches_other": "showing {{count}} matches",
+ "roleDetail.expandAll": "Expand all",
+ "roleDetail.collapseAll": "Collapse all",
+ "roleDetail.noMatchTitle": "No permissions match",
+ "roleDetail.noMatchBody": "Try a different term or clear the current filter.",
+ "roleDetail.resetFilters": "Reset filters",
+ "roleDetail.toggleAllAria": "Toggle all {{resource}}",
+ "roleDetail.changed": "{{n}} changed",
+ "roleDetail.changedTitle_one": "{{count}} unsaved change",
+ "roleDetail.changedTitle_other": "{{count}} unsaved changes",
+ "roleDetail.groupMatches_one": "· {{count}} match",
+ "roleDetail.groupMatches_other": "· {{count}} matches",
+ "roleDetail.groupAll": "All",
+ "roleDetail.groupNone": "None",
+ "roleDetail.rootBadge": "root",
+ "roleDetail.rootTitle": "Root-level permission. May be filtered server-side.",
+ "roleDetail.basicBadge": "basic",
+ "roleDetail.modifiedAria": "modified",
+ "roleDetail.deleteTitle": "Delete role",
+ "roleDetail.deleteBodyPre": "This permanently removes ",
+ "roleDetail.deleteBodyPost": ". Members currently assigned will lose its permissions immediately.",
+ "roleDetail.deleting": "Deleting…",
+ "roleDetail.deleteConfirm": "Delete role",
+ "roleDetail.toastUpdateFailed": "Update failed",
+ "roleDetail.toastPermsFailed": "Permissions update failed",
+ "roleDetail.toastDeleted": "Role deleted",
+ "roleDetail.toastDeleteFailed": "Delete failed",
+ "roleDetail.toastSaved": "Role saved",
+
+ "groupDetail.back": "Back to groups",
+ "groupDetail.notFound": "Group not found.",
+ "groupDetail.default": "Default",
+ "groupDetail.system": "System",
+ "groupDetail.subtitle": "Group cohort.",
+ "groupDetail.deleteGroup": "Delete group",
+ "groupDetail.memberLabel_one": "member",
+ "groupDetail.memberLabel_other": "members",
+ "groupDetail.roleLabel_one": "role",
+ "groupDetail.roleLabel_other": "roles",
+ "groupDetail.detailsTitle": "Group details",
+ "groupDetail.detailsDescription": "Name, description, and the roles attached to this group.",
+ "groupDetail.discard": "Discard",
+ "groupDetail.saving": "Saving…",
+ "groupDetail.saveChanges": "Save changes",
+ "groupDetail.descPlaceholder": "Short summary of who or what this group represents",
+ "groupDetail.defaultLabel": "Default group",
+ "groupDetail.defaultHint": "Auto-assign to newly registered users.",
+ "groupDetail.rolesAttached": "Roles attached",
+ "groupDetail.noRolesPre": "No roles defined. ",
+ "groupDetail.noRolesLink": "Create one",
+ "groupDetail.noRolesPost": " first.",
+ "groupDetail.attachAria": "Attach {{name}}",
+ "groupDetail.membersTitle": "Members",
+ "groupDetail.membersDescription": "Users who belong to this group inherit every role attached above.",
+ "groupDetail.addMembers": "Add members",
+ "groupDetail.emptyMembersPre": "No one in this group yet. Click ",
+ "groupDetail.emptyMembersStrong": "Add members",
+ "groupDetail.emptyMembersPost": " to attach users.",
+ "groupDetail.remove": "Remove",
+ "groupDetail.unknownUser": "Unknown user",
+ "groupDetail.deleteTitle": "Delete group",
+ "groupDetail.deleteBodyPost": " will be removed. Members will lose any permissions inherited through this group.",
+ "groupDetail.deleting": "Deleting…",
+ "groupDetail.deleteConfirm": "Delete group",
+ "groupDetail.toastUpdated": "Group updated",
+ "groupDetail.toastUpdateFailed": "Update failed",
+ "groupDetail.toastDeleted": "Group deleted",
+ "groupDetail.toastDeleteFailed": "Delete failed",
+ "groupDetail.toastMemberRemoved": "Member removed",
+ "groupDetail.toastRemoveFailed": "Remove failed",
+ "groupDetail.pickTitle": "Pick members to add",
+ "groupDetail.pickDescription": "Search active users and select one or more to attach to this group.",
+ "groupDetail.searchPlaceholder": "Search by name, username, or email…",
+ "groupDetail.clearSearch": "Clear search",
+ "groupDetail.noMatch": "No users match \"{{term}}\".",
+ "groupDetail.noUsers": "No users available.",
+ "groupDetail.alreadyIn": "already in",
+ "groupDetail.selected": "{{count}} selected",
+ "groupDetail.adding": "Adding…",
+ "groupDetail.addN": "Add {{count}}",
+ "groupDetail.toastAdded_one": "Added {{count}} member",
+ "groupDetail.toastAdded_other": "Added {{count}} members",
+ "groupDetail.toastAddedDupes": " · {{count}} already present",
+ "groupDetail.toastAddFailed": "Add failed"
+}
diff --git a/clients/dashboard/src/locales/en-US/notifications.json b/clients/dashboard/src/locales/en-US/notifications.json
new file mode 100644
index 0000000000..0b4f3b223f
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/notifications.json
@@ -0,0 +1,32 @@
+{
+ "bell.aria": "Notifications",
+ "bell.ariaUnread": "Notifications, {{count}} unread",
+ "bell.title": "Notifications",
+ "header.title": "Notifications",
+ "header.allCaughtUp": "All caught up",
+ "header.summary": "{{unread}} unread · {{loaded}} loaded",
+ "markAllRead": "Mark all read",
+ "recent": "Recent",
+ "loading": "Loading…",
+ "empty": "Nothing yet. Mentions and channel updates will appear here.",
+ "settings": "Settings ↗",
+ "rel.justNow": "just now",
+ "rel.minutes": "{{n}}m",
+ "rel.hours": "{{n}}h",
+ "rel.days": "{{n}}d",
+ "rel.weeks": "{{n}}w",
+ "toast.channelFallback": "channel",
+ "toast.aChannel": "a channel",
+ "toast.directMessage": "direct message",
+ "toast.groupChat": "group chat",
+ "toast.justNow": "just now",
+ "toast.openConversation": "Open conversation",
+ "toast.emptyBody": "(attachment or empty body)",
+ "toast.dismissAria": "Dismiss notification",
+ "toast.dismiss": "Dismiss",
+ "badge.chat": "Chat",
+ "badge.ariaUnread_one": "Chat, {{count}} unread message",
+ "badge.ariaUnread_other": "Chat, {{count}} unread messages",
+ "badge.titleUnread_one": "{{count}} unread chat message",
+ "badge.titleUnread_other": "{{count}} unread chat messages"
+}
diff --git a/clients/dashboard/src/locales/en-US/overview.json b/clients/dashboard/src/locales/en-US/overview.json
new file mode 100644
index 0000000000..9b759ef4af
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/overview.json
@@ -0,0 +1,108 @@
+{
+ "greeting.morning": "Good morning, {{name}}",
+ "greeting.afternoon": "Good afternoon, {{name}}",
+ "greeting.evening": "Good evening, {{name}}",
+ "operator": "operator",
+ "yourTenant": "your tenant",
+ "refresh": "Refresh",
+ "viewActivity": "View activity",
+ "viewAudits": "View audits",
+ "open": "Open",
+ "seeAll": "See all",
+
+ "stat.plan": "Plan",
+ "stat.validFor": "Valid for",
+ "stat.resources": "Resources",
+ "stat.liveEvents": "Live events",
+ "stat.unavailable": "Unavailable",
+ "stat.noSubscription": "No subscription",
+ "stat.status.Active": "Active",
+ "stat.status.Suspended": "Suspended",
+ "stat.status.Cancelled": "Cancelled",
+
+ "connState.idle": "idle",
+ "connState.connecting": "connecting",
+ "connState.connected": "connected",
+ "connState.reconnecting": "reconnecting",
+ "connState.error": "error",
+
+ "validity.expired": "Expired",
+ "validity.openEnded": "Open-ended",
+ "validity.days": "days",
+ "validity.statusUnavailable": "Status unavailable",
+ "validity.renew": "Contact your operator to renew",
+ "validity.graceEnds": "grace ends {{date}}",
+ "validity.inGracePeriod": "in grace period",
+ "validity.until": "until {{date}}",
+ "validity.noEndDate": "no end date",
+
+ "resources.avgUtilization": "avg utilization",
+ "resources.overage": "overage",
+
+ "subscription.loadErrorTitle": "Couldn't load subscription",
+ "subscription.loadErrorBody": "The subscription endpoint returned an error. Try refreshing.",
+ "subscription.noneTitle": "No active subscription",
+ "subscription.noneBody": "Pick a plan to enable billing, quotas, and overage tracking.",
+ "subscription.viewCta": "View subscription",
+ "subscription.started": "Started",
+ "subscription.ends": "Ends",
+ "subscription.openEnded": "open-ended",
+ "subscription.currentTerm": "Current term",
+ "subscription.termProgress": "{{pct}}% · {{days}}d left",
+
+ "system.streamLive": "Stream live",
+ "system.offline": "offline",
+ "system.liveDesc": "Backend events are flowing in real time.",
+ "system.erroredDesc": "Stream disconnected. Events will queue once it recovers.",
+ "system.waitingDesc": "Waiting for the stream to come online.",
+ "system.eventsThisSession": "Events this session",
+
+ "audits.emptyTitle": "No recent activity",
+ "audits.emptyBody": "Audited actions in the last 24 hours will appear here.",
+ "audits.eventFallback": "Event",
+ "audits.systemActor": "system",
+ "audits.ago": "{{value}} ago",
+
+ "quick.inviteUsers.title": "Invite users",
+ "quick.inviteUsers.description": "Add teammates, assign roles.",
+ "quick.browseCatalog.title": "Browse catalog",
+ "quick.browseCatalog.description": "Products, brands, categories.",
+ "quick.subscription.title": "Subscription",
+ "quick.subscription.description": "Plan, usage, invoices.",
+ "quick.liveActivity.title": "Live activity",
+ "quick.liveActivity.description": "Real-time event stream.",
+
+ "liveFeed.emptyTitle": "Listening for activity",
+ "liveFeed.emptyBody": "Events will appear here as the backend publishes them.",
+
+ "firstRun.dismissAria": "Dismiss setup checklist",
+ "firstRun.skipTitle": "Skip for now",
+ "firstRun.welcome": "Welcome to {{name}}",
+ "firstRun.subtitle": "Your tenant is provisioned and ready. Here's where most teams start.",
+ "firstRun.step": "Step {{step}}",
+ "firstRun.tile.plan.title": "Pick a plan",
+ "firstRun.tile.plan.description": "Enable billing, quotas, and overage tracking.",
+ "firstRun.tile.team.title": "Invite your team",
+ "firstRun.tile.team.description": "Add teammates, assign roles, and group them.",
+ "firstRun.tile.catalog.title": "Browse catalog",
+ "firstRun.tile.catalog.description": "Sample products, brands, categories ready to go.",
+ "firstRun.tile.watch.title": "Watch live",
+ "firstRun.tile.watch.description": "SSE stream right into the dashboard.",
+
+ "section.subscription": "Subscription",
+ "section.systemStatus": "System status",
+ "section.recentAudits": "Recent audits",
+ "section.recentAuditsDesc": "Last 24 hours, top 5 events.",
+ "section.usage": "Usage by resource",
+ "section.usageDesc": "Current-month consumption against plan limits.",
+ "section.quickActions": "Quick actions",
+ "section.quickActionsDesc": "Jump into the most-used destinations.",
+ "section.liveFeed": "Live feed",
+ "section.liveFeedDesc": "Real-time backend events over SSE.",
+
+ "usage.errorTitle": "Couldn't load usage",
+ "usage.errorBody": "The usage endpoint returned an error. Try refreshing.",
+ "usage.emptyTitle": "No usage captured yet",
+ "usage.emptyBody": "Activity will appear here as the backend records snapshots for this period.",
+ "usageOverageBadge": "overage"
+}
diff --git a/clients/dashboard/src/locales/en-US/settings.json b/clients/dashboard/src/locales/en-US/settings.json
new file mode 100644
index 0000000000..5b44d4b8ab
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/settings.json
@@ -0,0 +1,219 @@
+{
+ "title": "Settings",
+ "sections": "Sections",
+ "sectionsNav": "Settings sections",
+ "tab.profile.label": "Profile",
+ "tab.profile.hint": "Your identity across the tenant",
+ "tab.security.label": "Security",
+ "tab.security.hint": "Password and active sessions",
+ "tab.appearance.label": "Appearance",
+ "tab.appearance.hint": "Theme and visual preferences",
+ "tab.branding.label": "Branding",
+ "tab.branding.hint": "Tenant colours and logos",
+ "tab.notifications.label": "Notifications",
+ "tab.notifications.hint": "How we reach you",
+ "tab.apiKeys.label": "API keys",
+ "tab.apiKeys.hint": "Personal access tokens",
+
+ "profile.toastSaved": "Profile saved",
+ "profile.saveError": "Failed to save profile",
+ "profile.toastSaveFail": "Save failed",
+ "profile.imageUpdated": "Profile image updated",
+ "profile.imageUpdateError": "Failed to update profile image",
+ "profile.loadError": "Couldn't load your profile. Showing details from your session; saved changes may not reflect the latest server state.",
+ "profile.photo.title": "Photo",
+ "profile.photo.description": "Shown in the topbar and on your activity. Square crops work best — JPG, PNG, or WebP.",
+ "profile.identity.title": "Identity",
+ "profile.identity.description": "Your name and contact details, visible across the dashboard.",
+ "profile.reset": "Reset",
+ "profile.saving": "Saving…",
+ "profile.save": "Save changes",
+ "profile.field.firstName": "First name",
+ "profile.field.lastName": "Last name",
+ "profile.field.email": "Email",
+ "profile.field.phone": "Phone",
+ "profile.emailHint": "Contact your tenant admin to change your sign-in email.",
+ "profile.phonePlaceholder": "+1 (555) 123-4567",
+ "profile.subject.title": "Subject identifier",
+ "profile.subject.description": "The unique ID this account uses inside the platform. Read-only.",
+
+ "notifications.title": "Notification preferences",
+ "notifications.description": "Choose which events you want delivered as email or in-app notifications.",
+ "notifications.emptyTitle": "Per-event preferences aren't tunable yet.",
+ "notifications.emptyBody": "In-app notifications are already delivered to your bell — open it from the top bar to see them. Granular per-event email opt-ins are on the v1.1 roadmap. In the meantime, your tenant admin can disable email delivery globally.",
+ "notifications.openBell": "Open notifications bell",
+
+ "apiKeys.title": "API keys",
+ "apiKeys.description": "Long-lived credentials used by services to call the FSH API. Treat them like passwords.",
+ "apiKeys.emptyTitle": "API keys aren't available yet.",
+ "apiKeys.emptyBody": "Personal access tokens and service-to-service API keys are on the v1.1 roadmap. For now, your access flows through the user-bound JWT issued at sign-in. Track progress on the public roadmap or watch the next release notes.",
+ "apiKeys.viewRoadmap": "View roadmap",
+
+ "appearance.theme.title": "Theme",
+ "appearance.theme.description": "Pick a colour mode for the dashboard. System follows your OS.",
+ "appearance.theme.light": "Light",
+ "appearance.theme.lightBlurb": "Bright canvas, day-shift comfort.",
+ "appearance.theme.system": "System",
+ "appearance.theme.systemBlurb": "Follow the OS preference.",
+ "appearance.theme.dark": "Dark",
+ "appearance.theme.darkBlurb": "Reduced glare for long sessions.",
+ "appearance.theme.ariaLabel": "{{label}} theme",
+ "appearance.active": "Active",
+ "appearance.accent.title": "Accent",
+ "appearance.accent.description": "Pick the brand colour used for primary actions, charts, and highlights across the dashboard.",
+ "appearance.accent.ariaLabel": "{{label}} accent",
+ "appearance.font.title": "Font",
+ "appearance.font.description": "The UI typeface. Mono code blocks always use JetBrains Mono.",
+ "appearance.font.ariaLabel": "{{label}} font",
+ "appearance.density.title": "Density",
+ "appearance.density.description": "Compact mode reduces card padding and row height for data-dense screens.",
+ "appearance.density.toggle": "Use compact spacing across the dashboard.",
+ "appearance.density.ariaLabel": "Compact density",
+ "appearance.motion.title": "Motion",
+ "appearance.motion.descPre": "Override the system",
+ "appearance.motion.descPost": "setting.",
+ "appearance.motion.toggle": "Disable transitions and decorative animations.",
+ "appearance.motion.ariaLabel": "Reduce motion",
+ "appearance.custom.ariaLabel": "Custom accent",
+ "appearance.custom.title": "Custom accent — click to edit",
+ "appearance.custom.label": "Custom",
+ "appearance.custom.dialogTitle": "Pick your brand colour",
+ "appearance.custom.dialogDescription": "Drag the hue ribbon to recolour the accent. Saturation scales chroma uniformly across the eleven brand stops.",
+ "appearance.custom.hue": "Hue",
+ "appearance.custom.saturation": "Saturation",
+ "appearance.custom.brandLadder": "Brand ladder",
+ "appearance.custom.preview": "Preview",
+ "appearance.custom.previewSubscription": "Subscription",
+ "appearance.custom.previewActive": "active",
+ "appearance.custom.primaryAction": "Primary action",
+ "appearance.custom.cancel": "Cancel",
+ "appearance.custom.apply": "Apply accent",
+
+ "security.sessions.unknownBrowser": "Unknown browser",
+ "security.sessions.unknownOs": "Unknown OS",
+ "security.sessions.unknownIp": "unknown ip",
+ "security.sessions.toastRevoked": "Session revoked",
+ "security.sessions.revokeError": "Could not revoke session.",
+ "security.sessions.toastRevokedCount_one": "Revoked {{count}} session",
+ "security.sessions.toastRevokedCount_other": "Revoked {{count}} sessions",
+ "security.sessions.revokeAllError": "Could not revoke sessions.",
+ "security.sessions.loadError": "Failed to load sessions.",
+ "security.sessions.title": "Active sessions",
+ "security.sessions.activeCount_one": "{{count}} active",
+ "security.sessions.activeCount_other": "{{count}} active",
+ "security.sessions.description": "Browsers and devices currently signed in to your account.",
+ "security.sessions.signOutOthers": "Sign out everywhere else",
+ "security.sessions.emptyTitle": "No sessions tracked",
+ "security.sessions.emptyBody": "Session activity will appear here once you sign in from any device.",
+ "security.sessions.thisDevice": "this device",
+ "security.sessions.revokedBadge": "revoked",
+ "security.sessions.meta": "{{ip}} · last activity {{lastActivity}} · expires {{expires}}",
+ "security.sessions.revoking": "Revoking…",
+ "security.sessions.revoke": "Revoke",
+
+ "security.password.title": "Password",
+ "security.password.description": "Used to sign in to this tenant. Choose a strong, unique password.",
+ "security.password.recommendation": "We recommend a passphrase of 16+ characters with no reuse from other services.",
+ "security.password.change": "Change password",
+ "security.password.hide": "Hide password",
+ "security.password.show": "Show password",
+ "security.password.toastSuccess": "Password changed",
+ "security.password.toastSuccessDesc": "Other active sessions remain valid until you revoke them.",
+ "security.password.changeError": "Could not change password.",
+ "security.password.dialogDescription": "Pick a strong password you don't reuse elsewhere. Your other signed-in sessions stay active — revoke them below if you want them out.",
+ "security.password.current": "Current password",
+ "security.password.new": "New password",
+ "security.password.confirm": "Confirm new password",
+ "security.password.match": "Passwords match",
+ "security.password.mismatchYet": "Passwords don't match yet",
+ "security.password.cancel": "Cancel",
+ "security.password.updating": "Updating…",
+ "security.password.update": "Update password",
+
+ "security.validation.min8": "New password must be at least 8 characters.",
+ "security.validation.mismatch": "Passwords don't match.",
+ "security.validation.mustDiffer": "New password must differ from the current one.",
+
+ "security.strength.weak": "Weak",
+ "security.strength.fair": "Fair",
+ "security.strength.good": "Good",
+ "security.strength.strong": "Strong",
+
+ "security.twoFa.title": "Two-factor authentication",
+ "security.twoFa.badgeOn": "enabled",
+ "security.twoFa.badgeOff": "disabled",
+ "security.twoFa.description": "Require a one-time code from an authenticator app on every sign-in.",
+ "security.twoFa.enrollFailed": "Enrollment failed",
+ "security.twoFa.enrollStartError": "Could not start enrollment.",
+ "security.twoFa.enabledToast": "Two-factor enabled",
+ "security.twoFa.enabledToastDesc": "Future logins require a 6-digit code from your authenticator.",
+ "security.twoFa.verifyFailed": "Verification failed",
+ "security.twoFa.verifyMismatch": "That code didn't match. Try again.",
+ "security.twoFa.verifyError": "Could not verify code.",
+ "security.twoFa.generating": "Generating…",
+ "security.twoFa.enable": "Enable two-factor",
+ "security.twoFa.enrollHint": "You'll scan a QR code in your authenticator app (1Password, Google Authenticator, Authy, …).",
+ "security.twoFa.qrAlt": "Two-factor QR code",
+ "security.twoFa.rendering": "Rendering…",
+ "security.twoFa.manualEntry": "can't scan? enter manually",
+ "security.twoFa.copied": "copied",
+ "security.twoFa.copy": "copy",
+ "security.twoFa.codeLabel": "6-digit code from your app",
+ "security.twoFa.codePlaceholder": "123 456",
+ "security.twoFa.verifying": "Verifying…",
+ "security.twoFa.confirmEnable": "Confirm & enable",
+ "security.twoFa.cancel": "Cancel",
+ "security.twoFa.disabledToast": "Two-factor disabled",
+ "security.twoFa.disableFailed": "Disable failed",
+ "security.twoFa.disableWrongPassword": "Password verification failed.",
+ "security.twoFa.disableError": "Could not disable two-factor.",
+ "security.twoFa.disableDescription": "Two-factor is currently enabled. Confirm your password to disable — this rotates the authenticator secret, so a fresh enroll will generate a new QR code.",
+ "security.twoFa.disabling": "Disabling…",
+ "security.twoFa.disable": "Disable two-factor",
+
+ "branding.title": "Branding",
+ "branding.description": "Theme tokens consumed by your tenant's apps on sign-in. Live preview reflects the primary action with the chosen palette.",
+ "branding.loadingDescription": "Loading branding…",
+ "branding.loading": "Loading branding",
+ "branding.toastSaved": "Branding saved",
+ "branding.saveFailed": "Save failed",
+ "branding.toastReset": "Branding reset to defaults",
+ "branding.resetFailed": "Reset failed",
+ "branding.unknownError": "Unknown error",
+ "branding.badgeDefault": "default",
+ "branding.badgeUnsaved": "unsaved",
+ "branding.resetAriaLabel": "Reset branding to defaults",
+ "branding.resetting": "Resetting…",
+ "branding.resetToDefaults": "Reset to defaults",
+ "branding.saving": "Saving…",
+ "branding.save": "Save branding",
+ "branding.lightPreview": "Light preview",
+ "branding.lightPalette": "Light palette",
+ "branding.darkPalette": "Dark palette",
+ "branding.resetPalette": "Reset palette",
+ "branding.pickColor": "Pick {{label}} color",
+ "branding.colorAriaLabel": "{{label}} color",
+ "branding.field.primary": "Primary",
+ "branding.field.secondary": "Secondary",
+ "branding.field.tertiary": "Tertiary",
+ "branding.field.background": "Background",
+ "branding.field.surface": "Surface",
+ "branding.field.error": "Error",
+ "branding.field.warning": "Warning",
+ "branding.field.success": "Success",
+ "branding.field.info": "Info",
+ "branding.assets.title": "Brand assets",
+ "branding.assets.description": "URLs to your hosted brand assets. Upload via the Files module first, then paste the resulting public URL here.",
+ "branding.assets.logo": "Logo URL",
+ "branding.assets.logoDark": "Logo URL (dark mode)",
+ "branding.assets.favicon": "Favicon URL",
+ "branding.assets.placeholder": "https://cdn.example.com/logo.svg",
+ "branding.preview.live": "live",
+ "branding.preview.samplePage": "Sample tenant page",
+ "branding.preview.active": "active",
+ "branding.preview.body": "A short paragraph rendered with the chosen body color over the chosen surface, on the chosen page background. Action buttons use the primary token.",
+ "branding.preview.primaryAction": "Primary action",
+ "branding.preview.secondary": "Secondary",
+ "branding.preview.warn": "warn",
+ "branding.preview.error": "error"
+}
diff --git a/clients/dashboard/src/locales/en-US/subscription.json b/clients/dashboard/src/locales/en-US/subscription.json
new file mode 100644
index 0000000000..678f89cf82
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/subscription.json
@@ -0,0 +1,125 @@
+{
+ "title": "Subscription",
+ "description": "Your tenant's plan, validity, usage, and recent invoices.",
+
+ "plan.title": "Plan",
+ "plan.noneTitle": "No active subscription",
+ "plan.noneBody": "Your tenant has no plan assigned. Contact your operator to enable billing, quotas, and overage tracking.",
+ "plan.started": "Started",
+ "plan.ends": "Ends",
+ "plan.openEnded": "open-ended",
+ "plan.changesNote": "Plan changes are operator-driven. Contact your operator to upgrade, renew, or cancel.",
+ "plan.status.Active": "Active",
+ "plan.status.Suspended": "Suspended",
+ "plan.status.Cancelled": "Cancelled",
+
+ "validity.title": "Validity",
+ "validity.unavailable": "Tenant status is unavailable right now.",
+ "validity.inactive": "Inactive",
+ "validity.validUntil": "Valid until",
+ "validity.graceEnds": "Grace ends",
+ "validity.state.inGrace": "In grace",
+ "validity.state.expired": "Expired",
+ "validity.state.active": "Active",
+ "validity.state.unknown": "Unknown",
+
+ "usage.title": "Usage by resource",
+ "usage.description": "Current-month consumption against your plan limits.",
+ "usage.loadError": "Couldn't load usage. Try refreshing.",
+ "usage.emptyTitle": "No usage captured yet",
+ "usage.emptyBody": "Consumption will appear here as snapshots are recorded for this period.",
+
+ "recent.title": "Recent invoices",
+ "recent.description": "Your five most recent invoices.",
+ "recent.seeAll": "See all",
+ "recent.loadError": "Couldn't load invoices. Try refreshing.",
+
+ "invoices.title": "Invoices",
+ "invoices.unit": "invoice",
+ "invoices.description": "Your tenant's billing history, newest first.",
+ "invoices.searchPlaceholder": "Search by invoice number, status, or period…",
+ "invoices.loadError": "Failed to load invoices.",
+ "invoices.clearSearch": "Clear search",
+ "invoices.empty.foundTitle": "No invoices found",
+ "invoices.empty.emptyTitle": "No invoices yet",
+ "invoices.empty.foundBody": "Nothing matches \"{{term}}\". Try a different term or clear the search.",
+ "invoices.empty.emptyBody": "Once your tenant has been billed for a period, invoices will appear here.",
+ "invoices.matched_one": "{{count}} invoice matched on this page",
+ "invoices.matched_other": "{{count}} invoices matched on this page",
+ "invoices.showing_one": "Showing {{shown}} of {{count}} invoice",
+ "invoices.showing_other": "Showing {{shown}} of {{count}} invoices",
+ "invoices.pageSuffix": " · page {{page}} of {{totalPages}}",
+ "invoices.col.number": "Invoice #",
+ "invoices.col.customer": "Customer",
+ "invoices.col.amount": "Amount",
+ "invoices.col.status": "Status",
+ "invoices.col.dueDate": "Due date",
+ "invoices.period": "period {{period}}",
+ "invoices.due": "due {{date}}",
+ "invoices.paidOn": "paid {{date}}",
+ "invoices.status.Draft": "Draft",
+ "invoices.status.Issued": "Issued",
+ "invoices.status.Paid": "Paid",
+ "invoices.status.Void": "Void",
+
+ "wallet.title": "WhatsApp wallet",
+ "wallet.description": "Your prepaid balance for WhatsApp messaging. Request a top-up and we'll raise an invoice to credit your wallet.",
+ "wallet.currentBalance": "Current balance",
+ "wallet.lowEmpty": "Your wallet is empty. Request a top-up to keep sending WhatsApp messages.",
+ "wallet.lowSoon": "Your balance is running low. Consider requesting a top-up soon.",
+ "wallet.requestTitle": "Request a top-up",
+ "wallet.amountLabel": "Amount",
+ "wallet.amountHint": "How much to add to your wallet, in {{currency}}.",
+ "wallet.amountPlaceholder": "100.00",
+ "wallet.noteLabel": "Note",
+ "wallet.noteHint": "Optional — anything we should know about this top-up.",
+ "wallet.notePlaceholder": "e.g. budget for the June campaign",
+ "wallet.submitting": "Submitting…",
+ "wallet.submit": "Request top-up",
+ "wallet.toastRequested": "Top-up requested",
+ "wallet.toastRequestedDesc": "We'll raise an invoice to credit your wallet shortly.",
+ "wallet.toastFailed": "Top-up request failed",
+ "wallet.requestsTitle": "My top-up requests",
+ "wallet.emptyTitle": "No top-up requests yet",
+ "wallet.emptyBody": "Once you request a top-up it will appear here with its status — pending, invoiced, or completed.",
+ "wallet.col.requested": "Requested",
+ "wallet.col.amount": "Amount",
+ "wallet.col.status": "Status",
+ "wallet.col.invoice": "Invoice",
+ "wallet.invoiceLink": "Invoice",
+ "wallet.viewInvoice": "View invoice",
+ "wallet.status.Pending": "Pending",
+ "wallet.status.Invoiced": "Invoiced",
+ "wallet.status.Completed": "Completed",
+ "wallet.status.Rejected": "Rejected",
+ "wallet.status.Cancelled": "Cancelled",
+
+ "invoiceDetail.back": "Back to invoices",
+ "invoiceDetail.downloadFailed": "Download failed",
+ "invoiceDetail.periodLabel": "Period {{period}}",
+ "invoiceDetail.preparing": "Preparing…",
+ "invoiceDetail.downloadPdf": "Download PDF",
+ "invoiceDetail.lineItems": "Line items",
+ "invoiceDetail.details": "Details",
+ "invoiceDetail.notes": "Notes",
+ "invoiceDetail.noLineItems": "No line items",
+ "invoiceDetail.noLineItemsBody": "This invoice has no itemized charges.",
+ "invoiceDetail.col.description": "Description",
+ "invoiceDetail.col.qty": "Qty",
+ "invoiceDetail.col.unitPrice": "Unit price",
+ "invoiceDetail.col.amount": "Amount",
+ "invoiceDetail.total": "Total",
+ "invoiceDetail.row.status": "Status",
+ "invoiceDetail.row.currency": "Currency",
+ "invoiceDetail.row.period": "Period",
+ "invoiceDetail.row.created": "Created",
+ "invoiceDetail.row.issued": "Issued",
+ "invoiceDetail.row.due": "Due",
+ "invoiceDetail.row.paid": "Paid",
+ "invoiceDetail.row.voided": "Voided",
+ "invoiceDetail.row.periodStart": "Period start",
+ "invoiceDetail.row.periodEnd": "Period end",
+ "invoiceDetail.notFoundTitle": "Invoice not found",
+ "invoiceDetail.notFoundBody": "It may not belong to your tenant, or the link may be wrong.",
+ "invoiceDetail.notFoundBack": "Back to invoices"
+}
diff --git a/clients/dashboard/src/locales/en-US/system.json b/clients/dashboard/src/locales/en-US/system.json
new file mode 100644
index 0000000000..94a62ae918
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/system.json
@@ -0,0 +1,80 @@
+{
+ "sessions.title": "Active sessions",
+ "sessions.description": "Every browser and device currently signed in to {{tenant}}. Refreshes every 30 seconds.",
+ "sessions.thisTenant": "this tenant",
+ "sessions.refresh": "Refresh",
+ "sessions.searchPlaceholder": "Find by user, email, or IP…",
+ "sessions.filter.visibility": "Visibility",
+ "sessions.filter.liveOnly": "Live only",
+ "sessions.filter.includeInactive": "Include inactive",
+ "sessions.stats.active": "{{n}} active",
+ "sessions.stats.users": "{{n}} users",
+ "sessions.stats.mobile": "{{n}} mobile",
+ "sessions.empty.foundTitle": "No sessions found",
+ "sessions.empty.activeTitle": "No active sessions",
+ "sessions.empty.searchBody": "Nothing matches \"{{term}}\". Try a different term, or toggle Include inactive to see expired sessions.",
+ "sessions.empty.body": "Sessions appear when users sign in. Toggle Include inactive to see expired and revoked sessions.",
+ "sessions.empty.clearSearch": "Clear search",
+ "sessions.empty.refresh": "Refresh",
+ "sessions.found_one": "{{count}} session found",
+ "sessions.found_other": "{{count}} sessions found",
+ "sessions.col.user": "User",
+ "sessions.col.device": "Device",
+ "sessions.col.ip": "IP",
+ "sessions.col.lastActivity": "Last activity",
+ "sessions.col.actions": "Actions",
+ "sessions.badge.you": "You",
+ "sessions.badge.inactive": "Inactive",
+ "sessions.unknownUser": "Unknown user",
+ "sessions.unknownBrowser": "Unknown browser",
+ "sessions.allDevices": "All devices",
+ "sessions.all": "All",
+ "sessions.revoke": "Revoke",
+ "sessions.revoking": "Revoking…",
+ "sessions.revokeAllTitle": "Sign this user out of all devices",
+ "sessions.toast.revoked": "Session revoked",
+ "sessions.toast.revokeError": "Could not revoke session.",
+ "sessions.toast.revokedAll_one": "Revoked {{count}} session",
+ "sessions.toast.revokedAll_other": "Revoked {{count}} sessions",
+ "sessions.toast.revokeAllError": "Could not revoke sessions.",
+ "trash.title": "Recycle bin",
+ "trash.description": "Soft-deleted records, kept indefinitely until you restore them. Restoring a row brings it back to its parent list with the same ID and history intact.",
+ "trash.noAccessTitle": "No recycle bins available",
+ "trash.noAccessBody": "You don't have permission to restore deleted records in this tenant. Ask an administrator if you think you should.",
+ "trash.sections": "Trash sections",
+ "trash.tab.products": "Products",
+ "trash.tab.brands": "Brands",
+ "trash.tab.categories": "Categories",
+ "trash.tab.tickets": "Tickets",
+ "trash.tab.files": "Files",
+ "trash.count": "{{n}} {{entity}} in trash",
+ "trash.emptyTitle": "The {{entity}} trash is empty",
+ "trash.emptyBody": "Soft-deleted {{entity}} land here for as long as you want — no automatic purge. Anything you remove from the main list can be recovered.",
+ "trash.backTo": "Back to {{entity}}",
+ "trash.col.entity": "Entity",
+ "trash.col.deletedBy": "Deleted by",
+ "trash.col.deletedAt": "Deleted at",
+ "trash.col.actions": "Actions",
+ "trash.action.restore": "Restore",
+ "trash.action.restoring": "Restoring…",
+ "trash.by": "by {{id}}…",
+ "trash.restore.title": "Restore {{entity}}?",
+ "trash.restore.body": "will be moved back to its {{entity}} list with the same ID and history intact.",
+ "trash.restore.cancel": "Cancel",
+ "trash.restore.confirm": "Restore {{entity}}",
+ "trash.entityPlural.products": "products",
+ "trash.entityPlural.brands": "brands",
+ "trash.entityPlural.categories": "categories",
+ "trash.entityPlural.tickets": "tickets",
+ "trash.entityPlural.files": "files",
+ "trash.entitySingular.products": "product",
+ "trash.entitySingular.brands": "brand",
+ "trash.entitySingular.categories": "category",
+ "trash.entitySingular.tickets": "ticket",
+ "trash.entitySingular.files": "file",
+ "trash.toast.products": "Product restored",
+ "trash.toast.brands": "Brand restored",
+ "trash.toast.categories": "Category restored",
+ "trash.toast.tickets": "Ticket restored",
+ "trash.toast.files": "File restored"
+}
diff --git a/clients/dashboard/src/locales/en-US/tickets.json b/clients/dashboard/src/locales/en-US/tickets.json
new file mode 100644
index 0000000000..630066488d
--- /dev/null
+++ b/clients/dashboard/src/locales/en-US/tickets.json
@@ -0,0 +1,119 @@
+{
+ "status.Open": "Open",
+ "status.InProgress": "In progress",
+ "status.Resolved": "Resolved",
+ "status.Closed": "Closed",
+ "priority.Low": "Low",
+ "priority.Medium": "Medium",
+ "priority.High": "High",
+ "priority.Critical": "Critical",
+ "list.title": "Tickets",
+ "list.unit": "ticket",
+ "list.description": "Open work, ranked by priority. The desk where issues land, get owned, and ship.",
+ "list.new": "New ticket",
+ "list.searchPlaceholder": "Find by number, title, or description…",
+ "list.count_one": "{{count}} ticket found",
+ "list.count_other": "{{count}} tickets found",
+ "filter.status": "Status filter",
+ "filter.priority": "Priority filter",
+ "filter.all": "All",
+ "filter.any": "Any",
+ "col.subject": "Subject",
+ "col.priority": "Priority",
+ "col.status": "Status",
+ "col.assignee": "Assignee",
+ "col.updated": "Updated",
+ "empty.searchTitle": "No tickets found",
+ "empty.title": "No tickets yet",
+ "empty.searchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the filters.",
+ "empty.searchBody": "No tickets match the current filters.",
+ "empty.body": "Open the first ticket to start tracking work. Tickets carry a status, priority, an optional assignee, and a comment thread.",
+ "empty.clearFilters": "Clear filters",
+ "empty.openTicket": "Open ticket",
+ "aria.openTicket": "Open ticket {{title}}",
+ "row.unassigned": "Unassigned",
+ "create.toastOpened": "Ticket opened",
+ "create.title": "Open a ticket",
+ "create.descPrefix": "Tickets land on the desk as ",
+ "create.descSuffix": ". Assign one to start it; resolve it with a note when work is done.",
+ "create.fieldTitle": "Title",
+ "create.fieldDescription": "Description",
+ "create.fieldPriority": "Priority",
+ "create.placeholderTitle": "Briefly describe the issue or request",
+ "create.placeholderDescription": "Steps to reproduce, expected behavior, anything else useful",
+ "create.cancel": "Cancel",
+ "create.opening": "Opening…",
+ "create.submit": "Open ticket",
+ "detail.back": "Back to tickets",
+ "detail.hero.openedBy": "opened {{rel}} by {{name}}",
+ "detail.hero.refresh": "Refresh",
+ "detail.hero.reassign": "Reassign",
+ "detail.hero.assign": "Assign",
+ "detail.hero.resolve": "Resolve",
+ "detail.hero.reopen": "Reopen",
+ "detail.hero.toastReopened": "Ticket reopened",
+ "detail.stat.comment_one": "comment",
+ "detail.stat.comment_other": "comments",
+ "detail.stat.updated": "updated",
+ "detail.stat.resolved": "resolved",
+ "detail.meta.assignee": "Assignee:",
+ "detail.meta.reporter": "Reporter:",
+ "detail.meta.created": "Created:",
+ "detail.unassigned": "Unassigned",
+ "detail.description.title": "Description",
+ "detail.description.empty": "No description on file. Comments below carry the conversation.",
+ "detail.description.resolution": "Resolution",
+ "detail.comments.title": "Conversation",
+ "detail.comments.none": "No comments yet",
+ "detail.comments.count_one": "{{count}} comment",
+ "detail.comments.count_other": "{{count}} comments",
+ "detail.comments.toastPosted": "Comment posted",
+ "detail.comments.empty": "No comments yet. Add the first note below — visible to anyone with View access.",
+ "detail.comments.ariaAdd": "Add a comment",
+ "detail.comments.placeholderDisabled": "Reopen the ticket to add a new comment.",
+ "detail.comments.placeholder": "Add a comment to the thread…",
+ "detail.comments.charCount": "{{count}} / 8192",
+ "detail.comments.posting": "Posting…",
+ "detail.comments.post": "Post comment",
+ "detail.comments.self": "You",
+ "detail.properties.title": "Properties",
+ "detail.properties.reporter": "Reporter",
+ "detail.properties.assignee": "Assignee",
+ "detail.properties.status": "Status",
+ "detail.properties.priority": "Priority",
+ "detail.properties.created": "Created",
+ "detail.properties.updated": "Updated",
+ "detail.properties.resolved": "Resolved",
+ "detail.resolve.toast": "Ticket resolved",
+ "detail.resolve.desc": "Resolution notes are kept on the ticket and shown in the description panel. Leave blank if there's nothing to add.",
+ "detail.resolve.fieldNote": "Resolution note",
+ "detail.resolve.hintNote": "Optional — leave blank if there's nothing to add.",
+ "detail.resolve.placeholder": "What changed? Root cause? Anything reviewers should know.",
+ "detail.resolve.cancel": "Cancel",
+ "detail.resolve.resolving": "Resolving…",
+ "detail.resolve.submit": "Mark resolved",
+ "detail.assign.toastAssigned": "Ticket assigned",
+ "detail.assign.toastUnassigned": "Ticket unassigned",
+ "detail.assign.descPrefix": "Paste a user ID. Picking up the ticket transitions it to ",
+ "detail.assign.descMid": "; clearing the assignee on an in-progress ticket sends it back to ",
+ "detail.assign.descSuffix": ".",
+ "detail.assign.fieldAssignee": "Assignee",
+ "detail.assign.hintAssignee": "Search by name or email. Clear the selection to unassign.",
+ "detail.assign.warnPrefix": "Clearing the assignee will ",
+ "detail.assign.warnEmphasis": "unassign",
+ "detail.assign.warnSuffix": " the ticket.",
+ "detail.assign.cancel": "Cancel",
+ "detail.assign.saving": "Saving…",
+ "detail.assign.submit": "Save assignment",
+ "detail.notFound.title": "This ticket no longer exists.",
+ "detail.notFound.body": "It may have been deleted. Check the trash, or head back to the tickets desk.",
+ "detail.notFound.back": "Back to tickets",
+ "detail.notFound.desk": "Tickets desk",
+ "userPicker.searchPlaceholder": "Search by name or email…",
+ "userPicker.searchReassign": "Search to reassign…",
+ "userPicker.clear": "Clear",
+ "userPicker.clearSelection": "Clear selection",
+ "userPicker.searching": "Searching for \"{{term}}\"…",
+ "userPicker.noMatch": "No users match \"{{term}}\".",
+ "userPicker.results": "Search results"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/activity.json b/clients/dashboard/src/locales/pt-BR/activity.json
new file mode 100644
index 0000000000..d2e426643f
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/activity.json
@@ -0,0 +1,21 @@
+{
+ "title": "Atividade ao vivo",
+ "unit": "evento",
+ "description": "Log completo de eventos transmitido pela API via Server-Sent Events.",
+ "ariaLabel": "Eventos de atividade",
+ "status.streaming": "transmitindo",
+ "status.offline": "offline",
+ "status.idle": "ocioso",
+ "status.connecting": "conectando",
+ "status.reconnecting": "reconectando",
+ "shown_one": "{{count}} evento exibido",
+ "shown_other": "{{count}} eventos exibidos",
+ "totalCount": "{{total}} no total",
+ "empty.listeningTitle": "Aguardando atividade",
+ "empty.noEventsTitle": "Nenhum evento ainda",
+ "empty.listeningBody": "O stream está aberto. Os eventos aparecerão aqui conforme o backend os publica.",
+ "empty.offlineBody": "O stream de atividade não está conectado. Os eventos entrarão na fila assim que a conexão ficar online.",
+ "col.action": "Ação",
+ "col.entity": "Entidade",
+ "col.time": "Horário"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/audits.json b/clients/dashboard/src/locales/pt-BR/audits.json
new file mode 100644
index 0000000000..da83a60e88
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/audits.json
@@ -0,0 +1,93 @@
+{
+ "eventType.none": "Desconhecido",
+ "eventType.entity": "Entidade",
+ "eventType.security": "Segurança",
+ "eventType.activity": "Atividade",
+ "eventType.exception": "Exceção",
+ "severity.none": "—",
+ "severity.trace": "Trace",
+ "severity.debug": "Debug",
+ "severity.info": "Info",
+ "severity.warn": "Aviso",
+ "severity.error": "Erro",
+ "severity.critical": "Crítico",
+ "tag.piiMasked": "PII mascarada",
+ "tag.quotaExceeded": "Cota excedida",
+ "tag.sampled": "Amostrado",
+ "tag.retained": "Retido",
+ "tag.healthCheck": "Health check",
+ "tag.auth": "Autenticação",
+ "tag.authz": "Autorização",
+ "page.title": "Trilha de auditoria",
+ "page.unit": "evento",
+ "page.description": "Eventos de atividade, segurança, mudança de entidade e exceções em toda a plataforma. Janela imposta no servidor; máximo de 90 dias.",
+ "page.refresh": "Atualizar",
+ "page.searchPlaceholder": "Buscar payload, origem, usuário…",
+ "page.count_one": "{{count}} evento encontrado",
+ "page.count_other": "{{count}} eventos encontrados",
+ "page.loadError": "Falha ao carregar auditorias",
+ "empty.title": "Nenhuma auditoria nesta janela",
+ "empty.body": "Tente ampliar o intervalo de tempo ou afrouxar os filtros. Eventos de atividade chegam assim que a plataforma processa uma requisição.",
+ "empty.reset": "Redefinir filtros",
+ "col.actor": "Ator",
+ "col.event": "Evento",
+ "col.severity": "Severidade",
+ "col.timestamp": "Horário",
+ "actor.system": "Sistema",
+ "summary.window": "Janela {{range}}",
+ "summary.events": "eventos",
+ "summary.legend.activity": "Atividade",
+ "summary.legend.entity": "Entidade",
+ "summary.legend.security": "Segurança",
+ "summary.legend.exception": "Exceção",
+ "summary.severity.warn": "Aviso",
+ "summary.severity.err": "Erro",
+ "summary.topSources": "Principais origens",
+ "filter.advanced": "Avançado",
+ "filter.hideSystemTitle": "Oculta os eventos de Atividade do sistema por requisição (api.*) para mostrar só auditorias relevantes",
+ "filter.hideSystem": "Ocultar atividade do sistema",
+ "filter.clearSearch": "Limpar busca",
+ "filter.reset": "Redefinir",
+ "filter.type": "Tipo",
+ "filter.severity": "Severidade",
+ "filter.field.source": "Origem",
+ "filter.field.sourcePlaceholder": "api.identity.RegisterUser",
+ "filter.field.user": "ID do usuário",
+ "filter.field.userPlaceholder": "00000000-0000-…",
+ "filter.field.correlation": "Correlação",
+ "filter.field.correlationPlaceholder": "0HMxxxx…",
+ "filter.field.trace": "Trace",
+ "filter.field.tracePlaceholder": "hex traceparent",
+ "filter.tags": "Tags",
+ "drawer.srTitle": "Detalhe da auditoria",
+ "drawer.srDescription": "Payload completo, identificadores e ações relacionadas ao evento de auditoria selecionado.",
+ "drawer.close": "Fechar",
+ "drawer.eventFallback": "Evento de auditoria",
+ "drawer.utc": "UTC",
+ "drawer.section.identity": "Identidade",
+ "drawer.section.trace": "Trace",
+ "drawer.section.payload": "Payload",
+ "drawer.section.pipeline": "Pipeline",
+ "drawer.section.related": "Eventos relacionados",
+ "drawer.field.tenant": "Organização",
+ "drawer.field.user": "Usuário",
+ "drawer.field.userId": "ID do usuário",
+ "drawer.field.source": "Origem",
+ "drawer.field.traceId": "Trace ID",
+ "drawer.field.spanId": "Span ID",
+ "drawer.field.correlationId": "ID de correlação",
+ "drawer.field.requestId": "ID da requisição",
+ "drawer.field.occurred": "Ocorrido",
+ "drawer.field.received": "Recebido",
+ "drawer.field.sinkDelay": "Atraso de gravação",
+ "drawer.field.auditId": "ID da auditoria",
+ "drawer.allByCorrelation": "Todos por correlação",
+ "drawer.allByTrace": "Todos por trace",
+ "drawer.copy": "Copiar",
+ "drawer.copied": "Copiado",
+ "drawer.related.count": "{{count}} nesta correlação",
+ "drawer.related.empty": "Nenhum outro evento compartilha esta correlação. O ciclo de vida completo desta requisição está no payload acima.",
+ "drawer.related.thisEvent": "este evento",
+ "drawer.error.title": "Não foi possível carregar a auditoria",
+ "drawer.error.body": "O servidor retornou um erro ao buscar esta auditoria. O registro pode ter sido removido pela rotina de retenção."
+}
diff --git a/clients/dashboard/src/locales/pt-BR/auth.json b/clients/dashboard/src/locales/pt-BR/auth.json
new file mode 100644
index 0000000000..aa9f8295d1
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/auth.json
@@ -0,0 +1,107 @@
+{
+ "shell.tagline": "Starter Kit .NET 10",
+ "shell.encrypted": "Criptografado em trânsito · sessão protegida por JWT",
+ "shell.footer": "Administração fullstackhero",
+ "login.title": "Bem-vindo de volta",
+ "login.subtitle": "Entre na sua conta",
+ "login.inactivityNotice": "Você foi desconectado por inatividade.",
+ "login.tenant": "Organização",
+ "login.tenantPlaceholder": "root",
+ "login.email": "E-mail",
+ "login.emailPlaceholder": "nome@exemplo.com",
+ "login.password": "Senha",
+ "login.passwordPlaceholder": "Digite sua senha",
+ "login.forgot": "Esqueceu?",
+ "login.errorFallback": "Falha no login",
+ "login.showPassword": "Mostrar senha",
+ "login.hidePassword": "Ocultar senha",
+ "login.submit": "Entrar",
+ "login.submitting": "Entrando…",
+ "login.demoButton": "Entrar com uma conta de demonstração",
+ "demo.dialogTitle": "Contas de demonstração",
+ "demo.dialogDescription": "Escolha uma organização e uma conta de demonstração para entrar.",
+ "demo.liveDemo": "Demonstração ao vivo",
+ "demo.title": "Assuma qualquer papel.",
+ "demo.subtitle": "Explore o fullstackhero como qualquer usuário nas organizações de demonstração, entramos por você na hora.",
+ "demo.tenantsAria": "Organizações de demonstração",
+ "demo.userCount_one": "{{count}} usuário",
+ "demo.userCount_other": "{{count}} usuários",
+ "demo.users": "Usuários",
+ "demo.tapToSignIn": "toque para entrar",
+ "demo.demoOnly": "apenas demonstração",
+ "demo.resets": "Reiniciado a cada nova carga de dados.",
+ "inactivity.seconds": "segundos",
+ "inactivity.title": "Ainda por aí?",
+ "inactivity.description": "Você está inativo há um tempo. Vamos desconectá-lo em breve para manter sua conta segura.",
+ "inactivity.signOutNow": "Sair agora",
+ "inactivity.stay": "Estou aqui",
+ "confirm.malformed": "Este link de confirmação está sem parâmetros obrigatórios. Ele pode ter sido cortado pelo seu cliente de e-mail.",
+ "confirm.successFallback": "Seu e-mail foi confirmado. Você já pode entrar.",
+ "confirm.backLink": "← Voltar para o login",
+ "confirm.verifyingLead": "Verificando seu",
+ "confirm.verifyingAccent": "e-mail…",
+ "confirm.verifyingBody": "Um momento — verificando o token de confirmação com o servidor.",
+ "confirm.successLead": "E-mail",
+ "confirm.successAccent": "confirmado",
+ "confirm.continueSignIn": "Continuar para o login",
+ "confirm.errorLead": "Não foi possível",
+ "confirm.errorAccent": "confirmar",
+ "confirm.errorTrail": " seu e-mail",
+ "confirm.errorHint": "O link pode ter expirado ou já ter sido usado. Se você entrou desde que este e-mail foi enviado, pode ignorá-lo.",
+ "confirm.backToSignIn": "Voltar para o login",
+ "confirm.resetInstead": "Redefinir a senha",
+ "forgot.titleLead": "Redefina sua",
+ "forgot.titleAccent": "senha",
+ "forgot.subtitle": "Informe o e-mail que você usa para entrar. Enviaremos um link de uso único.",
+ "forgot.tenant": "Organização",
+ "forgot.tenantPlaceholder": "root",
+ "forgot.email": "E-mail",
+ "forgot.emailPlaceholder": "voce@exemplo.com",
+ "forgot.submit": "Enviar link de redefinição",
+ "forgot.submitting": "Enviando link…",
+ "forgot.successLead": "Verifique sua",
+ "forgot.successAccent": "caixa de entrada",
+ "forgot.successPre": "Se existir uma conta para",
+ "forgot.successMid": "na organização",
+ "forgot.successPost": ", um link de redefinição de uso único está a caminho. O link expira em 30 minutos.",
+ "forgot.tip1": "Não recebeu? Aguarde um minuto e verifique o spam.",
+ "forgot.tip2": "Ainda nada? Confirme o e-mail + organização e tente novamente.",
+ "forgot.tryDifferent": "Tentar outro endereço",
+ "forgot.backToSignIn": "Voltar para o login",
+ "forgot.remembered": "Lembrou?",
+ "forgot.signIn": "Entrar",
+ "reset.incompleteLead": "Este link está",
+ "reset.incompleteAccent": "incompleto",
+ "reset.incompletePre": "Falta um destes no link de redefinição:",
+ "reset.fieldToken": "token",
+ "reset.fieldEmail": "e-mail",
+ "reset.fieldTenant": "organização",
+ "reset.or": "ou",
+ "reset.incompletePost": "Alguns clientes de e-mail cortam URLs longas — tente copiar e colar o link completo do e-mail original na barra de endereços do navegador.",
+ "reset.requestNewLink": "Solicitar um novo link",
+ "reset.backToSignIn": "Voltar para o login",
+ "reset.titleLead": "Defina uma nova",
+ "reset.titleAccent": "senha",
+ "reset.subtitlePre": "Redefinindo a senha de",
+ "reset.subtitleMid": "em",
+ "reset.subtitlePost": ".",
+ "reset.newPassword": "Nova senha",
+ "reset.newPasswordPlaceholder": "Pelo menos 8 caracteres",
+ "reset.confirmPassword": "Confirmar senha",
+ "reset.confirmPasswordPlaceholder": "Digite a senha novamente",
+ "reset.showPassword": "Mostrar senha",
+ "reset.hidePassword": "Ocultar senha",
+ "reset.strength_weak": "Fraca",
+ "reset.strength_fair": "Razoável",
+ "reset.strength_strong": "Forte",
+ "reset.matches": "As senhas coincidem",
+ "reset.notMatchYet": "Ainda não coincide",
+ "reset.errMismatch": "As senhas não coincidem.",
+ "reset.errTooShort": "Use pelo menos 8 caracteres.",
+ "reset.submit": "Definir nova senha",
+ "reset.submitting": "Atualizando senha…",
+ "reset.toastTitle": "Senha atualizada",
+ "reset.toastDesc": "Entre com sua nova senha para continuar.",
+ "reset.changedMind": "Mudou de ideia?",
+ "reset.signIn": "Entrar"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/catalog.json b/clients/dashboard/src/locales/pt-BR/catalog.json
new file mode 100644
index 0000000000..50c598515a
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/catalog.json
@@ -0,0 +1,204 @@
+{
+ "products.title": "Produtos",
+ "products.description": "Navegue e gerencie o catálogo. Cada produto tem SKU, marca, categoria, preço e contagem de estoque em tempo real.",
+ "products.searchPlaceholder": "Buscar por nome, SKU ou slug…",
+ "products.count_one": "{{count}} produto encontrado",
+ "products.count_other": "{{count}} produtos encontrados",
+ "brands.title": "Marcas",
+ "brands.unit": "marca",
+ "brands.description": "Organize as marcas por trás de cada produto. Cada marca tem seu próprio slug, história e logotipo.",
+ "brands.searchPlaceholder": "Buscar por nome ou slug…",
+ "brands.count_one": "{{count}} marca encontrada",
+ "brands.count_other": "{{count}} marcas encontradas",
+ "categories.title": "Categorias",
+ "categories.unit": "categoria",
+ "categories.description": "Agrupe produtos em prateleiras. Categorias se aninham sob outras para formar a taxonomia que os clientes navegam.",
+ "categories.searchPlaceholder": "Buscar por nome ou slug…",
+ "categories.count_one": "{{count}} categoria encontrada",
+ "categories.count_other": "{{count}} categorias encontradas",
+ "filter.brand": "Marca",
+ "filter.category": "Categoria",
+ "filter.activeLabel": "Filtro de ativos",
+ "filter.all": "Todos",
+ "filter.active": "Ativo",
+ "filter.hidden": "Oculto",
+ "col.product": "Produto",
+ "col.sku": "SKU",
+ "col.brand": "Marca",
+ "col.price": "Preço",
+ "col.slug": "Slug",
+ "col.created": "Criado",
+ "col.category": "Categoria",
+ "badge.hidden": "Oculto",
+ "badge.active": "Ativo",
+ "aria.openProduct": "Abrir produto {{name}}",
+ "aria.editProduct": "Editar {{name}}",
+ "aria.deleteProduct": "Excluir {{name}}",
+ "aria.editBrand": "Editar marca {{name}}",
+ "aria.editCategory": "Editar categoria {{name}}",
+ "row.under": "sob {{name}}",
+ "row.parentFallback": "(pai)",
+ "row.root": "raiz",
+ "action.newProduct": "Novo produto",
+ "action.addProduct": "Adicionar produto",
+ "action.newBrand": "Nova marca",
+ "action.addBrand": "Adicionar marca",
+ "action.newCategory": "Nova categoria",
+ "action.addCategory": "Adicionar categoria",
+ "action.clear": "Limpar",
+ "action.clearFilters": "Limpar filtros",
+ "action.clearSearch": "Limpar busca",
+ "action.cancel": "Cancelar",
+ "action.saving": "Salvando…",
+ "action.saveChanges": "Salvar alterações",
+ "action.deleting": "Excluindo…",
+ "action.changePrice": "Alterar preço",
+ "action.adjustStock": "Ajustar estoque",
+ "action.adjusting": "Ajustando…",
+ "action.refresh": "Atualizar",
+ "action.edit": "Editar",
+ "action.delete": "Excluir",
+ "empty.products.searchTitle": "Nenhum produto encontrado",
+ "empty.products.title": "Nenhum produto ainda",
+ "empty.products.searchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe os filtros.",
+ "empty.products.searchBody": "Nenhum produto corresponde aos filtros atuais.",
+ "empty.products.body": "Adicione seu primeiro produto para começar a vender. Cada um tem seu próprio SKU, preço, estoque e imagem.",
+ "empty.brands.searchTitle": "Nenhuma marca encontrada",
+ "empty.brands.title": "Nenhuma marca ainda",
+ "empty.brands.searchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe a busca.",
+ "empty.brands.searchBody": "Nenhuma marca corresponde aos filtros atuais.",
+ "empty.brands.body": "Adicione sua primeira marca para começar a construir o catálogo. Cada marca tem seu próprio slug, descrição e logotipo.",
+ "empty.categories.searchTitle": "Nenhuma categoria encontrada",
+ "empty.categories.title": "Nenhuma categoria ainda",
+ "empty.categories.searchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe a busca.",
+ "empty.categories.searchBody": "Nenhuma categoria corresponde aos filtros atuais.",
+ "empty.categories.body": "As categorias dão ao seu catálogo sua árvore. Crie prateleiras raiz e depois aninhe subprateleiras sob elas.",
+ "toast.productCreated": "Produto criado",
+ "toast.productUpdated": "Produto atualizado",
+ "toast.productDeleted": "Produto excluído",
+ "toast.brandCreated": "Marca criada",
+ "toast.brandUpdated": "Marca atualizada",
+ "toast.brandDeleted": "Marca excluída",
+ "toast.categoryCreated": "Categoria criada",
+ "toast.categoryUpdated": "Categoria atualizada",
+ "toast.categoryDeleted": "Categoria excluída",
+ "toast.priceUpdated": "Preço atualizado",
+ "toast.stockAdjusted": "Estoque ajustado",
+ "toast.createFailed": "Falha ao criar",
+ "toast.updateFailed": "Falha ao atualizar",
+ "toast.deleteFailed": "Falha ao excluir",
+ "toast.priceChangeFailed": "Falha ao alterar preço",
+ "toast.adjustmentFailed": "Falha no ajuste",
+ "field.name": "Nome",
+ "field.sku": "SKU",
+ "field.brand": "Marca",
+ "field.category": "Categoria",
+ "field.price": "Preço",
+ "field.currency": "Moeda",
+ "field.stock": "Estoque",
+ "field.description": "Descrição",
+ "field.slug": "Slug",
+ "field.parent": "Pai",
+ "field.logoUrl": "URL do logotipo",
+ "field.newAmount": "Novo valor",
+ "placeholder.productName": "Camiseta de Algodão Clássica",
+ "placeholder.sku": "ACM-TS-001",
+ "placeholder.selectBrand": "Selecione uma marca…",
+ "placeholder.selectCategory": "Selecione uma categoria…",
+ "placeholder.productDescription": "Algodão 100% orgânico, gola careca.",
+ "placeholder.brandName": "Acme Goods",
+ "placeholder.brandDescription": "Itens essenciais de qualidade para o lar moderno.",
+ "placeholder.logoUrl": "https://…",
+ "placeholder.categoryName": "Ar livre",
+ "placeholder.categoryDescription": "Equipamentos para a natureza.",
+ "placeholder.noParent": "Sem pai (raiz)",
+ "hint.skuFixed": "O SKU é fixo após a criação.",
+ "hint.skuNew": "Unidade de manutenção de estoque. Torna-se o identificador canônico.",
+ "hint.description": "Exibida nas páginas de listagem e detalhe do produto.",
+ "hint.slug": "Derivado automaticamente do nome. Usado em URLs.",
+ "hint.brandDescription": "Exibida nas páginas de listagem e detalhe do produto.",
+ "hint.logoUrl": "Opcional. URL pública da imagem do logotipo da marca.",
+ "hint.parent": "Opcional. Deixe em branco para tornar esta uma categoria raiz.",
+ "hint.categoryDescription": "Exibida nas páginas de navegação de categoria.",
+ "parentOption.label": "Categoria pai",
+ "visibility.title": "Visibilidade",
+ "visibility.listed": "Listado para clientes.",
+ "visibility.hidden": "Oculto das listagens.",
+ "visibility.active": "Ativo",
+ "productEditor.editTitle": "Editar produto",
+ "productEditor.addTitle": "Adicionar um produto",
+ "productEditor.editDesc": "Atualize os detalhes de {{name}}. Use os chips de preço/estoque na linha para alterá-los — eles emitem eventos de domínio.",
+ "productEditor.addDesc": "Adicione um produto ao seu catálogo. Preço e estoque podem ser ajustados na linha após a criação.",
+ "brandEditor.editTitle": "Editar marca",
+ "brandEditor.addTitle": "Adicionar uma marca",
+ "brandEditor.editDesc": "Atualize os detalhes de {{name}}. O slug é rederivado a partir do nome.",
+ "brandEditor.addDesc": "Adicione uma marca ao seu catálogo. O slug é gerado automaticamente a partir do nome.",
+ "categoryEditor.editTitle": "Editar categoria",
+ "categoryEditor.addTitle": "Adicionar uma categoria",
+ "categoryEditor.editDesc": "Atualize os detalhes de {{name}}. O slug é rederivado a partir do nome.",
+ "categoryEditor.addDesc": "Adicione uma categoria ao seu catálogo. O slug é gerado automaticamente a partir do nome.",
+ "price.title": "Alterar preço",
+ "price.emitsPrefix": "Emite o evento de domínio ",
+ "price.emitsSuffix": " para {{name}}.",
+ "price.emitsDetailPrefix": "{{name}} — emite o evento de domínio ",
+ "price.emitsDetailSuffix": ".",
+ "price.was": "Antes",
+ "price.becomes": "Depois",
+ "stock.title": "Ajustar estoque",
+ "stock.emitsPrefix": "Adicione ou remova unidades de {{name}}. Emite o evento ",
+ "stock.emitsSuffix": ".",
+ "stock.emitsDetailPrefix": "{{name}} — adicione ou remova unidades. Emite o evento ",
+ "stock.emitsDetailSuffix": ".",
+ "stock.current": "Atual",
+ "stock.becomes": "Depois",
+ "stock.deltaLabel": "Delta",
+ "stock.negativePrefix": "O estoque não pode ficar negativo. O decremento máximo é ",
+ "stock.negativeSuffix": ".",
+ "stock.negativeDetailPrefix": "O estoque não pode ficar negativo. O decremento máximo aqui é ",
+ "delete.productTitle": "Excluir produto",
+ "delete.productBody": ". O produto não aparecerá mais em nenhuma listagem ou relatório.",
+ "delete.brandTitle": "Excluir marca",
+ "delete.brandBody": ". Produtos que referenciam esta marca precisarão ser reatribuídos.",
+ "delete.categoryTitle": "Excluir categoria",
+ "delete.categoryBody": ". Categorias com subcategorias não podem ser excluídas — mova ou exclua os filhos primeiro.",
+ "delete.removesPrefix": "Isto remove permanentemente ",
+ "delete.createdOn": "(criado em {{date}})",
+ "delete.dateOnly": "({{date}})",
+ "detail.back": "Voltar aos produtos",
+ "detail.section.pricing": "Preços",
+ "detail.section.inventory": "Estoque",
+ "detail.section.identifiers": "Identificadores",
+ "detail.section.description": "Descrição",
+ "detail.section.descriptionDesc": "Texto voltado ao cliente exibido na página do produto.",
+ "detail.section.images": "Imagens",
+ "detail.section.imagesDesc": "Solte mais para adicionar. Marque uma com estrela para torná-la a capa.",
+ "detail.section.audit": "Auditoria",
+ "detail.stat.price": "preço",
+ "detail.stat.outOfStock": "sem estoque",
+ "detail.stat.low": "baixo (< {{n}})",
+ "detail.stat.inStock": "em estoque",
+ "detail.stat.images": "imagens",
+ "detail.meta.created": "Criado {{rel}}",
+ "detail.meta.updated": "Atualizado {{rel}}",
+ "detail.pricing.listed": "Preço listado · {{currency}}",
+ "detail.inventory.outOfStock": "Sem estoque",
+ "detail.inventory.below": "Abaixo de {{n}} unidades",
+ "detail.inventory.unitsOnHand": "Unidades em mãos",
+ "detail.id.sku": "SKU",
+ "detail.id.slug": "Slug",
+ "detail.id.productId": "ID do produto",
+ "detail.id.brandId": "ID da marca",
+ "detail.id.categoryId": "ID da categoria",
+ "detail.audit.created": "Criado",
+ "detail.audit.revised": "Revisado",
+ "detail.audit.never": "Nunca",
+ "detail.audit.noEdits": "sem edições desde a criação",
+ "detail.audit.status": "Status",
+ "detail.audit.active": "Ativo",
+ "detail.audit.hidden": "Oculto",
+ "detail.description.empty": "Nenhuma descrição registrada. Os clientes verão uma descrição em branco na página do produto até você adicionar uma.",
+ "detail.notFound.title": "Produto não encontrado",
+ "detail.notFound.body": "Pode ter sido excluído, ou o link pode estar errado.",
+ "detail.editor.title": "Editar produto",
+ "detail.editor.desc": "Atualize os detalhes de {{name}}. Use as ações de preço/estoque na barra lateral para alterá-los — eles emitem eventos de domínio."
+}
diff --git a/clients/dashboard/src/locales/pt-BR/chat.json b/clients/dashboard/src/locales/pt-BR/chat.json
new file mode 100644
index 0000000000..b519f4f179
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/chat.json
@@ -0,0 +1,206 @@
+{
+ "util.unnamedChannel": "(canal sem nome)",
+ "util.directMessage": "Mensagem direta",
+ "util.emptyGroup": "Grupo vazio",
+ "day.today": "Hoje",
+ "day.yesterday": "Ontem",
+ "unnamed": "(sem nome)",
+ "page.emptyTitle": "Escolha uma conversa",
+ "page.emptyBody": "Escolha um canal à esquerda para participar. Canais são públicos para a sua organização; DMs são privadas para as pessoas nelas. Menções chegam no sino de notificações, no canto superior direito.",
+ "page.hintSend": "Enviar",
+ "page.hintNewline": "Nova linha",
+ "page.hintMention": "Mencionar",
+ "page.channelUnreachable": "Este canal não está acessível. Ele pode ter sido arquivado ou você não é mais um membro.",
+ "page.loadingChannel": "Carregando canal…",
+ "page.backToChannels": "Voltar para os canais",
+ "page.member_one": "{{count}} membro",
+ "page.member_other": "{{count}} membros",
+ "page.searchMessages": "Buscar mensagens",
+ "page.channelSettings": "Configurações do canal",
+ "page.olderThanWindow": "Essa mensagem é mais antiga que a janela carregada.",
+ "rail.brand": "Chat",
+ "rail.filterPlaceholder": "Filtrar canais…",
+ "rail.clearFilter": "Limpar filtro",
+ "rail.sectionChannels": "Canais",
+ "rail.newChannel": "Novo canal",
+ "rail.sectionDms": "Mensagens diretas",
+ "rail.newDm": "Nova mensagem direta",
+ "rail.loading": "Carregando…",
+ "rail.noMatches": "Nenhuma correspondência.",
+ "rail.noChannels": "Nenhum canal ainda.",
+ "rail.noConversations": "Nenhuma conversa.",
+ "rail.footerFiltered": "{{shown}} de {{total}}",
+ "rail.footerCount_one": "{{count}} canal",
+ "rail.footerCount_other": "{{count}} canais",
+ "rail.unreadAria": "{{n}} não lidas",
+ "rail.createTitle": "Criar um canal",
+ "rail.createDescription": "Canais são onde os times conversam. Qualquer pessoa na sua organização pode entrar em canais públicos.",
+ "rail.nameLabel": "Nome",
+ "rail.namePlaceholder": "engenharia, feedback-de-design…",
+ "rail.descLabel": "Descrição (opcional)",
+ "rail.descPlaceholder": "Sobre o que é este canal?",
+ "rail.privateLabel": "Privado",
+ "rail.privateHint": "Apenas membros convidados podem encontrar ou entrar neste canal.",
+ "rail.cancel": "Cancelar",
+ "rail.createSubmit": "Criar canal",
+ "rail.dmTitle": "Nova mensagem direta",
+ "rail.dmDescription": "Encontre alguém na sua organização e abriremos uma DM com essa pessoa. DMs existentes são reutilizadas, você não cria duplicatas.",
+ "rail.dmSearchLabel": "Buscar",
+ "rail.dmSearchPlaceholder": "Nome, usuário ou e-mail…",
+ "rail.dmSearching": "Buscando…",
+ "rail.dmLoadingPeople": "Carregando pessoas…",
+ "rail.dmNoMatch": "Ninguém corresponde a \"{{term}}\".",
+ "rail.dmNoTeammates": "Nenhum colega na sua organização ainda.",
+ "rail.dmSearchResults": "Resultados da busca",
+ "rail.dmSuggested": "Sugeridos",
+ "composer.replyPlaceholder": "Digite sua resposta…",
+ "composer.messageChannel": "Mensagem para #{{name}}",
+ "composer.messageOther": "Mensagem para {{name}}",
+ "composer.ariaChannel": "Mensagem para o canal {{name}}",
+ "composer.ariaOther": "Mensagem para {{name}}",
+ "composer.ariaReply": "Digite sua resposta",
+ "composer.attachFile": "Anexar um arquivo",
+ "composer.sendMessage": "Enviar mensagem",
+ "composer.hintReply": "Enter para enviar · Shift+Enter para nova linha · Esc para limpar",
+ "composer.hintDefault": "Enter para enviar · Shift+Enter para nova linha · @ + 2 caracteres para mencionar",
+ "composer.sendFailedRetry": "Falha ao enviar, tentar de novo?",
+ "composer.replaceAttachment": "Substitua o anexo existente primeiro.",
+ "composer.attachError": "Não foi possível anexar esse arquivo.",
+ "composer.sendFailed": "A mensagem falhou ao enviar, tente novamente.",
+ "composer.replyingTo": "Respondendo a",
+ "composer.noTextEmpty": "(sem texto, anexo ou vazio)",
+ "composer.clearReplyAria": "Limpar contexto da resposta",
+ "composer.clearReply": "Limpar resposta",
+ "composer.uploadPreparing": "Preparando…",
+ "composer.uploadUploading": "Enviando… {{percent}}%",
+ "composer.uploadFinalizing": "Finalizando…",
+ "composer.uploadFailed": "Falha no envio",
+ "composer.uploadDone": "Concluído",
+ "composer.uploadProgressAria": "Progresso do envio",
+ "composer.uploadCancelAria": "Cancelar envio",
+ "composer.uploadCancel": "Cancelar",
+ "composer.readyToSend": "pronto para enviar",
+ "composer.removeAttachment": "Remover anexo",
+ "typing.one": "{{name}} está digitando…",
+ "typing.two": "{{a}} e {{b}} estão digitando…",
+ "typing.many": "{{a}} e mais {{count}} estão digitando…",
+ "pinned.bar_one": "{{count}} mensagem fixada",
+ "pinned.bar_other": "{{count}} mensagens fixadas",
+ "pinned.countAria_one": "{{count}} mensagem fixada, clique para revisar",
+ "pinned.countAria_other": "{{count}} mensagens fixadas, clique para revisar",
+ "pinned.panelTitle": "Mensagens fixadas",
+ "pinned.noText": "(sem texto)",
+ "pinned.attachment": "📎 {{name}}",
+ "pinned.attachmentMore": "📎 {{name}} (+{{count}})",
+ "search.close": "Fechar busca",
+ "search.placeholder": "Buscar mensagens neste canal",
+ "search.clear": "Limpar busca",
+ "search.searching": "Buscando…",
+ "search.noMatches": "Nenhuma correspondência para \"{{term}}\".",
+ "search.footer_one": "{{count}} correspondência · clique para ir · Esc para fechar",
+ "search.footer_other": "{{count}} correspondências · clique para ir · Esc para fechar",
+ "search.footerEsc": "Esc para fechar",
+ "mention.title": "Menção",
+ "mention.hint": "↑↓ navegar · Enter selecionar · Esc cancelar",
+ "mention.searching": "Buscando…",
+ "mention.listboxAria": "Sugestões de menção",
+ "message.edited": "· editada",
+ "message.pinned": "Fixada",
+ "message.deleted": "[mensagem excluída]",
+ "message.replyingTo": "Respondendo a",
+ "message.aMessage": "uma mensagem",
+ "message.noTextParent": "(sem texto, anexo ou vazio)",
+ "message.seen": "Vista",
+ "message.seenEveryone": "Vista por todos",
+ "message.seenByN": "Vista por {{n}}",
+ "message.mentionTitle": "Menção a {{username}}",
+ "message.mentionAria": "Menção a {{username}}, clique para abrir o perfil",
+ "message.you": "você",
+ "message.lookingUp": "Buscando…",
+ "message.userNotFound": "Usuário não encontrado.",
+ "message.loadUserError": "Não foi possível carregar este usuário.",
+ "message.copyHandle": "Copiar @{{username}}",
+ "message.opening": "Abrindo…",
+ "message.openDm": "Abrir DM",
+ "message.thatsYou": "Esse é você",
+ "message.copied": "Copiado {{text}}",
+ "message.copyFailed": "Não foi possível copiar para a área de transferência",
+ "message.openDmFailed": "Não foi possível abrir a DM",
+ "message.reactAria": "Reagir com {{emoji}}",
+ "message.reactionAddAria": "Adicionar reação {{emoji}}, {{n}} até agora",
+ "message.reactionRemoveAria": "Remover sua reação {{emoji}}, {{n}} até agora",
+ "message.react": "Reagir",
+ "message.reply": "Responder",
+ "message.unpin": "Desafixar",
+ "message.pin": "Fixar",
+ "message.edit": "Editar",
+ "message.delete": "Excluir",
+ "message.toastDeleted": "Mensagem excluída",
+ "message.deleteFailed": "Não foi possível excluir a mensagem",
+ "message.unpinned": "Desafixada",
+ "message.pinnedToast": "Fixada",
+ "message.pinUpdateFailed": "Não foi possível atualizar a fixação",
+ "message.deleteTitle": "Excluir esta mensagem?",
+ "message.deleteDescription": "Isto não pode ser desfeito. Todos no canal verão a mensagem desaparecer em tempo real.",
+ "message.deleteNoPreview": "Sem prévia de texto, anexos ou corpo vazio.",
+ "message.deleteCancel": "Cancelar",
+ "message.deleting": "Excluindo…",
+ "message.deleteConfirm": "Excluir mensagem",
+ "message.editAria": "Editar mensagem",
+ "message.editHint": "Enter para salvar · Shift+Enter para nova linha · Esc para cancelar",
+ "message.editCancel": "Cancelar",
+ "message.editSaving": "Salvando…",
+ "message.editSave": "Salvar",
+ "list.loading": "Carregando mensagens…",
+ "list.emptyTitle": "Nenhuma mensagem ainda",
+ "list.emptyBody": "Este é o começo da conversa. Envie a primeira mensagem para quebrar o silêncio.",
+ "list.loadingOlder": "Carregando mais antigas…",
+ "list.beginning": "Começo da conversa",
+ "list.logAria": "Mensagens do canal",
+ "list.new": "Novas",
+ "list.jumpAria_one": "Ir para a mais recente, {{count}} mensagem não vista",
+ "list.jumpAria_other": "Ir para a mais recente, {{count}} mensagens não vistas",
+ "list.jump_one": "nova mensagem",
+ "list.jump_other": "novas mensagens",
+ "settings.title": "Configurações do canal",
+ "settings.description": "Gerencie o nome, os membros e o ciclo de vida do canal.",
+ "settings.general": "Geral",
+ "settings.nameLabel": "Nome",
+ "settings.descLabel": "Descrição",
+ "settings.descPlaceholder": "Sobre o que é este canal?",
+ "settings.privateLabel": "Privado",
+ "settings.privateHint": "Apenas membros convidados podem encontrar ou entrar neste canal.",
+ "settings.members": "Membros",
+ "settings.danger": "Zona de perigo",
+ "settings.archiveTitle": "Arquivar canal",
+ "settings.archiveHint": "Oculta o canal para todos. Restaure pelo painel de admin se mudar de ideia.",
+ "settings.archiveConfirm": "Arquivar este canal para todos?",
+ "settings.archiving": "Arquivando…",
+ "settings.archive": "Arquivar",
+ "settings.leaveTitle": "Sair do canal",
+ "settings.leaveHint": "Você deixará de receber mensagens. Volte pela descoberta de canais se ele for público.",
+ "settings.leaveConfirm": "Sair deste canal?",
+ "settings.leaving": "Saindo…",
+ "settings.leave": "Sair",
+ "settings.close": "Fechar",
+ "settings.saving": "Salvando…",
+ "settings.saveChanges": "Salvar alterações",
+ "settings.toastUpdated": "Canal atualizado.",
+ "settings.toastUpdateFailed": "Não foi possível salvar as alterações do canal.",
+ "settings.toastArchived": "Canal arquivado.",
+ "settings.toastArchiveFailed": "Não foi possível arquivar o canal.",
+ "settings.toastLeft": "Você saiu do canal.",
+ "settings.toastLeaveFailed": "Não foi possível sair do canal.",
+ "settings.you": "você",
+ "settings.admin": "admin",
+ "settings.removeConfirm": "Remover {{name}} do canal?",
+ "settings.removeAria": "Remover {{name}}",
+ "settings.toastMemberRemoved": "Membro removido.",
+ "settings.toastRemoveFailed": "Não foi possível remover o membro.",
+ "settings.addMember": "Adicionar membro",
+ "settings.addPlaceholder": "Nome, usuário ou e-mail…",
+ "settings.addSearching": "Buscando…",
+ "settings.noMatchesOutside": "Nenhuma correspondência fora da lista atual de membros.",
+ "settings.toastMemberAdded": "Membro adicionado.",
+ "settings.toastAddFailed": "Não foi possível adicionar o membro."
+}
diff --git a/clients/dashboard/src/locales/pt-BR/commandPalette.json b/clients/dashboard/src/locales/pt-BR/commandPalette.json
new file mode 100644
index 0000000000..321edded88
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/commandPalette.json
@@ -0,0 +1,83 @@
+{
+ "group.navigate": "Navegar",
+ "group.create": "Criar",
+ "group.account": "Conta",
+ "group.theme": "Tema",
+ "group.accent": "Cor de destaque",
+ "group.session": "Sessão",
+ "nav.overview.label": "Visão geral",
+ "nav.overview.hint": "Telemetria e uso da organização",
+ "nav.activity.label": "Atividade ao vivo",
+ "nav.activity.hint": "Fluxo de eventos em tempo real",
+ "nav.chat.label": "Chat",
+ "nav.chat.hint": "Canais e mensagens diretas",
+ "nav.files.label": "Arquivos",
+ "nav.files.hint": "Meus arquivos enviados",
+ "nav.users.label": "Usuários",
+ "nav.users.hint": "Diretório de identidade",
+ "nav.roles.label": "Perfis",
+ "nav.roles.hint": "Permissões e atribuição de perfis",
+ "nav.groups.label": "Grupos",
+ "nav.groups.hint": "Grupos e associação da organização",
+ "nav.products.label": "Produtos",
+ "nav.products.hint": "Inventário do catálogo",
+ "nav.brands.label": "Marcas",
+ "nav.brands.hint": "Marcas do catálogo",
+ "nav.categories.label": "Categorias",
+ "nav.categories.hint": "Categorias do catálogo",
+ "nav.tickets.label": "Chamados",
+ "nav.tickets.hint": "Solicitações de suporte",
+ "nav.invoices.label": "Faturas",
+ "nav.invoices.hint": "Histórico de faturamento",
+ "nav.health.label": "Saúde",
+ "nav.health.hint": "Sonda de prontidão e dependências",
+ "nav.audits.label": "Trilha de auditoria",
+ "nav.audits.hint": "Eventos de atividade, segurança e mudança de entidade",
+ "nav.trash.label": "Lixeira",
+ "nav.trash.hint": "Registros excluídos temporariamente",
+ "nav.sessions.label": "Sessões",
+ "nav.sessions.hint": "Sessões de usuário ativas",
+ "nav.settings.label": "Configurações",
+ "create.user.label": "Criar usuário",
+ "create.user.hint": "Registrar uma nova conta",
+ "create.role.label": "Criar perfil",
+ "create.role.hint": "Definir um novo conjunto de permissões",
+ "create.group.label": "Criar grupo",
+ "create.group.hint": "Organizar membros",
+ "create.product.label": "Criar produto",
+ "create.product.hint": "Adicionar ao catálogo",
+ "create.brand.label": "Criar marca",
+ "create.brand.hint": "Adicionar uma marca ao catálogo",
+ "create.category.label": "Criar categoria",
+ "create.category.hint": "Adicionar uma categoria ao catálogo",
+ "create.ticket.label": "Criar chamado",
+ "create.ticket.hint": "Abrir uma solicitação de suporte",
+ "create.channel.label": "Criar canal de chat",
+ "create.channel.hint": "Iniciar um novo espaço de conversa",
+ "create.file.label": "Enviar arquivo",
+ "create.file.hint": "Adicionar ao seu armazenamento",
+ "account.profile.label": "Perfil",
+ "account.profile.hint": "Nome, e-mail, contato",
+ "account.security.label": "Segurança",
+ "account.security.hint": "Senha, 2FA, sessões",
+ "account.keys.label": "Chaves de API",
+ "account.keys.hint": "Gerar e rotacionar",
+ "account.notifications.label": "Notificações",
+ "account.notifications.hint": "Preferências de e-mail",
+ "account.appearance.label": "Aparência",
+ "account.appearance.hint": "Tema, destaque, fonte, densidade",
+ "theme.light": "Mudar para claro",
+ "theme.dark": "Mudar para escuro",
+ "theme.system": "Seguir o tema do sistema",
+ "accent.set": "Definir destaque: {{name}}",
+ "session.logout.label": "Sair",
+ "session.logout.hint": "Encerrar esta sessão",
+ "ui.searchPlaceholder": "Digite um comando ou busque…",
+ "ui.searchAria": "Buscar comandos",
+ "ui.srTitle": "Paleta de comandos",
+ "ui.srDescription": "Busque em páginas, ações da conta, tema e destaque. Use as setas para navegar; Enter para selecionar.",
+ "ui.emptyTitle": "Nenhuma correspondência",
+ "ui.emptyBody": "Tente outra palavra-chave — nome da página, entidade, tema, destaque ou sair.",
+ "ui.navigate": "navegar",
+ "ui.select": "selecionar"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/common.json b/clients/dashboard/src/locales/pt-BR/common.json
new file mode 100644
index 0000000000..3894bdc860
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/common.json
@@ -0,0 +1,136 @@
+{
+ "language": "Idioma",
+ "language.enUS": "English (US)",
+ "language.ptBR": "Português (BR)",
+ "search": "Buscar",
+ "commandPalette.open": "Abrir paleta de comandos",
+ "profileMenu.open": "Abrir menu do perfil",
+ "unknownUser": "Desconhecido",
+ "skipToContent": "Pular para o conteúdo principal",
+ "brand.dashboard": "Painel",
+ "presence.connected_one": "Conectado · {{formatted}} evento",
+ "presence.connected_other": "Conectado · {{formatted}} eventos",
+ "presence.offline": "Transmissão offline",
+ "presence.connecting": "Conectando…",
+ "presence.reconnecting": "Reconectando…",
+ "presence.idle": "Ocioso",
+ "theme.title": "Tema",
+ "theme.light": "Claro",
+ "theme.dark": "Escuro",
+ "theme.system": "Sistema",
+ "account.title": "Conta",
+ "account.profile": "Perfil",
+ "account.settings": "Configurações",
+ "account.apiKeys": "Chaves de API",
+ "account.signOut": "Sair",
+ "signOut.title": "Sair do fullstackhero?",
+ "signOut.description": "Você precisará entrar novamente para acessar esta organização. Qualquer trabalho não salvo nesta sessão será perdido.",
+ "signOut.cancel": "Cancelar",
+ "signOut.confirm": "Sair",
+ "sidebar.collapse": "Recolher barra lateral",
+ "sidebar.expand": "Expandir barra lateral",
+ "nav.primary": "Navegação principal",
+ "nav.primaryDescription": "Seções do site e links da conta.",
+ "nav.openMenu": "Abrir menu de navegação",
+ "nav.overview": "Visão geral",
+ "nav.chat": "Chat",
+ "nav.myFiles": "Meus arquivos",
+ "nav.settings": "Configurações",
+ "nav.section.operations": "Operações",
+ "nav.section.catalog": "Catálogo",
+ "nav.section.helpdesk": "Central de ajuda",
+ "nav.section.identity": "Identidade",
+ "nav.section.system": "Sistema",
+ "nav.liveActivity": "Atividade ao vivo",
+ "nav.subscription": "Assinatura",
+ "nav.wallet": "Carteira do WhatsApp",
+ "nav.invoices": "Faturas",
+ "nav.products": "Produtos",
+ "nav.brands": "Marcas",
+ "nav.categories": "Categorias",
+ "nav.tickets": "Chamados",
+ "nav.users": "Usuários",
+ "nav.roles": "Funções",
+ "nav.groups": "Grupos",
+ "nav.health": "Saúde",
+ "nav.audits": "Trilha de auditoria",
+ "nav.sessions": "Sessões",
+ "nav.trash": "Lixeira",
+ "expiryBanner.expired": "Sua assinatura expirou. Entre em contato com seu operador para renovar e restaurar o acesso completo.",
+ "expiryBanner.grace": "Sua assinatura expirou — {{days}} de carência restantes (até {{date}}). Entre em contato com seu operador para renovar.",
+ "expiryBanner.nearing": "Sua assinatura expira em {{days}}.",
+ "expiryBanner.days_one": "{{count}} dia",
+ "expiryBanner.days_other": "{{count}} dias",
+ "expiryBanner.graceSoon": "em breve",
+ "expiryBanner.dismiss": "Dispensar",
+ "expiryBanner.dismissNotice": "Dispensar aviso de assinatura",
+ "impersonationBanner.crossTenant": "Personificação entre organizações",
+ "impersonationBanner.impersonating": "Personificando",
+ "impersonationBanner.operator": "operador",
+ "impersonationBanner.end": "Encerrar personificação",
+ "impersonationBanner.ending": "Encerrando…",
+ "impersonationBanner.toastEnded": "Personificação encerrada",
+ "impersonationBanner.toastReturned": "De volta à sua sessão",
+ "impersonationBanner.toastErrorTitle": "Não foi possível encerrar a personificação corretamente",
+ "impersonationBanner.toastErrorDesc": "Sessão local restaurada.",
+ "backToSignIn": "Voltar ao login",
+ "relative.justNow": "agora mesmo",
+ "relative.secondsAgo": "há {{n}}s",
+ "relative.minutesAgo": "há {{n}}min",
+ "relative.hoursAgo": "há {{n}}h",
+ "relative.daysAgo": "há {{n}}d",
+ "relative.monthsAgo": "há {{n}}mês",
+ "relative.yearsAgo": "há {{n}}a",
+ "notFound.title": "Página não encontrada",
+ "notFound.body": "Não encontramos nada neste endereço. Pode ser um link desatualizado, uma rota renomeada ou um caminho que nunca existiu.",
+ "notFound.requested": "Solicitado",
+ "notFound.backHome": "Voltar ao início",
+ "notFound.goBack": "Voltar à página anterior",
+ "tenantDeactivated.title": "Organização desativada",
+ "tenantDeactivated.body": "Esta organização foi desativada e não está mais disponível. Se você acha que isto é um engano, entre em contato com o administrador.",
+ "impersonationEnded.title": "Personificação encerrada",
+ "impersonationEnded.body": "O acesso do operador a esta sessão foi revogado ou expirou. Entre com a sua própria conta para continuar.",
+ "close": "Fechar",
+ "list.clear": "Limpar",
+ "list.clearSearch": "Limpar busca",
+ "list.clearFilter": "Limpar filtro",
+ "list.noMatches": "Nenhuma correspondência.",
+ "routeError.title": "Algo deu errado",
+ "routeError.reload": "Recarregar",
+ "routeError.goHome": "Ir para o início",
+ "routeError.unexpected": "Erro inesperado",
+ "list.loading": "Carregando…",
+ "list.pageOf": "Página {{page}} de {{total}}",
+ "list.prevPage": "Página anterior",
+ "list.nextPage": "Próxima página",
+ "unit.item_one": "{{count}} item",
+ "unit.item_other": "{{count}} itens",
+ "unit.event_one": "{{count}} evento",
+ "unit.event_other": "{{count}} eventos",
+ "unit.brand_one": "{{count}} marca",
+ "unit.brand_other": "{{count}} marcas",
+ "unit.category_one": "{{count}} categoria",
+ "unit.category_other": "{{count}} categorias",
+ "unit.file_one": "{{count}} arquivo",
+ "unit.file_other": "{{count}} arquivos",
+ "unit.group_one": "{{count}} grupo",
+ "unit.group_other": "{{count}} grupos",
+ "unit.invoice_one": "{{count}} fatura",
+ "unit.invoice_other": "{{count}} faturas",
+ "unit.role_one": "{{count}} função",
+ "unit.role_other": "{{count}} funções",
+ "unit.user_one": "{{count}} usuário",
+ "unit.user_other": "{{count}} usuários",
+ "unit.session_one": "{{count}} sessão",
+ "unit.session_other": "{{count}} sessões",
+ "unit.ticket_one": "{{count}} chamado",
+ "unit.ticket_other": "{{count}} chamados",
+ "realtimeStatus.offline": "Offline",
+ "realtimeStatus.connecting": "Conectando",
+ "realtimeStatus.live": "Ao vivo",
+ "realtimeStatus.reconnecting": "Reconectando",
+ "realtimeStatus.title": "Tempo real: {{label}}",
+ "session.restoring": "Restaurando sua sessão…",
+ "field.required": "obrigatório",
+ "errorBand.failure": "Falha"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/files.json b/clients/dashboard/src/locales/pt-BR/files.json
new file mode 100644
index 0000000000..9b4c634513
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/files.json
@@ -0,0 +1,130 @@
+{
+ "page.title": "Arquivos",
+ "page.unit": "arquivo",
+ "page.description": "Solte imagens, documentos ou arquivos compactados. Os envios são privados por padrão; torne um arquivo público na sua pré-visualização para deixá-lo visível ao resto da organização em Compartilhados.",
+ "tabs.aria": "Escopos de arquivo",
+ "tabs.mine": "Meus arquivos",
+ "tabs.shared": "Compartilhados na organização",
+ "search.placeholder": "Buscar por nome do arquivo…",
+ "search.aria": "Buscar arquivos",
+ "search.clear": "Limpar busca",
+ "chip.all": "Todos",
+ "chip.images": "Imagens",
+ "chip.documents": "Documentos",
+ "chip.archives": "Compactados",
+ "chip.other": "Outros",
+ "error.mine": "Não foi possível carregar seus arquivos.",
+ "error.shared": "Não foi possível carregar os arquivos compartilhados.",
+ "empty.mine.title": "Nenhum arquivo ainda",
+ "empty.mine.body": "Solte um arquivo acima para começar. Seus envios são privados por padrão.",
+ "empty.shared.title": "Nada compartilhado ainda",
+ "empty.shared.body": "Quando um colega tornar um de seus arquivos público, ele aparece aqui para todos na organização.",
+ "empty.refresh": "Atualizar",
+ "empty.noMatch.title": "Nenhuma correspondência",
+ "empty.noMatch.body": "Nada corresponde ao filtro atual.",
+ "empty.noMatch.bodyTerm": "Nada corresponde ao filtro atual \"{{term}}\".",
+ "empty.noMatch.reset": "Redefinir filtros",
+ "col.filename": "Nome do arquivo",
+ "col.uploadedBy": "Enviado por",
+ "col.visibility": "Visibilidade",
+ "col.size": "Tamanho",
+ "col.uploaded": "Enviado",
+ "count.showing_one": "Mostrando {{shown}} de {{count}} arquivo",
+ "count.showing_other": "Mostrando {{shown}} de {{count}} arquivos",
+ "count.total_one": "{{count}} arquivo",
+ "count.total_other": "{{count}} arquivos",
+ "visibility.public": "Público",
+ "visibility.private": "Privado",
+ "card.by": "por",
+ "preview.fallbackName": "Arquivo",
+ "preview.toastDeleted": "Arquivo excluído",
+ "preview.toastDeleteFailed": "Falha ao excluir",
+ "preview.toastNowPublic": "O arquivo agora é público para a sua organização",
+ "preview.toastNowPrivate": "O arquivo agora é privado",
+ "preview.toastVisibilityFailed": "Falha ao alterar a visibilidade",
+ "preview.errorMeta": "Não foi possível carregar os metadados do arquivo.",
+ "preview.confirmDelete": "Excluir este arquivo?",
+ "preview.cancel": "Cancelar",
+ "preview.deleting": "Excluindo…",
+ "preview.confirmDeleteButton": "Confirmar exclusão",
+ "preview.delete": "Excluir",
+ "preview.close": "Fechar",
+ "preview.notFinalized": "Envio ainda não finalizado.",
+ "preview.quarantined": "Este arquivo está em quarentena e não pode ser visualizado.",
+ "preview.expired": "O link de pré-visualização expirou ou está inacessível.",
+ "preview.retry": "Tentar novamente",
+ "preview.notAvailable": "Pré-visualização indisponível para este tipo de arquivo.",
+ "preview.openNewTab": "Abrir em nova aba",
+ "preview.textError": "Não foi possível obter o conteúdo de texto: {{err}}",
+ "preview.fetchFailed": "Falha ao obter",
+ "preview.meta.fileId": "ID do arquivo",
+ "preview.meta.ownerType": "Tipo de dono",
+ "preview.meta.uploadedBy": "Enviado por",
+ "preview.meta.contentType": "Tipo de conteúdo",
+ "preview.meta.size": "Tamanho",
+ "preview.meta.status": "Status",
+ "preview.meta.created": "Criado",
+ "preview.meta.uploaderLoading": "Carregando…",
+ "preview.meta.visibility": "Visibilidade",
+ "preview.meta.publicHint": "Todos na sua organização podem encontrar este arquivo em Compartilhados.",
+ "preview.meta.privateHint": "Somente você pode visualizar ou baixar este arquivo.",
+ "preview.meta.readOnly": "Somente leitura",
+ "preview.meta.toggleAria": "Alternar visibilidade pública",
+ "preview.status.pending": "Envio pendente",
+ "preview.status.available": "Disponível",
+ "preview.status.quarantined": "Em quarentena",
+ "preview.status.unknown": "Desconhecido ({{status}})",
+ "preview.download": "Baixar",
+ "dropzone.toastUploaded": "Arquivo enviado",
+ "dropzone.dismiss": "Dispensar",
+ "dropzone.cancel": "Cancelar",
+ "dropzone.progressAria": "Progresso do envio",
+ "dropzone.caption.preparing": "Preparando envio…",
+ "dropzone.caption.uploading": "Enviando {{name}}",
+ "dropzone.caption.uploadingFallback": "Enviando …",
+ "dropzone.caption.finalizing": "Finalizando…",
+ "dropzone.caption.done": "Enviado {{name}}",
+ "dropzone.caption.doneFallback": "Arquivo enviado",
+ "dropzone.caption.error": "Falha no envio",
+ "dropzone.caption.idle": "Solte um arquivo ou clique para procurar",
+ "dropzone.detail.uploading": "{{loaded}} de {{total}}",
+ "dropzone.detail.allowed": "Permitidos: {{ext}}",
+ "dropzone.detail.upTo": " · até {{size}}",
+ "dropzone.detail.dropAFile": "Solte um arquivo",
+ "imageInput.upload": "Enviar",
+ "imageInput.pasteUrl": "Colar URL",
+ "imageInput.replace": "Substituir imagem",
+ "imageInput.choose": "Escolher imagem",
+ "imageInput.remove": "Remover",
+ "imageInput.urlPlaceholder": "https://…",
+ "imageInput.hintUpload": "JPG/PNG/WebP/GIF · até {{size}}",
+ "imageInput.hintUrl": "Link direto para uma imagem hospedada em outro lugar.",
+ "imageInput.toastUploaded": "Imagem enviada",
+ "imageInput.uploadFailed": "Falha no envio",
+ "imageInput.noPublicUrl": "O servidor não retornou publicUrl para este arquivo.",
+ "pim.upload": "Enviar imagens",
+ "pim.count_one": "{{count}} imagem · JPG / PNG / WebP / GIF · até 10 MB",
+ "pim.count_other": "{{count}} imagens · JPG / PNG / WebP / GIF · até 10 MB",
+ "pim.empty": "Nenhuma imagem ainda. Envie uma para definir a capa do produto.",
+ "pim.toastCoverUpdated": "Imagem de capa atualizada",
+ "pim.toastImageRemoved": "Imagem removida",
+ "pim.errAttach": "Falha ao anexar imagem",
+ "pim.errSetCover": "Falha ao definir a capa",
+ "pim.errRemove": "Falha ao remover imagem",
+ "pim.errUploadNamed": "Falha no envio: {{name}}",
+ "pim.noPublicUrl": "O servidor não retornou publicUrl para a imagem enviada.",
+ "pim.cover": "Capa",
+ "pim.setCover": "Definir como capa",
+ "pim.removeImage": "Remover imagem",
+ "pim.previewImage": "Visualizar imagem",
+ "pim.previewTitle": "Pré-visualização da imagem",
+ "pim.currentCover": "Imagem de capa atual.",
+ "pim.clickOutside": "Clique fora para fechar.",
+ "pim.close": "Fechar",
+ "pim.detachTitle": "Desanexar esta imagem?",
+ "pim.detachBody": "A imagem é removida deste produto. ",
+ "pim.detachCoverNote": "Ela é a capa atual — outra imagem será promovida automaticamente.",
+ "pim.cancel": "Cancelar",
+ "pim.removing": "Removendo…",
+ "pim.remove": "Remover"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/health.json b/clients/dashboard/src/locales/pt-BR/health.json
new file mode 100644
index 0000000000..783393cec5
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/health.json
@@ -0,0 +1,48 @@
+{
+ "eyebrow": "Sistema · Saúde",
+ "title": "Saúde",
+ "subtitlePre": "Sonda de prontidão ao vivo em cada dependência registrada. Consultado a cada ",
+ "subtitleSuffix": ".",
+ "pauseTitle": "Pausar atualização automática",
+ "resumeTitle": "Retomar atualização automática",
+ "live": "Ao vivo",
+ "paused": "Pausado",
+ "refresh": "Atualizar",
+ "dependencies": "Dependências",
+ "checksTotal_one": "{{count}} verificação · total",
+ "checksTotal_other": "{{count}} verificações · total",
+ "hero.Healthy.headline": "Todos os sistemas operacionais",
+ "hero.Healthy.subline": "Todas as dependências estão respondendo dentro da tolerância.",
+ "hero.Degraded.headline": "Degradação parcial",
+ "hero.Degraded.subline": "Uma ou mais verificações estão reportando latência elevada ou avisos.",
+ "hero.Unhealthy.headline": "Interrupção detectada",
+ "hero.Unhealthy.subline": "Ao menos uma dependência crítica está inacessível. Investigue as verificações com falha abaixo.",
+ "status.Healthy": "Saudável",
+ "status.Degraded": "Degradado",
+ "status.Unhealthy": "Com falha",
+ "http": "HTTP {{code}}",
+ "vital.checks": "Verificações",
+ "vital.checksHint": "aprovadas",
+ "vital.roundTrip": "Ida e volta",
+ "vital.roundTripHint": "ponta a ponta",
+ "vital.slowest": "Mais lenta",
+ "vital.slowestHint": "verificação única",
+ "vital.lastPoll": "Última consulta",
+ "recentPolls": "Consultas recentes",
+ "sessionCount": "{{n}} / {{limit}} nesta sessão",
+ "historyAria": "Histórico de consultas recentes",
+ "historyTick": "{{status}} · {{time}}",
+ "historyNoData": "sem dados",
+ "noDescription": "Nenhuma descrição registrada.",
+ "latencyBudget": "Orçamento de latência",
+ "latencyBudgetValue": "{{value}} / 500 ms",
+ "latencyBudgetHint": "Escalonado contra um orçamento de prontidão suave de 500 ms. Barras no terço superior são território de alerta antecipado.",
+ "detail": "Detalhe",
+ "noDetails": "Nenhum detalhe adicional reportado.",
+ "moreDetails": "+{{count}} mais",
+ "error.title": "Endpoint de saúde inacessível",
+ "error.default": "O navegador não conseguiu acessar /health/ready. A API pode estar fora do ar, ou uma política de rede está bloqueando a requisição.",
+ "empty.title": "Nenhuma verificação registrada",
+ "empty.bodyPre": "Módulos podem registrar verificações de dependência via ",
+ "empty.bodyPost": ". Nenhuma está reportando ainda."
+}
diff --git a/clients/dashboard/src/locales/pt-BR/identity.json b/clients/dashboard/src/locales/pt-BR/identity.json
new file mode 100644
index 0000000000..c72da0809a
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/identity.json
@@ -0,0 +1,331 @@
+{
+ "unnamedUser": "Usuário sem nome",
+ "backToUsers": "Voltar para usuários",
+ "notFound": "Usuário não encontrado.",
+ "member": "Membro",
+ "cancel": "Cancelar",
+ "badge.active": "Ativo",
+ "badge.inactive": "Inativo",
+ "badge.emailConfirmed": "Email confirmado",
+ "badge.emailPending": "Email pendente",
+ "actions.impersonate": "Personificar",
+ "actions.impersonateDisabledTitle": "Não é possível personificar um usuário inativo",
+ "actions.sending": "Enviando…",
+ "actions.resendConfirmation": "Reenviar confirmação",
+ "actions.confirming": "Confirmando…",
+ "actions.confirmEmail": "Confirmar email",
+ "actions.deactivate": "Desativar",
+ "actions.reactivate": "Reativar",
+ "actions.delete": "Excluir",
+ "stat.role_one": "função",
+ "stat.role_other": "funções",
+ "stat.session_one": "sessão",
+ "stat.session_other": "sessões",
+ "card.title": "Cartão de identidade",
+ "card.description": "Somente leitura aqui. Os membros atualizam o próprio perfil nas configurações.",
+ "field.username": "Usuário",
+ "field.email": "Email",
+ "field.firstName": "Nome",
+ "field.lastName": "Sobrenome",
+ "field.phone": "Telefone",
+ "field.id": "ID",
+ "roles.title": "Atribuição de funções",
+ "roles.description": "Alterne quais funções se aplicam. As alterações ficam pendentes até salvar.",
+ "roles.pending_one": "{{count}} pendente",
+ "roles.pending_other": "{{count}} pendentes",
+ "roles.discard": "Descartar",
+ "roles.saving": "Salvando…",
+ "roles.save": "Salvar alterações",
+ "roles.emptyPre": "Nenhuma função definida.",
+ "roles.emptyLink": "Crie uma",
+ "roles.emptyPost": "para começar a atribuir acesso.",
+ "roles.untitled": "Função sem título",
+ "roles.modified": "modificada",
+ "roles.toggleAria": "Alternar {{name}}",
+ "roles.roleFallback": "função",
+ "roles.updated": "Funções atualizadas",
+ "roles.updatedDesc_one": "{{count}} função alterada.",
+ "roles.updatedDesc_other": "{{count}} funções alteradas.",
+ "roles.updateFailed": "Falha na atualização",
+ "status.deactivated": "Usuário desativado",
+ "status.reactivated": "Usuário reativado",
+ "status.changeFailed": "Falha ao alterar o status",
+ "status.deactivateTitle": "Desativar usuário?",
+ "status.reactivateTitle": "Reativar usuário?",
+ "status.deactivateDesc": "{{name}} não poderá entrar até ser reativado. As sessões existentes permanecem, a menos que sejam revogadas.",
+ "status.reactivateDesc": "{{name}} recuperará o acesso de login imediatamente.",
+ "status.working": "Processando…",
+ "email.confirmed": "Email confirmado",
+ "email.confirmFailed": "Falha ao confirmar o email",
+ "email.resent": "Email de confirmação enviado",
+ "email.resendFailed": "Não foi possível enviar o email de confirmação",
+ "delete.deleted": "Usuário excluído",
+ "delete.failed": "Falha ao excluir",
+ "delete.title": "Excluir este membro",
+ "delete.descPre": "Isto remove permanentemente",
+ "delete.descPost": "Ele perderá o acesso imediatamente. Isto não pode ser desfeito.",
+ "delete.deleting": "Excluindo…",
+ "delete.confirm": "Excluir usuário",
+ "sessions.revoked": "Sessão revogada",
+ "sessions.revokeFailed": "Falha ao revogar",
+ "sessions.revokedCount_one": "{{count}} sessão revogada",
+ "sessions.revokedCount_other": "{{count}} sessões revogadas",
+ "sessions.revokeAllFailed": "Falha ao revogar todas",
+ "sessions.unknownBrowser": "Navegador desconhecido",
+ "sessions.unknownOs": "SO desconhecido",
+ "sessions.title": "Sessões ativas",
+ "sessions.loading": "Carregando sessões…",
+ "sessions.summary": "{{active}} ativas · {{total}} registradas no total",
+ "sessions.revokeAll": "Revogar todas",
+ "sessions.empty": "Nenhuma sessão registrada. O usuário não entrou recentemente.",
+ "sessions.badgeActive": "Ativa",
+ "sessions.badgeEnded": "Encerrada",
+ "sessions.lastSeen": "vista por último {{date}}",
+ "sessions.started": "iniciada {{date}}",
+ "sessions.revoking": "Revogando…",
+ "sessions.revoke": "Revogar",
+ "sessions.revokeAllTitle": "Revogar todas as sessões de {{name}}?",
+ "sessions.revokeAllDesc": "Toda sessão ativa — desktop, celular, aba do navegador — será encerrada imediatamente. O usuário precisará entrar novamente em cada dispositivo.",
+ "sessions.revokingAll": "Revogando…",
+ "impersonate.started": "Personificação iniciada",
+ "impersonate.startedDesc": "Você agora está agindo como este usuário. Use o banner para encerrar.",
+ "impersonate.failed": "Falha na personificação",
+ "impersonate.title": "Personificar {{name}}?",
+ "impersonate.description": "Você agirá como este usuário em todo o dashboard. Cada ação que você fizer será atribuída a ele nos logs de auditoria (com o seu id de operador preservado como autor). Encerre a personificação pelo banner no topo da página quando terminar.",
+ "impersonate.reasonLabel": "Motivo (opcional, registrado no log de auditoria)",
+ "impersonate.reasonPlaceholder": "Investigando um relato de bug deste usuário…",
+ "impersonate.starting": "Iniciando…",
+ "impersonate.start": "Iniciar personificação",
+ "field.name": "Nome",
+ "field.description": "Descrição",
+ "field.password": "Senha",
+ "field.confirm": "Confirmar",
+ "rolesList.title": "Funções",
+ "rolesList.description": "Funções agrupam permissões em faixas de autoridade nomeadas. Atribua-as pela página de detalhe do usuário.",
+ "rolesList.newRole": "Nova função",
+ "rolesList.searchPlaceholder": "Buscar por nome ou descrição…",
+ "rolesList.emptyFoundTitle": "Nenhuma função encontrada",
+ "rolesList.emptyTitle": "Nenhuma função ainda",
+ "rolesList.emptySearchBody": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe a busca.",
+ "rolesList.emptyBody": "Crie a primeira função para começar a agrupar permissões. Sem funções, os membros ficam sem acesso por padrão.",
+ "rolesList.clearSearch": "Limpar busca",
+ "rolesList.addRole": "Adicionar função",
+ "rolesList.found_one": "{{count}} função encontrada",
+ "rolesList.found_other": "{{count}} funções encontradas",
+ "rolesList.colName": "Nome",
+ "rolesList.colDescription": "Descrição",
+ "rolesList.colPermissions": "Permissões",
+ "rolesList.system": "Sistema",
+ "rolesList.noDescription": "Sem descrição registrada.",
+ "rolesList.openAria": "Abrir função {{name}}",
+ "rolesList.permNone": "—",
+ "rolesList.perm_one": "{{count}} permissão",
+ "rolesList.perm_other": "{{count}} permissões",
+ "rolesList.createTitle": "Criar uma função",
+ "rolesList.createDescription": "Funções agrupam permissões. Após criar, você será levado ao editor para atribuir permissões e ajustar o acesso.",
+ "rolesList.namePlaceholder": "Gerente",
+ "rolesList.descHint": "Mostrado aos administradores ao atribuir funções. Linguagem simples ajuda.",
+ "rolesList.descPlaceholder": "Pode gerenciar usuários e atribuir funções",
+ "rolesList.creating": "Criando…",
+ "rolesList.createSubmit": "Criar função",
+ "rolesList.toastCreated": "Função criada",
+ "rolesList.toastCreatedDesc": "Agora configure as permissões de {{name}}.",
+ "rolesList.toastCreateFailed": "Falha ao criar",
+ "groupsList.title": "Grupos",
+ "groupsList.description": "Grupos agrupam membros e funções em coortes reutilizáveis. Adicione um usuário a um grupo para conceder todas as funções vinculadas a ele.",
+ "groupsList.newGroup": "Novo grupo",
+ "groupsList.searchPlaceholder": "Buscar por nome ou descrição…",
+ "groupsList.emptyFoundTitle": "Nenhum grupo encontrado",
+ "groupsList.emptyTitle": "Nenhum grupo ainda",
+ "groupsList.emptySearchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo.",
+ "groupsList.emptySearchBodyNoTerm": "Nenhum grupo corresponde aos filtros atuais.",
+ "groupsList.emptyBody": "Crie o primeiro grupo para agrupar membros e funções. Útil para times, departamentos ou coortes de recursos.",
+ "groupsList.clearSearch": "Limpar busca",
+ "groupsList.addGroup": "Adicionar grupo",
+ "groupsList.found_one": "{{count}} grupo encontrado",
+ "groupsList.found_other": "{{count}} grupos encontrados",
+ "groupsList.colGroup": "Grupo",
+ "groupsList.colComposition": "Composição",
+ "groupsList.colFlags": "Sinalizadores",
+ "groupsList.default": "Padrão",
+ "groupsList.system": "Sistema",
+ "groupsList.noDescription": "Sem descrição registrada.",
+ "groupsList.openAria": "Abrir grupo {{name}}",
+ "groupsList.member_one": "{{count}} membro",
+ "groupsList.member_other": "{{count}} membros",
+ "groupsList.role_one": "{{count}} função",
+ "groupsList.role_other": "{{count}} funções",
+ "groupsList.createTitle": "Criar um grupo",
+ "groupsList.createDescription": "Grupos agrupam membros e funções. Após criar, você será levado ao editor para anexar funções e adicionar membros.",
+ "groupsList.namePlaceholder": "Engenharia",
+ "groupsList.descHint": "Ajuda os administradores a entender o que esta coorte representa.",
+ "groupsList.descPlaceholder": "Engenheiros de web, mobile e plataforma.",
+ "groupsList.defaultLabel": "Grupo padrão",
+ "groupsList.defaultHint": "Usuários recém-registrados entram automaticamente.",
+ "groupsList.creating": "Criando…",
+ "groupsList.createSubmit": "Criar grupo",
+ "groupsList.toastCreated": "Grupo criado",
+ "groupsList.toastCreatedDesc": "Adicione membros e funções a seguir.",
+ "groupsList.toastCreateFailed": "Falha ao criar",
+ "usersList.title": "Usuários",
+ "usersList.description": "Todo membro com acesso a esta organização. Registre novos usuários, revise o status e gerencie funções.",
+ "usersList.registerUser": "Registrar usuário",
+ "usersList.searchPlaceholder": "Buscar por nome, usuário ou e-mail…",
+ "usersList.filterAccountStatus": "Status da conta",
+ "usersList.filterAll": "Todos",
+ "usersList.filterActive": "Ativo",
+ "usersList.filterInactive": "Inativo",
+ "usersList.filterEmailStatus": "Status do e-mail",
+ "usersList.filterAnyEmail": "Qualquer e-mail",
+ "usersList.filterConfirmed": "Confirmado",
+ "usersList.filterPending": "Pendente",
+ "usersList.filterRole": "Função",
+ "usersList.emptyFoundTitle": "Nenhum usuário encontrado",
+ "usersList.emptyTitle": "Nenhum usuário ainda",
+ "usersList.emptySearchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe os filtros.",
+ "usersList.emptySearchBodyNoTerm": "Nenhum usuário corresponde aos filtros atuais.",
+ "usersList.emptyBody": "Registre o primeiro membro para popular esta organização. Ele receberá um e-mail de confirmação se a confirmação de e-mail estiver habilitada.",
+ "usersList.clearFilters": "Limpar filtros",
+ "usersList.found_one": "{{count}} usuário encontrado",
+ "usersList.found_other": "{{count}} usuários encontrados",
+ "usersList.colName": "Nome",
+ "usersList.colUsername": "Usuário",
+ "usersList.colStatus": "Status",
+ "usersList.noEmail": "sem e-mail",
+ "usersList.noEmailFile": "sem e-mail registrado",
+ "usersList.openAria": "Abrir usuário {{name}}",
+ "usersList.badgeConfirmed": "Confirmado",
+ "usersList.badgePending": "Pendente",
+ "usersList.registerTitle": "Registrar um membro",
+ "usersList.registerDescription": "Adicione um novo usuário a esta organização. Usuário e e-mail devem ser únicos. Senhas precisam de uma letra maiúscula, uma minúscula e um dígito.",
+ "usersList.firstPlaceholder": "Ada",
+ "usersList.lastPlaceholder": "Lovelace",
+ "usersList.usernameHint": "Usado no login. Minúsculas, sem espaços.",
+ "usersList.usernamePlaceholder": "ada.lovelace",
+ "usersList.emailPlaceholder": "ada@exemplo.com",
+ "usersList.phoneHint": "Número de contato opcional.",
+ "usersList.phonePlaceholder": "+55 …",
+ "usersList.confirmMismatch": "As senhas não coincidem.",
+ "usersList.registering": "Registrando…",
+ "usersList.registerSubmit": "Registrar usuário",
+ "usersList.toastRegistered": "Usuário registrado",
+ "usersList.toastRegisteredDesc": "Uma confirmação de e-mail pode ser necessária antes do login.",
+ "usersList.toastRegisterFailed": "Falha no registro",
+ "roleDetail.back": "Voltar para funções",
+ "roleDetail.notFound": "Função não encontrada.",
+ "roleDetail.catalogError": "Não foi possível carregar o catálogo de permissões: {{error}}",
+ "roleDetail.system": "Sistema",
+ "roleDetail.subtitleSystem": "Função interna gerenciada pelo framework.",
+ "roleDetail.subtitleCustom": "Função personalizada.",
+ "roleDetail.deleteRole": "Excluir função",
+ "roleDetail.deleteSystemTitle": "Funções de sistema não podem ser excluídas.",
+ "roleDetail.statPermissions": "permissões",
+ "roleDetail.readOnlyTitle": "Função interna, somente leitura",
+ "roleDetail.readOnlyBody": " vem com o framework. Seu nome, descrição e permissões são gerenciados centralmente para que o contrato de seed e o sincronizador de permissões em runtime fiquem alinhados. Crie uma função personalizada se precisar de um conjunto de concessões diferente.",
+ "roleDetail.detailsTitle": "Detalhes da função",
+ "roleDetail.detailsDescription": "O rótulo de exibição e um resumo de uma linha que os administradores verão no momento da atribuição.",
+ "roleDetail.descPlaceholder": "Descrição curta para esta função",
+ "roleDetail.permsTitle": "Permissões",
+ "roleDetail.permsDescription": "Busque, filtre e alterne permissões individuais, ou aplique um padrão sensato a partir de um preset. Algumas permissões de nível raiz podem ser filtradas no servidor.",
+ "roleDetail.presetBasic": "Básico",
+ "roleDetail.presetAll": "Todas",
+ "roleDetail.presetClear": "Limpar",
+ "roleDetail.unsaved": "Alterações não salvas",
+ "roleDetail.allSaved": "Todas as alterações salvas",
+ "roleDetail.discard": "Descartar",
+ "roleDetail.saving": "Salvando…",
+ "roleDetail.saveChanges": "Salvar alterações",
+ "roleDetail.searchPlaceholder": "Buscar por recurso, ação ou descrição…",
+ "roleDetail.searchAria": "Buscar permissões",
+ "roleDetail.clearSearch": "Limpar busca",
+ "roleDetail.filterAll": "Todas",
+ "roleDetail.filterEnabled": "Habilitadas",
+ "roleDetail.filterModified": "Modificadas",
+ "roleDetail.filterBasic": "Básicas",
+ "roleDetail.enabled": "habilitadas",
+ "roleDetail.modified": "{{n}} modificadas",
+ "roleDetail.showingMatches_one": "mostrando {{count}} correspondência",
+ "roleDetail.showingMatches_other": "mostrando {{count}} correspondências",
+ "roleDetail.expandAll": "Expandir tudo",
+ "roleDetail.collapseAll": "Recolher tudo",
+ "roleDetail.noMatchTitle": "Nenhuma permissão corresponde",
+ "roleDetail.noMatchBody": "Tente outro termo ou limpe o filtro atual.",
+ "roleDetail.resetFilters": "Redefinir filtros",
+ "roleDetail.toggleAllAria": "Alternar todas {{resource}}",
+ "roleDetail.changed": "{{n}} alteradas",
+ "roleDetail.changedTitle_one": "{{count}} alteração não salva",
+ "roleDetail.changedTitle_other": "{{count}} alterações não salvas",
+ "roleDetail.groupMatches_one": "· {{count}} correspondência",
+ "roleDetail.groupMatches_other": "· {{count}} correspondências",
+ "roleDetail.groupAll": "Todas",
+ "roleDetail.groupNone": "Nenhuma",
+ "roleDetail.rootBadge": "raiz",
+ "roleDetail.rootTitle": "Permissão de nível raiz. Pode ser filtrada no servidor.",
+ "roleDetail.basicBadge": "básica",
+ "roleDetail.modifiedAria": "modificada",
+ "roleDetail.deleteTitle": "Excluir função",
+ "roleDetail.deleteBodyPre": "Isto remove permanentemente ",
+ "roleDetail.deleteBodyPost": ". Os membros atualmente atribuídos perderão suas permissões imediatamente.",
+ "roleDetail.deleting": "Excluindo…",
+ "roleDetail.deleteConfirm": "Excluir função",
+ "roleDetail.toastUpdateFailed": "Falha ao atualizar",
+ "roleDetail.toastPermsFailed": "Falha ao atualizar permissões",
+ "roleDetail.toastDeleted": "Função excluída",
+ "roleDetail.toastDeleteFailed": "Falha ao excluir",
+ "roleDetail.toastSaved": "Função salva",
+ "groupDetail.back": "Voltar para grupos",
+ "groupDetail.notFound": "Grupo não encontrado.",
+ "groupDetail.default": "Padrão",
+ "groupDetail.system": "Sistema",
+ "groupDetail.subtitle": "Coorte de grupo.",
+ "groupDetail.deleteGroup": "Excluir grupo",
+ "groupDetail.memberLabel_one": "membro",
+ "groupDetail.memberLabel_other": "membros",
+ "groupDetail.roleLabel_one": "função",
+ "groupDetail.roleLabel_other": "funções",
+ "groupDetail.detailsTitle": "Detalhes do grupo",
+ "groupDetail.detailsDescription": "Nome, descrição e as funções vinculadas a este grupo.",
+ "groupDetail.discard": "Descartar",
+ "groupDetail.saving": "Salvando…",
+ "groupDetail.saveChanges": "Salvar alterações",
+ "groupDetail.descPlaceholder": "Resumo curto de quem ou o que este grupo representa",
+ "groupDetail.defaultLabel": "Grupo padrão",
+ "groupDetail.defaultHint": "Atribuir automaticamente a usuários recém-registrados.",
+ "groupDetail.rolesAttached": "Funções vinculadas",
+ "groupDetail.noRolesPre": "Nenhuma função definida. ",
+ "groupDetail.noRolesLink": "Crie uma",
+ "groupDetail.noRolesPost": " primeiro.",
+ "groupDetail.attachAria": "Vincular {{name}}",
+ "groupDetail.membersTitle": "Membros",
+ "groupDetail.membersDescription": "Usuários que pertencem a este grupo herdam todas as funções vinculadas acima.",
+ "groupDetail.addMembers": "Adicionar membros",
+ "groupDetail.emptyMembersPre": "Ninguém neste grupo ainda. Clique em ",
+ "groupDetail.emptyMembersStrong": "Adicionar membros",
+ "groupDetail.emptyMembersPost": " para vincular usuários.",
+ "groupDetail.remove": "Remover",
+ "groupDetail.unknownUser": "Usuário desconhecido",
+ "groupDetail.deleteTitle": "Excluir grupo",
+ "groupDetail.deleteBodyPost": " será removido. Os membros perderão qualquer permissão herdada por meio deste grupo.",
+ "groupDetail.deleting": "Excluindo…",
+ "groupDetail.deleteConfirm": "Excluir grupo",
+ "groupDetail.toastUpdated": "Grupo atualizado",
+ "groupDetail.toastUpdateFailed": "Falha ao atualizar",
+ "groupDetail.toastDeleted": "Grupo excluído",
+ "groupDetail.toastDeleteFailed": "Falha ao excluir",
+ "groupDetail.toastMemberRemoved": "Membro removido",
+ "groupDetail.toastRemoveFailed": "Falha ao remover",
+ "groupDetail.pickTitle": "Escolha membros para adicionar",
+ "groupDetail.pickDescription": "Busque usuários ativos e selecione um ou mais para vincular a este grupo.",
+ "groupDetail.searchPlaceholder": "Buscar por nome, usuário ou e-mail…",
+ "groupDetail.clearSearch": "Limpar busca",
+ "groupDetail.noMatch": "Nenhum usuário corresponde a \"{{term}}\".",
+ "groupDetail.noUsers": "Nenhum usuário disponível.",
+ "groupDetail.alreadyIn": "já no grupo",
+ "groupDetail.selected": "{{count}} selecionados",
+ "groupDetail.adding": "Adicionando…",
+ "groupDetail.addN": "Adicionar {{count}}",
+ "groupDetail.toastAdded_one": "{{count}} membro adicionado",
+ "groupDetail.toastAdded_other": "{{count}} membros adicionados",
+ "groupDetail.toastAddedDupes": " · {{count}} já presentes",
+ "groupDetail.toastAddFailed": "Falha ao adicionar"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/notifications.json b/clients/dashboard/src/locales/pt-BR/notifications.json
new file mode 100644
index 0000000000..b919490d0b
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/notifications.json
@@ -0,0 +1,32 @@
+{
+ "bell.aria": "Notificações",
+ "bell.ariaUnread": "Notificações, {{count}} não lidas",
+ "bell.title": "Notificações",
+ "header.title": "Notificações",
+ "header.allCaughtUp": "Tudo em dia",
+ "header.summary": "{{unread}} não lidas · {{loaded}} carregadas",
+ "markAllRead": "Marcar todas como lidas",
+ "recent": "Recentes",
+ "loading": "Carregando…",
+ "empty": "Nada ainda. Menções e atualizações de canais aparecerão aqui.",
+ "settings": "Configurações ↗",
+ "rel.justNow": "agora mesmo",
+ "rel.minutes": "{{n}}min",
+ "rel.hours": "{{n}}h",
+ "rel.days": "{{n}}d",
+ "rel.weeks": "{{n}}sem",
+ "toast.channelFallback": "canal",
+ "toast.aChannel": "um canal",
+ "toast.directMessage": "mensagem direta",
+ "toast.groupChat": "conversa em grupo",
+ "toast.justNow": "agora mesmo",
+ "toast.openConversation": "Abrir conversa",
+ "toast.emptyBody": "(anexo ou corpo vazio)",
+ "toast.dismissAria": "Dispensar notificação",
+ "toast.dismiss": "Dispensar",
+ "badge.chat": "Chat",
+ "badge.ariaUnread_one": "Chat, {{count}} mensagem não lida",
+ "badge.ariaUnread_other": "Chat, {{count}} mensagens não lidas",
+ "badge.titleUnread_one": "{{count}} mensagem de chat não lida",
+ "badge.titleUnread_other": "{{count}} mensagens de chat não lidas"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/overview.json b/clients/dashboard/src/locales/pt-BR/overview.json
new file mode 100644
index 0000000000..0891845a8a
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/overview.json
@@ -0,0 +1,96 @@
+{
+ "greeting.morning": "Bom dia, {{name}}",
+ "greeting.afternoon": "Boa tarde, {{name}}",
+ "greeting.evening": "Boa noite, {{name}}",
+ "operator": "operador",
+ "yourTenant": "sua organização",
+ "refresh": "Atualizar",
+ "viewActivity": "Ver atividade",
+ "viewAudits": "Ver auditorias",
+ "open": "Abrir",
+ "seeAll": "Ver tudo",
+ "stat.plan": "Plano",
+ "stat.validFor": "Válido por",
+ "stat.resources": "Recursos",
+ "stat.liveEvents": "Eventos ao vivo",
+ "stat.unavailable": "Indisponível",
+ "stat.noSubscription": "Sem assinatura",
+ "stat.status.Active": "Ativa",
+ "stat.status.Suspended": "Suspensa",
+ "stat.status.Cancelled": "Cancelada",
+ "connState.idle": "ocioso",
+ "connState.connecting": "conectando",
+ "connState.connected": "conectado",
+ "connState.reconnecting": "reconectando",
+ "connState.error": "erro",
+ "validity.expired": "Expirada",
+ "validity.openEnded": "Sem prazo",
+ "validity.days": "dias",
+ "validity.statusUnavailable": "Status indisponível",
+ "validity.renew": "Entre em contato com o operador para renovar",
+ "validity.graceEnds": "carência termina {{date}}",
+ "validity.inGracePeriod": "em período de carência",
+ "validity.until": "até {{date}}",
+ "validity.noEndDate": "sem data de término",
+ "resources.avgUtilization": "utilização média",
+ "resources.overage": "excedente",
+ "subscription.loadErrorTitle": "Não foi possível carregar a assinatura",
+ "subscription.loadErrorBody": "O endpoint de assinatura retornou um erro. Tente atualizar.",
+ "subscription.noneTitle": "Nenhuma assinatura ativa",
+ "subscription.noneBody": "Escolha um plano para habilitar cobrança, cotas e controle de excedente.",
+ "subscription.viewCta": "Ver assinatura",
+ "subscription.started": "Início",
+ "subscription.ends": "Término",
+ "subscription.openEnded": "sem prazo",
+ "subscription.currentTerm": "Período atual",
+ "subscription.termProgress": "{{pct}}% · {{days}}d restantes",
+ "system.streamLive": "Stream ao vivo",
+ "system.offline": "offline",
+ "system.liveDesc": "Os eventos do backend estão chegando em tempo real.",
+ "system.erroredDesc": "Stream desconectado. Os eventos entrarão na fila assim que ele se recuperar.",
+ "system.waitingDesc": "Aguardando o stream ficar online.",
+ "system.eventsThisSession": "Eventos nesta sessão",
+ "audits.emptyTitle": "Nenhuma atividade recente",
+ "audits.emptyBody": "As ações auditadas nas últimas 24 horas aparecerão aqui.",
+ "audits.eventFallback": "Evento",
+ "audits.systemActor": "sistema",
+ "audits.ago": "há {{value}}",
+ "quick.inviteUsers.title": "Convidar usuários",
+ "quick.inviteUsers.description": "Adicione colegas, atribua funções.",
+ "quick.browseCatalog.title": "Explorar catálogo",
+ "quick.browseCatalog.description": "Produtos, marcas, categorias.",
+ "quick.subscription.title": "Assinatura",
+ "quick.subscription.description": "Plano, uso, faturas.",
+ "quick.liveActivity.title": "Atividade ao vivo",
+ "quick.liveActivity.description": "Stream de eventos em tempo real.",
+ "liveFeed.emptyTitle": "Aguardando atividade",
+ "liveFeed.emptyBody": "Os eventos aparecerão aqui conforme o backend os publica.",
+ "firstRun.dismissAria": "Dispensar checklist de configuração",
+ "firstRun.skipTitle": "Pular por enquanto",
+ "firstRun.welcome": "Bem-vindo ao {{name}}",
+ "firstRun.subtitle": "Sua organização está provisionada e pronta. É por aqui que a maioria das equipes começa.",
+ "firstRun.step": "Passo {{step}}",
+ "firstRun.tile.plan.title": "Escolha um plano",
+ "firstRun.tile.plan.description": "Habilite cobrança, cotas e controle de excedente.",
+ "firstRun.tile.team.title": "Convide sua equipe",
+ "firstRun.tile.team.description": "Adicione colegas, atribua funções e agrupe-os.",
+ "firstRun.tile.catalog.title": "Explorar catálogo",
+ "firstRun.tile.catalog.description": "Produtos, marcas e categorias de exemplo prontos para usar.",
+ "firstRun.tile.watch.title": "Acompanhe ao vivo",
+ "firstRun.tile.watch.description": "Stream SSE direto no dashboard.",
+ "section.subscription": "Assinatura",
+ "section.systemStatus": "Status do sistema",
+ "section.recentAudits": "Auditorias recentes",
+ "section.recentAuditsDesc": "Últimas 24 horas, 5 principais eventos.",
+ "section.usage": "Uso por recurso",
+ "section.usageDesc": "Consumo do mês atual em relação aos limites do plano.",
+ "section.quickActions": "Ações rápidas",
+ "section.quickActionsDesc": "Vá direto para os destinos mais usados.",
+ "section.liveFeed": "Feed ao vivo",
+ "section.liveFeedDesc": "Eventos do backend em tempo real via SSE.",
+ "usage.errorTitle": "Não foi possível carregar o uso",
+ "usage.errorBody": "O endpoint de uso retornou um erro. Tente atualizar.",
+ "usage.emptyTitle": "Nenhum uso registrado ainda",
+ "usage.emptyBody": "A atividade aparecerá aqui conforme o backend registra snapshots deste período.",
+ "usageOverageBadge": "excedente"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/settings.json b/clients/dashboard/src/locales/pt-BR/settings.json
new file mode 100644
index 0000000000..bc9842511f
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/settings.json
@@ -0,0 +1,209 @@
+{
+ "title": "Configurações",
+ "sections": "Seções",
+ "sectionsNav": "Seções de configurações",
+ "tab.profile.label": "Perfil",
+ "tab.profile.hint": "Sua identidade na organização",
+ "tab.security.label": "Segurança",
+ "tab.security.hint": "Senha e sessões ativas",
+ "tab.appearance.label": "Aparência",
+ "tab.appearance.hint": "Tema e preferências visuais",
+ "tab.branding.label": "Marca",
+ "tab.branding.hint": "Cores e logos da organização",
+ "tab.notifications.label": "Notificações",
+ "tab.notifications.hint": "Como falamos com você",
+ "tab.apiKeys.label": "Chaves de API",
+ "tab.apiKeys.hint": "Tokens de acesso pessoais",
+ "profile.toastSaved": "Perfil salvo",
+ "profile.saveError": "Falha ao salvar o perfil",
+ "profile.toastSaveFail": "Falha ao salvar",
+ "profile.imageUpdated": "Imagem de perfil atualizada",
+ "profile.imageUpdateError": "Falha ao atualizar a imagem de perfil",
+ "profile.loadError": "Não foi possível carregar seu perfil. Exibindo os detalhes da sua sessão; alterações salvas podem não refletir o estado mais recente do servidor.",
+ "profile.photo.title": "Foto",
+ "profile.photo.description": "Exibida na barra superior e na sua atividade. Recortes quadrados funcionam melhor — JPG, PNG ou WebP.",
+ "profile.identity.title": "Identidade",
+ "profile.identity.description": "Seu nome e detalhes de contato, visíveis em todo o dashboard.",
+ "profile.reset": "Redefinir",
+ "profile.saving": "Salvando…",
+ "profile.save": "Salvar alterações",
+ "profile.field.firstName": "Nome",
+ "profile.field.lastName": "Sobrenome",
+ "profile.field.email": "Email",
+ "profile.field.phone": "Telefone",
+ "profile.emailHint": "Entre em contato com o administrador da organização para alterar o email de acesso.",
+ "profile.phonePlaceholder": "+55 (11) 91234-5678",
+ "profile.subject.title": "Identificador do usuário",
+ "profile.subject.description": "O ID único que esta conta usa dentro da plataforma. Somente leitura.",
+ "notifications.title": "Preferências de notificação",
+ "notifications.description": "Escolha quais eventos você quer receber por email ou como notificações no app.",
+ "notifications.emptyTitle": "As preferências por evento ainda não são ajustáveis.",
+ "notifications.emptyBody": "As notificações no app já chegam ao seu sino, abra-o na barra superior para vê-las. As opções granulares de email por evento estão no roadmap da v1.1. Enquanto isso, o administrador da organização pode desativar o envio de emails globalmente.",
+ "notifications.openBell": "Abrir sino de notificações",
+ "apiKeys.title": "Chaves de API",
+ "apiKeys.description": "Credenciais de longa duração usadas por serviços para chamar a API do FSH. Trate-as como senhas.",
+ "apiKeys.emptyTitle": "As chaves de API ainda não estão disponíveis.",
+ "apiKeys.emptyBody": "Tokens de acesso pessoais e chaves de API entre serviços estão no roadmap da v1.1. Por enquanto, seu acesso passa pelo JWT vinculado ao usuário emitido no login. Acompanhe o progresso no roadmap público ou nas próximas notas de versão.",
+ "apiKeys.viewRoadmap": "Ver roadmap",
+ "appearance.theme.title": "Tema",
+ "appearance.theme.description": "Escolha um modo de cor para o dashboard. Sistema segue o seu SO.",
+ "appearance.theme.light": "Claro",
+ "appearance.theme.lightBlurb": "Tela clara, conforto para o dia.",
+ "appearance.theme.system": "Sistema",
+ "appearance.theme.systemBlurb": "Segue a preferência do SO.",
+ "appearance.theme.dark": "Escuro",
+ "appearance.theme.darkBlurb": "Menos brilho para sessões longas.",
+ "appearance.theme.ariaLabel": "Tema {{label}}",
+ "appearance.active": "Ativo",
+ "appearance.accent.title": "Destaque",
+ "appearance.accent.description": "Escolha a cor de marca usada em ações principais, gráficos e destaques em todo o dashboard.",
+ "appearance.accent.ariaLabel": "Destaque {{label}}",
+ "appearance.font.title": "Fonte",
+ "appearance.font.description": "A tipografia da interface. Blocos de código mono sempre usam JetBrains Mono.",
+ "appearance.font.ariaLabel": "Fonte {{label}}",
+ "appearance.density.title": "Densidade",
+ "appearance.density.description": "O modo compacto reduz o preenchimento dos cards e a altura das linhas para telas com muitos dados.",
+ "appearance.density.toggle": "Usar espaçamento compacto em todo o dashboard.",
+ "appearance.density.ariaLabel": "Densidade compacta",
+ "appearance.motion.title": "Movimento",
+ "appearance.motion.descPre": "Sobrescreve a configuração",
+ "appearance.motion.descPost": "do sistema.",
+ "appearance.motion.toggle": "Desativar transições e animações decorativas.",
+ "appearance.motion.ariaLabel": "Reduzir movimento",
+ "appearance.custom.ariaLabel": "Destaque personalizado",
+ "appearance.custom.title": "Destaque personalizado — clique para editar",
+ "appearance.custom.label": "Personalizado",
+ "appearance.custom.dialogTitle": "Escolha a cor da sua marca",
+ "appearance.custom.dialogDescription": "Arraste a faixa de matiz para recolorir o destaque. A saturação escala o croma uniformemente pelos onze tons da marca.",
+ "appearance.custom.hue": "Matiz",
+ "appearance.custom.saturation": "Saturação",
+ "appearance.custom.brandLadder": "Escala da marca",
+ "appearance.custom.preview": "Prévia",
+ "appearance.custom.previewSubscription": "Assinatura",
+ "appearance.custom.previewActive": "ativa",
+ "appearance.custom.primaryAction": "Ação principal",
+ "appearance.custom.cancel": "Cancelar",
+ "appearance.custom.apply": "Aplicar destaque",
+ "security.sessions.unknownBrowser": "Navegador desconhecido",
+ "security.sessions.unknownOs": "SO desconhecido",
+ "security.sessions.unknownIp": "ip desconhecido",
+ "security.sessions.toastRevoked": "Sessão revogada",
+ "security.sessions.revokeError": "Não foi possível revogar a sessão.",
+ "security.sessions.toastRevokedCount_one": "{{count}} sessão revogada",
+ "security.sessions.toastRevokedCount_other": "{{count}} sessões revogadas",
+ "security.sessions.revokeAllError": "Não foi possível revogar as sessões.",
+ "security.sessions.loadError": "Falha ao carregar as sessões.",
+ "security.sessions.title": "Sessões ativas",
+ "security.sessions.activeCount_one": "{{count}} ativa",
+ "security.sessions.activeCount_other": "{{count}} ativas",
+ "security.sessions.description": "Navegadores e dispositivos conectados à sua conta no momento.",
+ "security.sessions.signOutOthers": "Sair de todos os outros",
+ "security.sessions.emptyTitle": "Nenhuma sessão registrada",
+ "security.sessions.emptyBody": "A atividade de sessão aparecerá aqui assim que você entrar de qualquer dispositivo.",
+ "security.sessions.thisDevice": "este dispositivo",
+ "security.sessions.revokedBadge": "revogada",
+ "security.sessions.meta": "{{ip}} · última atividade {{lastActivity}} · expira {{expires}}",
+ "security.sessions.revoking": "Revogando…",
+ "security.sessions.revoke": "Revogar",
+ "security.password.title": "Senha",
+ "security.password.description": "Usada para entrar nesta organização. Escolha uma senha forte e única.",
+ "security.password.recommendation": "Recomendamos uma frase secreta de 16+ caracteres sem reuso de outros serviços.",
+ "security.password.change": "Alterar senha",
+ "security.password.hide": "Ocultar senha",
+ "security.password.show": "Mostrar senha",
+ "security.password.toastSuccess": "Senha alterada",
+ "security.password.toastSuccessDesc": "As outras sessões ativas continuam válidas até você revogá-las.",
+ "security.password.changeError": "Não foi possível alterar a senha.",
+ "security.password.dialogDescription": "Escolha uma senha forte que você não reutilize em outros lugares. Suas outras sessões conectadas continuam ativas — revogue-as abaixo se quiser encerrá-las.",
+ "security.password.current": "Senha atual",
+ "security.password.new": "Nova senha",
+ "security.password.confirm": "Confirmar nova senha",
+ "security.password.match": "As senhas conferem",
+ "security.password.mismatchYet": "As senhas ainda não conferem",
+ "security.password.cancel": "Cancelar",
+ "security.password.updating": "Atualizando…",
+ "security.password.update": "Atualizar senha",
+ "security.validation.min8": "A nova senha deve ter pelo menos 8 caracteres.",
+ "security.validation.mismatch": "As senhas não conferem.",
+ "security.validation.mustDiffer": "A nova senha deve ser diferente da atual.",
+ "security.strength.weak": "Fraca",
+ "security.strength.fair": "Razoável",
+ "security.strength.good": "Boa",
+ "security.strength.strong": "Forte",
+ "security.twoFa.title": "Autenticação de dois fatores",
+ "security.twoFa.badgeOn": "ativada",
+ "security.twoFa.badgeOff": "desativada",
+ "security.twoFa.description": "Exige um código de uso único de um app autenticador em todo login.",
+ "security.twoFa.enrollFailed": "Falha na configuração",
+ "security.twoFa.enrollStartError": "Não foi possível iniciar a configuração.",
+ "security.twoFa.enabledToast": "Dois fatores ativado",
+ "security.twoFa.enabledToastDesc": "Os próximos logins exigirão um código de 6 dígitos do seu autenticador.",
+ "security.twoFa.verifyFailed": "Falha na verificação",
+ "security.twoFa.verifyMismatch": "Esse código não conferiu. Tente novamente.",
+ "security.twoFa.verifyError": "Não foi possível verificar o código.",
+ "security.twoFa.generating": "Gerando…",
+ "security.twoFa.enable": "Ativar dois fatores",
+ "security.twoFa.enrollHint": "Você vai escanear um QR code no seu app autenticador (1Password, Google Authenticator, Authy, …).",
+ "security.twoFa.qrAlt": "QR code de dois fatores",
+ "security.twoFa.rendering": "Renderizando…",
+ "security.twoFa.manualEntry": "não consegue escanear? insira manualmente",
+ "security.twoFa.copied": "copiado",
+ "security.twoFa.copy": "copiar",
+ "security.twoFa.codeLabel": "código de 6 dígitos do seu app",
+ "security.twoFa.codePlaceholder": "123 456",
+ "security.twoFa.verifying": "Verificando…",
+ "security.twoFa.confirmEnable": "Confirmar e ativar",
+ "security.twoFa.cancel": "Cancelar",
+ "security.twoFa.disabledToast": "Dois fatores desativado",
+ "security.twoFa.disableFailed": "Falha ao desativar",
+ "security.twoFa.disableWrongPassword": "A verificação da senha falhou.",
+ "security.twoFa.disableError": "Não foi possível desativar os dois fatores.",
+ "security.twoFa.disableDescription": "O dois fatores está ativado no momento. Confirme sua senha para desativar — isso rotaciona o segredo do autenticador, então uma nova configuração vai gerar um novo QR code.",
+ "security.twoFa.disabling": "Desativando…",
+ "security.twoFa.disable": "Desativar dois fatores",
+ "branding.title": "Marca",
+ "branding.description": "Tokens de tema consumidos pelos apps da sua organização no login. A prévia ao vivo reflete a ação principal com a paleta escolhida.",
+ "branding.loadingDescription": "Carregando marca…",
+ "branding.loading": "Carregando marca",
+ "branding.toastSaved": "Marca salva",
+ "branding.saveFailed": "Falha ao salvar",
+ "branding.toastReset": "Marca redefinida para os padrões",
+ "branding.resetFailed": "Falha ao redefinir",
+ "branding.unknownError": "Erro desconhecido",
+ "branding.badgeDefault": "padrão",
+ "branding.badgeUnsaved": "não salvo",
+ "branding.resetAriaLabel": "Redefinir marca para os padrões",
+ "branding.resetting": "Redefinindo…",
+ "branding.resetToDefaults": "Redefinir para os padrões",
+ "branding.saving": "Salvando…",
+ "branding.save": "Salvar marca",
+ "branding.lightPreview": "Prévia clara",
+ "branding.lightPalette": "Paleta clara",
+ "branding.darkPalette": "Paleta escura",
+ "branding.resetPalette": "Redefinir paleta",
+ "branding.pickColor": "Escolher cor {{label}}",
+ "branding.colorAriaLabel": "cor {{label}}",
+ "branding.field.primary": "Primária",
+ "branding.field.secondary": "Secundária",
+ "branding.field.tertiary": "Terciária",
+ "branding.field.background": "Fundo",
+ "branding.field.surface": "Superfície",
+ "branding.field.error": "Erro",
+ "branding.field.warning": "Aviso",
+ "branding.field.success": "Sucesso",
+ "branding.field.info": "Info",
+ "branding.assets.title": "Ativos de marca",
+ "branding.assets.description": "URLs dos seus ativos de marca hospedados. Faça o upload pelo módulo de Arquivos primeiro e depois cole aqui a URL pública resultante.",
+ "branding.assets.logo": "URL do logo",
+ "branding.assets.logoDark": "URL do logo (modo escuro)",
+ "branding.assets.favicon": "URL do favicon",
+ "branding.assets.placeholder": "https://cdn.example.com/logo.svg",
+ "branding.preview.live": "ao vivo",
+ "branding.preview.samplePage": "Página de exemplo da organização",
+ "branding.preview.active": "ativa",
+ "branding.preview.body": "Um parágrafo curto renderizado com a cor de corpo escolhida sobre a superfície escolhida, no fundo de página escolhido. Os botões de ação usam o token primário.",
+ "branding.preview.primaryAction": "Ação principal",
+ "branding.preview.secondary": "Secundária",
+ "branding.preview.warn": "aviso",
+ "branding.preview.error": "erro"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/subscription.json b/clients/dashboard/src/locales/pt-BR/subscription.json
new file mode 100644
index 0000000000..6e83266a02
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/subscription.json
@@ -0,0 +1,118 @@
+{
+ "title": "Assinatura",
+ "description": "O plano, a validade, o uso e as faturas recentes da sua organização.",
+ "plan.title": "Plano",
+ "plan.noneTitle": "Nenhuma assinatura ativa",
+ "plan.noneBody": "Sua organização não tem plano atribuído. Entre em contato com o operador para habilitar cobrança, cotas e controle de excedente.",
+ "plan.started": "Início",
+ "plan.ends": "Término",
+ "plan.openEnded": "sem prazo",
+ "plan.changesNote": "As mudanças de plano são feitas pelo operador. Entre em contato com o operador para fazer upgrade, renovar ou cancelar.",
+ "plan.status.Active": "Ativa",
+ "plan.status.Suspended": "Suspensa",
+ "plan.status.Cancelled": "Cancelada",
+ "validity.title": "Validade",
+ "validity.unavailable": "O status da organização está indisponível no momento.",
+ "validity.inactive": "Inativo",
+ "validity.validUntil": "Válido até",
+ "validity.graceEnds": "Carência termina",
+ "validity.state.inGrace": "Em carência",
+ "validity.state.expired": "Expirada",
+ "validity.state.active": "Ativa",
+ "validity.state.unknown": "Desconhecida",
+ "usage.title": "Uso por recurso",
+ "usage.description": "Consumo do mês atual em relação aos limites do seu plano.",
+ "usage.loadError": "Não foi possível carregar o uso. Tente atualizar.",
+ "usage.emptyTitle": "Nenhum uso registrado ainda",
+ "usage.emptyBody": "O consumo aparecerá aqui conforme os snapshots forem registrados neste período.",
+ "recent.title": "Faturas recentes",
+ "recent.description": "Suas cinco faturas mais recentes.",
+ "recent.seeAll": "Ver todas",
+ "recent.loadError": "Não foi possível carregar as faturas. Tente atualizar.",
+ "invoices.title": "Faturas",
+ "invoices.unit": "fatura",
+ "invoices.description": "O histórico de cobrança da sua organização, da mais recente para a mais antiga.",
+ "invoices.searchPlaceholder": "Buscar por número da fatura, status ou período…",
+ "invoices.loadError": "Falha ao carregar as faturas.",
+ "invoices.clearSearch": "Limpar busca",
+ "invoices.empty.foundTitle": "Nenhuma fatura encontrada",
+ "invoices.empty.emptyTitle": "Nenhuma fatura ainda",
+ "invoices.empty.foundBody": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe a busca.",
+ "invoices.empty.emptyBody": "Assim que sua organização for cobrada por um período, as faturas aparecerão aqui.",
+ "invoices.matched_one": "{{count}} fatura corresponde nesta página",
+ "invoices.matched_other": "{{count}} faturas correspondem nesta página",
+ "invoices.showing_one": "Exibindo {{shown}} de {{count}} fatura",
+ "invoices.showing_other": "Exibindo {{shown}} de {{count}} faturas",
+ "invoices.pageSuffix": " · página {{page}} de {{totalPages}}",
+ "invoices.col.number": "Fatura nº",
+ "invoices.col.customer": "Cliente",
+ "invoices.col.amount": "Valor",
+ "invoices.col.status": "Status",
+ "invoices.col.dueDate": "Vencimento",
+ "invoices.period": "período {{period}}",
+ "invoices.due": "vence {{date}}",
+ "invoices.paidOn": "pago {{date}}",
+ "invoices.status.Draft": "Rascunho",
+ "invoices.status.Issued": "Emitida",
+ "invoices.status.Paid": "Paga",
+ "invoices.status.Void": "Anulada",
+ "wallet.title": "Carteira do WhatsApp",
+ "wallet.description": "Seu saldo pré-pago para mensagens do WhatsApp. Solicite uma recarga e emitiremos uma fatura para creditar sua carteira.",
+ "wallet.currentBalance": "Saldo atual",
+ "wallet.lowEmpty": "Sua carteira está vazia. Solicite uma recarga para continuar enviando mensagens do WhatsApp.",
+ "wallet.lowSoon": "Seu saldo está acabando. Considere solicitar uma recarga em breve.",
+ "wallet.requestTitle": "Solicitar recarga",
+ "wallet.amountLabel": "Valor",
+ "wallet.amountHint": "Quanto adicionar à sua carteira, em {{currency}}.",
+ "wallet.amountPlaceholder": "100,00",
+ "wallet.noteLabel": "Observação",
+ "wallet.noteHint": "Opcional, algo que devemos saber sobre esta recarga.",
+ "wallet.notePlaceholder": "ex.: verba para a campanha de junho",
+ "wallet.submitting": "Enviando…",
+ "wallet.submit": "Solicitar recarga",
+ "wallet.toastRequested": "Recarga solicitada",
+ "wallet.toastRequestedDesc": "Emitiremos uma fatura para creditar sua carteira em breve.",
+ "wallet.toastFailed": "Falha ao solicitar recarga",
+ "wallet.requestsTitle": "Minhas solicitações de recarga",
+ "wallet.emptyTitle": "Nenhuma solicitação de recarga ainda",
+ "wallet.emptyBody": "Assim que você solicitar uma recarga, ela aparecerá aqui com o status: pendente, faturada ou concluída.",
+ "wallet.col.requested": "Solicitada",
+ "wallet.col.amount": "Valor",
+ "wallet.col.status": "Status",
+ "wallet.col.invoice": "Fatura",
+ "wallet.invoiceLink": "Fatura",
+ "wallet.viewInvoice": "Ver fatura",
+ "wallet.status.Pending": "Pendente",
+ "wallet.status.Invoiced": "Faturada",
+ "wallet.status.Completed": "Concluída",
+ "wallet.status.Rejected": "Rejeitada",
+ "wallet.status.Cancelled": "Cancelada",
+ "invoiceDetail.back": "Voltar para faturas",
+ "invoiceDetail.downloadFailed": "Falha no download",
+ "invoiceDetail.periodLabel": "Período {{period}}",
+ "invoiceDetail.preparing": "Preparando…",
+ "invoiceDetail.downloadPdf": "Baixar PDF",
+ "invoiceDetail.lineItems": "Itens",
+ "invoiceDetail.details": "Detalhes",
+ "invoiceDetail.notes": "Observações",
+ "invoiceDetail.noLineItems": "Nenhum item",
+ "invoiceDetail.noLineItemsBody": "Esta fatura não tem cobranças detalhadas.",
+ "invoiceDetail.col.description": "Descrição",
+ "invoiceDetail.col.qty": "Qtd",
+ "invoiceDetail.col.unitPrice": "Preço unitário",
+ "invoiceDetail.col.amount": "Valor",
+ "invoiceDetail.total": "Total",
+ "invoiceDetail.row.status": "Status",
+ "invoiceDetail.row.currency": "Moeda",
+ "invoiceDetail.row.period": "Período",
+ "invoiceDetail.row.created": "Criada",
+ "invoiceDetail.row.issued": "Emitida",
+ "invoiceDetail.row.due": "Vencimento",
+ "invoiceDetail.row.paid": "Paga",
+ "invoiceDetail.row.voided": "Anulada",
+ "invoiceDetail.row.periodStart": "Início do período",
+ "invoiceDetail.row.periodEnd": "Fim do período",
+ "invoiceDetail.notFoundTitle": "Fatura não encontrada",
+ "invoiceDetail.notFoundBody": "Ela pode não pertencer à sua organização, ou o link pode estar errado.",
+ "invoiceDetail.notFoundBack": "Voltar para faturas"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/system.json b/clients/dashboard/src/locales/pt-BR/system.json
new file mode 100644
index 0000000000..3d78b77ba7
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/system.json
@@ -0,0 +1,80 @@
+{
+ "sessions.title": "Sessões ativas",
+ "sessions.description": "Todos os navegadores e dispositivos conectados a {{tenant}} agora. Atualiza a cada 30 segundos.",
+ "sessions.thisTenant": "esta organização",
+ "sessions.refresh": "Atualizar",
+ "sessions.searchPlaceholder": "Buscar por usuário, e-mail ou IP…",
+ "sessions.filter.visibility": "Visibilidade",
+ "sessions.filter.liveOnly": "Somente ativas",
+ "sessions.filter.includeInactive": "Incluir inativas",
+ "sessions.stats.active": "{{n}} ativas",
+ "sessions.stats.users": "{{n}} usuários",
+ "sessions.stats.mobile": "{{n}} celular",
+ "sessions.empty.foundTitle": "Nenhuma sessão encontrada",
+ "sessions.empty.activeTitle": "Nenhuma sessão ativa",
+ "sessions.empty.searchBody": "Nada corresponde a \"{{term}}\". Tente outro termo ou ative Incluir inativas para ver sessões expiradas.",
+ "sessions.empty.body": "As sessões aparecem quando os usuários entram. Ative Incluir inativas para ver sessões expiradas e revogadas.",
+ "sessions.empty.clearSearch": "Limpar busca",
+ "sessions.empty.refresh": "Atualizar",
+ "sessions.found_one": "{{count}} sessão encontrada",
+ "sessions.found_other": "{{count}} sessões encontradas",
+ "sessions.col.user": "Usuário",
+ "sessions.col.device": "Dispositivo",
+ "sessions.col.ip": "IP",
+ "sessions.col.lastActivity": "Última atividade",
+ "sessions.col.actions": "Ações",
+ "sessions.badge.you": "Você",
+ "sessions.badge.inactive": "Inativa",
+ "sessions.unknownUser": "Usuário desconhecido",
+ "sessions.unknownBrowser": "Navegador desconhecido",
+ "sessions.allDevices": "Todos os dispositivos",
+ "sessions.all": "Todos",
+ "sessions.revoke": "Revogar",
+ "sessions.revoking": "Revogando…",
+ "sessions.revokeAllTitle": "Desconectar este usuário de todos os dispositivos",
+ "sessions.toast.revoked": "Sessão revogada",
+ "sessions.toast.revokeError": "Não foi possível revogar a sessão.",
+ "sessions.toast.revokedAll_one": "{{count}} sessão revogada",
+ "sessions.toast.revokedAll_other": "{{count}} sessões revogadas",
+ "sessions.toast.revokeAllError": "Não foi possível revogar as sessões.",
+ "trash.title": "Lixeira",
+ "trash.description": "Registros excluídos, mantidos indefinidamente até você restaurá-los. Restaurar um registro o traz de volta à lista original com o mesmo ID e histórico intactos.",
+ "trash.noAccessTitle": "Nenhuma lixeira disponível",
+ "trash.noAccessBody": "Você não tem permissão para restaurar registros excluídos nesta organização. Fale com um administrador se acha que deveria ter.",
+ "trash.sections": "Seções da lixeira",
+ "trash.tab.products": "Produtos",
+ "trash.tab.brands": "Marcas",
+ "trash.tab.categories": "Categorias",
+ "trash.tab.tickets": "Chamados",
+ "trash.tab.files": "Arquivos",
+ "trash.count": "{{n}} {{entity}} na lixeira",
+ "trash.emptyTitle": "A lixeira de {{entity}} está vazia",
+ "trash.emptyBody": "{{entity}} excluídos ficam aqui pelo tempo que você quiser, sem limpeza automática. Tudo que você remover da lista principal pode ser recuperado.",
+ "trash.backTo": "Voltar para {{entity}}",
+ "trash.col.entity": "Entidade",
+ "trash.col.deletedBy": "Excluído por",
+ "trash.col.deletedAt": "Excluído em",
+ "trash.col.actions": "Ações",
+ "trash.action.restore": "Restaurar",
+ "trash.action.restoring": "Restaurando…",
+ "trash.by": "por {{id}}…",
+ "trash.restore.title": "Restaurar {{entity}}?",
+ "trash.restore.body": "voltará para a lista de {{entity}} com o mesmo ID e histórico intactos.",
+ "trash.restore.cancel": "Cancelar",
+ "trash.restore.confirm": "Restaurar {{entity}}",
+ "trash.entityPlural.products": "produtos",
+ "trash.entityPlural.brands": "marcas",
+ "trash.entityPlural.categories": "categorias",
+ "trash.entityPlural.tickets": "chamados",
+ "trash.entityPlural.files": "arquivos",
+ "trash.entitySingular.products": "produto",
+ "trash.entitySingular.brands": "marca",
+ "trash.entitySingular.categories": "categoria",
+ "trash.entitySingular.tickets": "chamado",
+ "trash.entitySingular.files": "arquivo",
+ "trash.toast.products": "Produto restaurado",
+ "trash.toast.brands": "Marca restaurada",
+ "trash.toast.categories": "Categoria restaurada",
+ "trash.toast.tickets": "Chamado restaurado",
+ "trash.toast.files": "Arquivo restaurado"
+}
diff --git a/clients/dashboard/src/locales/pt-BR/tickets.json b/clients/dashboard/src/locales/pt-BR/tickets.json
new file mode 100644
index 0000000000..a2bdd11b63
--- /dev/null
+++ b/clients/dashboard/src/locales/pt-BR/tickets.json
@@ -0,0 +1,119 @@
+{
+ "status.Open": "Aberto",
+ "status.InProgress": "Em andamento",
+ "status.Resolved": "Resolvido",
+ "status.Closed": "Fechado",
+ "priority.Low": "Baixa",
+ "priority.Medium": "Média",
+ "priority.High": "Alta",
+ "priority.Critical": "Crítica",
+ "list.title": "Chamados",
+ "list.unit": "chamado",
+ "list.description": "Trabalho em aberto, ordenado por prioridade. A central onde os problemas chegam, ganham dono e são entregues.",
+ "list.new": "Novo chamado",
+ "list.searchPlaceholder": "Busque por número, título ou descrição…",
+ "list.count_one": "{{count}} chamado encontrado",
+ "list.count_other": "{{count}} chamados encontrados",
+ "filter.status": "Filtro de status",
+ "filter.priority": "Filtro de prioridade",
+ "filter.all": "Todos",
+ "filter.any": "Qualquer",
+ "col.subject": "Assunto",
+ "col.priority": "Prioridade",
+ "col.status": "Status",
+ "col.assignee": "Responsável",
+ "col.updated": "Atualizado",
+ "empty.searchTitle": "Nenhum chamado encontrado",
+ "empty.title": "Nenhum chamado ainda",
+ "empty.searchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe os filtros.",
+ "empty.searchBody": "Nenhum chamado corresponde aos filtros atuais.",
+ "empty.body": "Abra o primeiro chamado para começar a acompanhar o trabalho. Chamados têm status, prioridade, um responsável opcional e um fluxo de comentários.",
+ "empty.clearFilters": "Limpar filtros",
+ "empty.openTicket": "Abrir chamado",
+ "aria.openTicket": "Abrir chamado {{title}}",
+ "row.unassigned": "Sem responsável",
+ "create.toastOpened": "Chamado aberto",
+ "create.title": "Abrir um chamado",
+ "create.descPrefix": "Chamados chegam à central como ",
+ "create.descSuffix": ". Atribua um para iniciá-lo; resolva-o com uma nota quando o trabalho estiver concluído.",
+ "create.fieldTitle": "Título",
+ "create.fieldDescription": "Descrição",
+ "create.fieldPriority": "Prioridade",
+ "create.placeholderTitle": "Descreva brevemente o problema ou a solicitação",
+ "create.placeholderDescription": "Passos para reproduzir, comportamento esperado, qualquer outra coisa útil",
+ "create.cancel": "Cancelar",
+ "create.opening": "Abrindo…",
+ "create.submit": "Abrir chamado",
+ "detail.back": "Voltar aos chamados",
+ "detail.hero.openedBy": "aberto {{rel}} por {{name}}",
+ "detail.hero.refresh": "Atualizar",
+ "detail.hero.reassign": "Reatribuir",
+ "detail.hero.assign": "Atribuir",
+ "detail.hero.resolve": "Resolver",
+ "detail.hero.reopen": "Reabrir",
+ "detail.hero.toastReopened": "Chamado reaberto",
+ "detail.stat.comment_one": "comentário",
+ "detail.stat.comment_other": "comentários",
+ "detail.stat.updated": "atualizado",
+ "detail.stat.resolved": "resolvido",
+ "detail.meta.assignee": "Responsável:",
+ "detail.meta.reporter": "Solicitante:",
+ "detail.meta.created": "Criado:",
+ "detail.unassigned": "Sem responsável",
+ "detail.description.title": "Descrição",
+ "detail.description.empty": "Nenhuma descrição registrada. Os comentários abaixo conduzem a conversa.",
+ "detail.description.resolution": "Resolução",
+ "detail.comments.title": "Conversa",
+ "detail.comments.none": "Nenhum comentário ainda",
+ "detail.comments.count_one": "{{count}} comentário",
+ "detail.comments.count_other": "{{count}} comentários",
+ "detail.comments.toastPosted": "Comentário publicado",
+ "detail.comments.empty": "Nenhum comentário ainda. Adicione a primeira nota abaixo — visível para qualquer pessoa com acesso de Visualização.",
+ "detail.comments.ariaAdd": "Adicionar um comentário",
+ "detail.comments.placeholderDisabled": "Reabra o chamado para adicionar um novo comentário.",
+ "detail.comments.placeholder": "Adicione um comentário ao fluxo…",
+ "detail.comments.charCount": "{{count}} / 8192",
+ "detail.comments.posting": "Publicando…",
+ "detail.comments.post": "Publicar comentário",
+ "detail.comments.self": "Você",
+ "detail.properties.title": "Propriedades",
+ "detail.properties.reporter": "Solicitante",
+ "detail.properties.assignee": "Responsável",
+ "detail.properties.status": "Status",
+ "detail.properties.priority": "Prioridade",
+ "detail.properties.created": "Criado",
+ "detail.properties.updated": "Atualizado",
+ "detail.properties.resolved": "Resolvido",
+ "detail.resolve.toast": "Chamado resolvido",
+ "detail.resolve.desc": "As notas de resolução ficam no chamado e são exibidas no painel de descrição. Deixe em branco se não houver nada a acrescentar.",
+ "detail.resolve.fieldNote": "Nota de resolução",
+ "detail.resolve.hintNote": "Opcional — deixe em branco se não houver nada a acrescentar.",
+ "detail.resolve.placeholder": "O que mudou? Causa raiz? Algo que os revisores devam saber.",
+ "detail.resolve.cancel": "Cancelar",
+ "detail.resolve.resolving": "Resolvendo…",
+ "detail.resolve.submit": "Marcar como resolvido",
+ "detail.assign.toastAssigned": "Chamado atribuído",
+ "detail.assign.toastUnassigned": "Chamado desatribuído",
+ "detail.assign.descPrefix": "Cole um ID de usuário. Assumir o chamado o transiciona para ",
+ "detail.assign.descMid": "; limpar o responsável de um chamado em andamento o envia de volta para ",
+ "detail.assign.descSuffix": ".",
+ "detail.assign.fieldAssignee": "Responsável",
+ "detail.assign.hintAssignee": "Busque por nome ou e-mail. Limpe a seleção para desatribuir.",
+ "detail.assign.warnPrefix": "Limpar o responsável vai ",
+ "detail.assign.warnEmphasis": "desatribuir",
+ "detail.assign.warnSuffix": " o chamado.",
+ "detail.assign.cancel": "Cancelar",
+ "detail.assign.saving": "Salvando…",
+ "detail.assign.submit": "Salvar atribuição",
+ "detail.notFound.title": "Este chamado não existe mais.",
+ "detail.notFound.body": "Pode ter sido excluído. Verifique a lixeira ou volte à central de chamados.",
+ "detail.notFound.back": "Voltar aos chamados",
+ "detail.notFound.desk": "Central de chamados",
+ "userPicker.searchPlaceholder": "Busque por nome ou e-mail…",
+ "userPicker.searchReassign": "Busque para reatribuir…",
+ "userPicker.clear": "Limpar",
+ "userPicker.clearSelection": "Limpar seleção",
+ "userPicker.searching": "Buscando por \"{{term}}\"…",
+ "userPicker.noMatch": "Nenhum usuário corresponde a \"{{term}}\".",
+ "userPicker.results": "Resultados da busca"
+}
diff --git a/clients/dashboard/src/main.tsx b/clients/dashboard/src/main.tsx
index a9db7cf51e..0c6975f570 100644
--- a/clients/dashboard/src/main.tsx
+++ b/clients/dashboard/src/main.tsx
@@ -2,13 +2,18 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "@/App";
import { installImpersonationFromHash } from "@/auth/impersonation-handoff";
-import { loadRuntimeConfig } from "@/env";
+import { env, loadRuntimeConfig } from "@/env";
+import { initI18n } from "@/i18n";
import "@/styles/globals.css";
// Runtime config must resolve before React mounts so env.apiBase reads
// inside components see the right value on first paint.
await loadRuntimeConfig();
+// i18n boots AFTER config so fallbackLng can read the per-deployment default;
+// the persisted/detected locale still wins over it.
+await initI18n(env.defaultLanguage);
+
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element '#root' not found");
diff --git a/clients/dashboard/src/pages/activity.tsx b/clients/dashboard/src/pages/activity.tsx
index 067d2a30a2..bfdccea4a2 100644
--- a/clients/dashboard/src/pages/activity.tsx
+++ b/clients/dashboard/src/pages/activity.tsx
@@ -1,6 +1,9 @@
import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { Activity, Inbox } from "lucide-react";
+import i18n from "@/i18n";
import { useSseEvents, useSseStatus, type SseEvent } from "@/sse/sse-context";
+import { formatNumber } from "@/lib/list-helpers";
import { Badge } from "@/components/ui/badge";
import {
EntityEmpty,
@@ -12,15 +15,14 @@ import {
type EntityStatusTone,
} from "@/components/list";
-const timeFmt = new Intl.DateTimeFormat("en-US", {
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- hour12: false,
-});
-
function formatTime(ts: number) {
- return timeFmt.format(new Date(ts));
+ // 24h hh:mm:ss in the active locale — no list-helper covers second precision.
+ return new Intl.DateTimeFormat(i18n.language, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ }).format(new Date(ts));
}
function payloadSummary(data: unknown, raw: string): string {
@@ -67,6 +69,7 @@ function entityLabel(data: unknown): string {
const DESKTOP_GRID = "grid-cols-[1fr_240px_120px]";
export function ActivityPage() {
+ const { t } = useTranslation("activity");
const { status, eventCount } = useSseStatus();
const { events } = useSseEvents();
@@ -77,37 +80,37 @@ export function ActivityPage() {
{isLive ? (
- streaming
+ {t("status.streaming")}
) : status === "error" ? (
- offline
+ {t("status.offline")}
) : (
- {status}
+ {t(`status.${status}`)}
)}
{items.length === 0 ? (
) : (
- {items.length} event{items.length === 1 ? "" : "s"} shown
+ {t("shown", { count: items.length })}
- · {new Intl.NumberFormat("en-US").format(eventCount)} total
+ · {t("totalCount", { total: formatNumber(eventCount) })}
@@ -118,7 +121,7 @@ export function ActivityPage() {
role="log"
aria-live="polite"
aria-relevant="additions"
- aria-label="Activity events"
+ aria-label={t("ariaLabel")}
>
{items.map((ev) => (
@@ -131,12 +134,12 @@ export function ActivityPage() {
role="log"
aria-live="polite"
aria-relevant="additions"
- aria-label="Activity events"
+ aria-label={t("ariaLabel")}
>
- Action
- Entity
- Time
+ {t("col.action")}
+ {t("col.entity")}
+ {t("col.time")}
{items.map((ev, i) => (
= {
+ None: "eventType.none",
+ EntityChange: "eventType.entity",
+ Security: "eventType.security",
+ Activity: "eventType.activity",
+ Exception: "eventType.exception",
+};
+const SEVERITY_KEY: Record = {
+ None: "severity.none",
+ Trace: "severity.trace",
+ Debug: "severity.debug",
+ Information: "severity.info",
+ Warning: "severity.warn",
+ Error: "severity.error",
+ Critical: "severity.critical",
+};
+const TAG_KEY_BY_NAME: Record = {
+ "PII masked": "tag.piiMasked",
+ "Quota exceeded": "tag.quotaExceeded",
+ Sampled: "tag.sampled",
+ Retained: "tag.retained",
+ "Health check": "tag.healthCheck",
+ Auth: "tag.auth",
+ Authz: "tag.authz",
+};
+
// ────────────────────────────────────────────────────────────────────────
// Time-range presets — keep the SQL window predictable from the UI.
// ────────────────────────────────────────────────────────────────────────
@@ -137,14 +164,14 @@ function fmtIsoDense(iso: string): { date: string; time: string } {
function fmtRelative(iso: string, now: number = Date.now()): string {
const delta = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
- if (delta < 5) return "just now";
- if (delta < 60) return `${delta}s ago`;
+ if (delta < 5) return i18n.t("relative.justNow");
+ if (delta < 60) return i18n.t("relative.secondsAgo", { n: delta });
const m = Math.floor(delta / 60);
- if (m < 60) return `${m}m ago`;
+ if (m < 60) return i18n.t("relative.minutesAgo", { n: m });
const h = Math.floor(m / 60);
- if (h < 24) return `${h}h ago`;
+ if (h < 24) return i18n.t("relative.hoursAgo", { n: h });
const days = Math.floor(h / 24);
- return `${days}d ago`;
+ return i18n.t("relative.daysAgo", { n: days });
}
// ────────────────────────────────────────────────────────────────────────
@@ -185,6 +212,7 @@ const INITIAL_FILTERS: FilterState = {
// ────────────────────────────────────────────────────────────────────────
export function AuditsPage() {
+ const { t } = useTranslation("audits");
const [filters, setFilters] = useState(INITIAL_FILTERS);
const [searchInput, setSearchInput] = useState("");
const [drawerId, setDrawerId] = useState(null);
@@ -281,10 +309,10 @@ export function AuditsPage() {
- Refresh
+ {t("page.refresh")}
@@ -306,7 +334,7 @@ export function AuditsPage() {
{/* Filter bar — preserved verbatim (range presets, chips, advanced) */}
@@ -338,17 +366,17 @@ export function AuditsPage() {
className="flex items-start gap-2 rounded-lg border border-[oklch(from_var(--color-destructive)_l_c_h_/_0.30)] bg-[oklch(from_var(--color-destructive)_l_c_h_/_0.06)] px-3 py-2 text-sm text-[var(--color-destructive)]"
>
-
{(auditsQuery.error as Error)?.message ?? "Failed to load audits"}
+
{(auditsQuery.error as Error)?.message ?? t("page.loadError")}
) : items.length === 0 ? (
0 || filters.search ? (
- Reset filters
+ {t("empty.reset")}
) : undefined
}
@@ -357,7 +385,7 @@ export function AuditsPage() {
- {paged?.totalCount ?? 0} event{(paged?.totalCount ?? 0) !== 1 ? "s" : ""} found
+ {t("page.count", { count: paged?.totalCount ?? 0 })}
{auditsQuery.isFetching && (
@@ -378,10 +406,10 @@ export function AuditsPage() {
{/* Desktop list */}
- Actor
- Event
- Severity
- Timestamp
+ {t("col.actor")}
+ {t("col.event")}
+ {t("col.severity")}
+ {t("col.timestamp")}
{items.map((row, i) => (
void;
}) {
+ const { t } = useTranslation("audits");
const ts = fmtIsoDense(row.occurredAtUtc);
const Icon = eventTypeIcon(row.eventType);
const tone = severityTone(row.severity);
const toneColor = severityColorVar(row.severity);
- const actor = row.userName ?? (row.userId ? `${row.userId.slice(0, 8)}…` : "System");
+ const actor = row.userName ?? (row.userId ? `${row.userId.slice(0, 8)}…` : t("actor.system"));
return (
- {AUDIT_SEVERITY_LABELS[row.severity]}
+ {t(SEVERITY_KEY[row.severity])}
- {AUDIT_EVENT_TYPE_LABELS[row.eventType]}
+ {t(EVENT_TYPE_KEY[row.eventType])}
@@ -499,11 +528,12 @@ function AuditDesktopRow({
isLast: boolean;
onOpen: () => void;
}) {
+ const { t } = useTranslation("audits");
const ts = fmtIsoDense(row.occurredAtUtc);
const Icon = eventTypeIcon(row.eventType);
const tone = severityTone(row.severity);
const toneColor = severityColorVar(row.severity);
- const actor = row.userName ?? (row.userId ? `${row.userId.slice(0, 8)}…` : "System");
+ const actor = row.userName ?? (row.userId ? `${row.userId.slice(0, 8)}…` : t("actor.system"));
const tags = decodeTags(row.tags);
return (
@@ -541,7 +571,7 @@ function AuditDesktopRow({
className="inline-flex shrink-0 items-center gap-0.5 rounded-full bg-[var(--color-muted)] px-1.5 py-0 font-mono text-[10px] text-[var(--color-muted-foreground)]"
>
- {name}
+ {t(TAG_KEY_BY_NAME[name])}
))}
{tags.length > 2 && (
@@ -558,7 +588,7 @@ function AuditDesktopRow({
- {AUDIT_SEVERITY_LABELS[row.severity]}
+ {t(SEVERITY_KEY[row.severity])}
@@ -595,6 +625,7 @@ function SummaryStrip({
topSources: Array<[string, number]>;
range: RangeKey;
}) {
+ const { t } = useTranslation("audits");
if (loading || !byType || !bySeverity) {
return (
@@ -609,7 +640,7 @@ function SummaryStrip({
// Stack-bar segments — fall back to a single muted segment when the
// window has zero activity, so the strip still has visual mass.
- const t = Math.max(1, total);
+ const denom = Math.max(1, total);
const segments = [
{ key: "activity", value: byType.activity, color: "var(--color-primary)" },
{ key: "entity", value: byType.entity, color: "var(--color-info)" },
@@ -622,12 +653,12 @@ function SummaryStrip({
- Window {range}
+ {t("summary.window", { range })}
- {new Intl.NumberFormat("en-US").format(total)}
+ {new Intl.NumberFormat(i18n.language).format(total)}
-
events
+
{t("summary.events")}
@@ -637,7 +668,7 @@ function SummaryStrip({
key={s.key}
title={`${s.key}: ${s.value}`}
style={{
- width: `${(s.value / t) * 100}%`,
+ width: `${(s.value / denom) * 100}%`,
backgroundColor: s.color,
transition: "width 600ms var(--ease-out-cubic)",
}}
@@ -645,19 +676,19 @@ function SummaryStrip({
))}
-
-
-
-
+
+
+
+
-
-
+
+
- Top sources
+ {t("summary.topSources")}
{topSources.length === 0 && (
@@ -719,6 +750,7 @@ function FilterBar({
onSearchInput: (v: string) => void;
onReset: () => void;
}) {
+ const { t } = useTranslation("audits");
const [advancedOpen, setAdvancedOpen] = useState(false);
return (
@@ -749,14 +781,14 @@ function FilterBar({
onSearchInput(e.target.value)}
- placeholder="Search payload, source, user…"
+ placeholder={t("page.searchPlaceholder")}
className="pl-9"
/>
{searchInput && (
onSearchInput("")}
- aria-label="Clear search"
+ aria-label={t("filter.clearSearch")}
className="absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)] hover:text-[var(--color-foreground)]"
>
@@ -771,7 +803,7 @@ function FilterBar({
className="gap-1.5"
>
- Advanced
+ {t("filter.advanced")}
{activeChipCount > 0 && (
{activeChipCount}
@@ -783,10 +815,10 @@ function FilterBar({
default so the trail reads as meaningful events, not infrastructure noise. */}
onPatch({ hideSystem: v })}
/>
@@ -795,7 +827,7 @@ function FilterBar({
onClick={() => onPatch({ hideSystem: !filters.hideSystem })}
className="select-none text-[12px] font-medium text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-foreground)]"
>
- Hide system activity
+ {t("filter.hideSystem")}
@@ -805,7 +837,7 @@ function FilterBar({
onClick={onReset}
className="font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]"
>
- Reset
+ {t("filter.reset")}
)}
@@ -813,24 +845,24 @@ function FilterBar({
{/* Row 2: event type + severity chips */}
- Type
+ {t("filter.type")}
- {[AuditEventType.Activity, AuditEventType.Security, AuditEventType.EntityChange, AuditEventType.Exception].map((t) => (
+ {[AuditEventType.Activity, AuditEventType.Security, AuditEventType.EntityChange, AuditEventType.Exception].map((et) => (
- onPatch({ eventType: filters.eventType === t ? null : t })
+ onPatch({ eventType: filters.eventType === et ? null : et })
}
>
- {AUDIT_EVENT_TYPE_LABELS[t]}
+ {t(EVENT_TYPE_KEY[et])}
))}
- Severity
+ {t("filter.severity")}
{[AuditSeverity.Information, AuditSeverity.Warning, AuditSeverity.Error, AuditSeverity.Critical].map((s) => (
- {AUDIT_SEVERITY_LABELS[s]}
+ {t(SEVERITY_KEY[s])}
))}
@@ -850,49 +882,49 @@ function FilterBar({
{advancedOpen && (
onPatch({ source: v })}
/>
onPatch({ user: v })}
/>
onPatch({ correlation: v })}
/>
onPatch({ trace: v })}
/>
- Tags
+ {t("filter.tags")}
- {AUDIT_TAG_LABELS.map((t) => {
- const active = (filters.tagsMask & t.flag) !== 0;
+ {AUDIT_TAG_LABELS.map((tag) => {
+ const active = (filters.tagsMask & tag.flag) !== 0;
return (
onPatch({
tagsMask: active
- ? filters.tagsMask & ~t.flag
- : filters.tagsMask | t.flag,
+ ? filters.tagsMask & ~tag.flag
+ : filters.tagsMask | tag.flag,
})
}
>
- {t.name}
+ {t(TAG_KEY_BY_NAME[tag.name])}
);
})}
@@ -988,6 +1020,7 @@ function AuditDetailDrawer({
onJumpCorrelation: (id: string) => void;
onJumpTrace: (id: string) => void;
}) {
+ const { t } = useTranslation("audits");
const open = auditId !== null;
const detail = useQuery({
@@ -1012,9 +1045,9 @@ function AuditDetailDrawer({
"duration-[var(--duration-default)]",
)}
>
-
Audit detail
+
{t("drawer.srTitle")}
- Full payload, identifiers, and related actions for the selected audit event.
+ {t("drawer.srDescription")}
@@ -1037,7 +1070,7 @@ function AuditDetailDrawer({
@@ -1093,31 +1127,31 @@ function DrawerHeader({ detail, loading }: { detail?: AuditDetailDto; loading: b
- {AUDIT_SEVERITY_LABELS[detail.severity]}
+ {t(SEVERITY_KEY[detail.severity])}
- {AUDIT_EVENT_TYPE_LABELS[detail.eventType]}
+ {t(EVENT_TYPE_KEY[detail.eventType])}
- {detail.source ?? "Audit event"}
+ {detail.source ?? t("drawer.eventFallback")}
- {ts.date} {ts.time} UTC
+ {ts.date} {ts.time} {t("drawer.utc")}
·
{fmtRelative(detail.occurredAtUtc)}
{tags.length > 0 && (
- {tags.map((t) => (
+ {tags.map((tag) => (
- {t}
+ {t(TAG_KEY_BY_NAME[tag])}
))}
@@ -1138,27 +1172,28 @@ function DrawerBody({
onJumpCorrelation: (id: string) => void;
onJumpTrace: (id: string) => void;
}) {
+ const { t } = useTranslation("audits");
return (
{/* Identity grid */}
- Identity
+ {t("drawer.section.identity")}
-
-
-
-
+
+
+
+
{/* Trace grid + jump links */}
- Trace
+ {t("drawer.section.trace")}
-
-
-
-
+
+
+
+
{detail.correlationId && (
@@ -1167,12 +1202,12 @@ function DrawerBody({
size="sm"
onClick={() => onJumpCorrelation(detail.correlationId!)}
>
- All by correlation
+ {t("drawer.allByCorrelation")}
)}
{detail.traceId && (
onJumpTrace(detail.traceId!)}>
- All by trace
+ {t("drawer.allByTrace")}
)}
@@ -1193,7 +1228,7 @@ function DrawerBody({
{/* Payload */}
- Payload
+ {t("drawer.section.payload")}
@@ -1203,24 +1238,24 @@ function DrawerBody({
{/* Reception window */}
- Pipeline
+ {t("drawer.section.pipeline")}
-
+
@@ -1246,12 +1281,13 @@ function DrawerSkeleton() {
}
function DrawerError({ message }: { message?: string }) {
+ const { t } = useTranslation("audits");
return (
-
Could not load audit
+
{t("drawer.error.title")}
- {message ?? "The server returned an error fetching this audit. The record may have been purged by the retention job."}
+ {message ?? t("drawer.error.body")}
);
@@ -1287,6 +1323,7 @@ function RelatedEventsSection({
currentOccurredAtUtc: string;
onJumpAudit: (id: string) => void;
}) {
+ const { t } = useTranslation("audits");
const related = useQuery({
queryKey: ["audit", "by-correlation", correlationId],
queryFn: ({ signal }) => getAuditsByCorrelation(correlationId, {}, signal),
@@ -1309,10 +1346,10 @@ function RelatedEventsSection({
return (
- Related events
+ {t("drawer.section.related")}
{!related.isLoading && (
- {sorted.length} on this correlation
+ {t("drawer.related.count", { count: sorted.length })}
)}
@@ -1325,8 +1362,7 @@ function RelatedEventsSection({
) : others.length === 0 ? (
- No other events share this correlation. The full lifecycle of this
- request is contained in the payload above.
+ {t("drawer.related.empty")}
) : (
@@ -1343,7 +1379,7 @@ function RelatedEventsSection({
const deltaSec = Math.round((Date.parse(row.occurredAtUtc) - currentMs) / 1000);
const deltaLabel =
isCurrent
- ? "this event"
+ ? t("drawer.related.thisEvent")
: deltaSec === 0
? "0s"
: deltaSec > 0
@@ -1375,10 +1411,10 @@ function RelatedEventsSection({
- {row.source ?? AUDIT_EVENT_TYPE_LABELS[row.eventType]}
+ {row.source ?? t(EVENT_TYPE_KEY[row.eventType])}
- {AUDIT_SEVERITY_LABELS[row.severity]}
+ {t(SEVERITY_KEY[row.severity])}
@@ -1399,6 +1435,7 @@ function RelatedEventsSection({
}
function CopyButton({ value }: { value: string }) {
+ const { t } = useTranslation("audits");
const [copied, setCopied] = useState(false);
return (
- {copied ? "Copied" : "Copy"}
+ {copied ? t("drawer.copied") : t("drawer.copy")}
);
}
diff --git a/clients/dashboard/src/pages/auth/confirm-email.tsx b/clients/dashboard/src/pages/auth/confirm-email.tsx
index e2b5b0a616..48ddba6ae1 100644
--- a/clients/dashboard/src/pages/auth/confirm-email.tsx
+++ b/clients/dashboard/src/pages/auth/confirm-email.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { AlertCircle, ArrowRight, CheckCircle2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AuthHeadline, AuthShell } from "@/components/auth/auth-shell";
@@ -24,6 +25,7 @@ type Status =
| { kind: "error"; message: string };
export function ConfirmEmailPage() {
+ const { t } = useTranslation("auth");
const [params] = useSearchParams();
const userId = params.get("userId") ?? "";
const code = params.get("code") ?? "";
@@ -40,8 +42,7 @@ export function ConfirmEmailPage() {
if (malformed) {
setStatus({
kind: "error",
- message:
- "This confirmation link is missing required parameters. It may have been clipped by your email client.",
+ message: t("confirm.malformed"),
});
return;
}
@@ -55,7 +56,7 @@ export function ConfirmEmailPage() {
message:
typeof message === "string" && message.length > 0
? message
- : "Your email is confirmed. You can now sign in.",
+ : t("confirm.successFallback"),
});
})
.catch((err: unknown) => {
@@ -70,7 +71,7 @@ export function ConfirmEmailPage() {
return () => {
cancelled = true;
};
- }, [userId, code, tenant, malformed]);
+ }, [userId, code, tenant, malformed, t]);
return (
- ← Back to sign in
+ {t("confirm.backLink")}
}
>
@@ -94,9 +95,9 @@ export function ConfirmEmailPage() {
-
+
- One moment — checking the confirmation token with the server.
+ {t("confirm.verifyingBody")}
@@ -113,14 +114,14 @@ export function ConfirmEmailPage() {
- Continue to sign in
+ {t("confirm.continueSignIn")}
@@ -138,24 +139,27 @@ export function ConfirmEmailPage() {
-
+
{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")}
diff --git a/clients/dashboard/src/pages/auth/forgot-password.tsx b/clients/dashboard/src/pages/auth/forgot-password.tsx
index 1894adfed7..e5374b6c65 100644
--- a/clients/dashboard/src/pages/auth/forgot-password.tsx
+++ b/clients/dashboard/src/pages/auth/forgot-password.tsx
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from "react";
import { Link, Navigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import {
AlertCircle,
@@ -30,6 +31,7 @@ import { env } from "@/env";
* inbox" success state after a 2xx.
*/
export function ForgotPasswordPage() {
+ const { t } = useTranslation("auth");
const { isAuthenticated } = useAuth();
const [email, setEmail] = useState("");
const [tenant, setTenant] = useState(env.defaultTenant);
@@ -65,12 +67,12 @@ export function ForgotPasswordPage() {
- Remembered it?{" "}
+ {t("forgot.remembered")}{" "}
- Sign in
+ {t("forgot.signIn")}
}
@@ -86,23 +88,23 @@ export function ForgotPasswordPage() {
-
+
- 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 try again.
+ {t("forgot.tip2")}
@@ -114,11 +116,11 @@ export function ForgotPasswordPage() {
setError(null);
}}
>
- Try a different address
+ {t("forgot.tryDifferent")}
- Back to sign in
+ {t("forgot.backToSignIn")}
@@ -126,9 +128,9 @@ export function ForgotPasswordPage() {
) : (
<>
-
+
- Enter the email you sign in with. We'll send a one-time link.
+ {t("forgot.subtitle")}
@@ -138,7 +140,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")}
@@ -146,7 +148,7 @@ export function ForgotPasswordPage() {
id="reset-tenant"
value={tenant}
onChange={(e) => setTenant(e.target.value)}
- placeholder="root"
+ placeholder={t("forgot.tenantPlaceholder")}
autoComplete="organization"
required
aria-invalid={error ? true : undefined}
@@ -161,7 +163,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")}
@@ -170,7 +172,7 @@ export function ForgotPasswordPage() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
- placeholder="you@example.com"
+ placeholder={t("forgot.emailPlaceholder")}
autoComplete="email"
required
autoFocus
@@ -206,11 +208,11 @@ export function ForgotPasswordPage() {
{mutation.isPending ? (
<>
-
Sending link…
+
{t("forgot.submitting")}
>
) : (
<>
-
Send reset link
+
{t("forgot.submit")}
>
)}
diff --git a/clients/dashboard/src/pages/auth/reset-password.tsx b/clients/dashboard/src/pages/auth/reset-password.tsx
index 153f263fc9..93aaa4156e 100644
--- a/clients/dashboard/src/pages/auth/reset-password.tsx
+++ b/clients/dashboard/src/pages/auth/reset-password.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import {
AlertCircle,
@@ -50,25 +51,23 @@ function scorePassword(value: string): Strength | null {
return "strong";
}
-const STRENGTH_META: Record
= {
+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",
},
};
export function ResetPasswordPage() {
+ const { t } = useTranslation("auth");
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
@@ -92,8 +91,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 });
},
@@ -121,11 +120,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();
@@ -135,12 +134,12 @@ export function ResetPasswordPage() {
- Changed your mind?{" "}
+ {t("reset.changedMind")}{" "}
- Sign in
+ {t("reset.signIn")}
}
@@ -148,25 +147,25 @@ export function ResetPasswordPage() {
{malformed ? (
-
+
- The reset 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.
+ {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")}
@@ -174,11 +173,11 @@ export function ResetPasswordPage() {
) : (
<>
-
+
- Resetting password for{" "}
- {email} on{" "}
- {tenant} .
+ {t("reset.subtitlePre")}{" "}
+ {email} {t("reset.subtitleMid")}{" "}
+ {tenant} {t("reset.subtitlePost")}
@@ -188,7 +187,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")}
setPassword(e.target.value)}
- placeholder="At least 8 characters"
+ placeholder={t("reset.newPasswordPlaceholder")}
required
autoComplete="new-password"
autoFocus
@@ -208,7 +207,7 @@ export function ResetPasswordPage() {
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 ? : }
@@ -227,7 +226,7 @@ export function ResetPasswordPage() {
/>
- {STRENGTH_META[strength].label}
+ {t(`reset.strength_${strength}`)}
)}
@@ -238,7 +237,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")}
setConfirm(e.target.value)}
- placeholder="Re-enter password"
+ placeholder={t("reset.confirmPasswordPlaceholder")}
required
autoComplete="new-password"
minLength={8}
@@ -257,7 +256,7 @@ export function ResetPasswordPage() {
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 ? : }
@@ -279,7 +278,7 @@ export function ResetPasswordPage() {
matches ? "opacity-100" : "opacity-40",
)}
/>
- {matches ? "Passwords match" : "Doesn't match yet"}
+ {matches ? t("reset.matches") : t("reset.notMatchYet")}
)}
@@ -309,12 +308,12 @@ export function ResetPasswordPage() {
{mutation.isPending ? (
<>
-
Updating password…
+
{t("reset.submitting")}
>
) : (
<>
-
Set new password
+
{t("reset.submit")}
>
)}
diff --git a/clients/dashboard/src/pages/catalog/brands.tsx b/clients/dashboard/src/pages/catalog/brands.tsx
index d7b8b9616e..86b77df5aa 100644
--- a/clients/dashboard/src/pages/catalog/brands.tsx
+++ b/clients/dashboard/src/pages/catalog/brands.tsx
@@ -19,6 +19,7 @@ import {
Trash2,
} from "lucide-react";
import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
import {
createBrand,
deleteBrand,
@@ -74,6 +75,7 @@ type EditorState =
// ───────────────────────────────────────────────────────────────────────
export function BrandsPage() {
+ const { t } = useTranslation("catalog");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [pageNumber, setPageNumber] = useState(1);
@@ -113,24 +115,24 @@ export function BrandsPage() {
setEditor({ mode: "create" })}
className="h-9 flex-1 gap-1.5 rounded-lg px-4 text-[13px] font-semibold sm:flex-none"
>
- New brand
+ {t("action.newBrand")}
{query.isLoading && items.length === 0 ? (
@@ -138,13 +140,13 @@ export function BrandsPage() {
) : items.length === 0 ? (
setSearch("")}
className="h-9 rounded-lg px-4 text-[13px]"
>
- Clear search
+ {t("action.clearSearch")}
) : (
- Add brand
+ {t("action.addBrand")}
)
}
@@ -170,8 +172,7 @@ export function BrandsPage() {
- {data?.totalCount ?? 0} brand
- {(data?.totalCount ?? 0) !== 1 ? "s" : ""} found
+ {t("brands.count", { count: data?.totalCount ?? 0 })}
@@ -189,9 +190,9 @@ export function BrandsPage() {
{/* Desktop: list card */}
- Brand
- Slug
- Created
+ {t("col.brand")}
+ {t("col.slug")}
+ {t("col.created")}
@@ -249,6 +250,7 @@ function MobileCard({
brand: BrandDto;
onEdit: () => void;
}) {
+ const { t } = useTranslation("catalog");
return (
@@ -298,6 +300,7 @@ function DesktopRow({
onEdit: () => void;
onDelete: () => void;
}) {
+ const { t } = useTranslation("catalog");
return (
@@ -349,7 +352,7 @@ function DesktopRow({
@@ -413,6 +416,7 @@ function BrandEditorDialog({
state: EditorState;
onClose: () => void;
}) {
+ const { t } = useTranslation("catalog");
const isOpen = state.mode === "create" || state.mode === "edit";
const brand = state.mode === "edit" ? state.brand : undefined;
const queryClient = useQueryClient();
@@ -443,21 +447,21 @@ function BrandEditorDialog({
const createMutation = useMutation({
mutationFn: (input: CreateBrandInput) => createBrand(input),
onSuccess: () => {
- toast.success("Brand created");
+ toast.success(t("toast.brandCreated"));
queryClient.invalidateQueries({ queryKey: ["catalog", "brands"] });
onClose();
},
- onError: (err) => toast.error("Create failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("toast.createFailed"), { description: describe(err) }),
});
const updateMutation = useMutation({
mutationFn: (input: UpdateBrandInput) => updateBrand(input),
onSuccess: () => {
- toast.success("Brand updated");
+ toast.success(t("toast.brandUpdated"));
queryClient.invalidateQueries({ queryKey: ["catalog", "brands"] });
onClose();
},
- onError: (err) => toast.error("Update failed", { description: describe(err) }),
+ onError: (err) => toast.error(t("toast.updateFailed"), { description: describe(err) }),
});
const isPending = createMutation.isPending || updateMutation.isPending;
@@ -483,21 +487,21 @@ function BrandEditorDialog({