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")}
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({ 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 && ( )} {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")}
    {missing.map((p) => (
  • @@ -47,7 +49,7 @@ export function ForbiddenView({ missing }: ForbiddenViewProps) {
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 && ( )}
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({ @@ -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 && ( )} @@ -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")}