Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
d052d05
feat(identity): add nullable User.Locale and emit locale JWT claim
marcelo-maciel Jul 20, 2026
e531835
feat(identity): validate User.Locale against supported cultures
marcelo-maciel Jul 20, 2026
63a9f11
feat(identity): migration adding nullable Locale column to Users
marcelo-maciel Jul 20, 2026
7975c05
feat(core): shared resx catalog and supported-cultures constant for l…
marcelo-maciel Jul 20, 2026
e8948df
feat(web): request localization with user-locale-first culture provider
marcelo-maciel Jul 20, 2026
2b37619
feat(web): localize GlobalExceptionHandler ProblemDetails titles
marcelo-maciel Jul 20, 2026
ef02091
feat(identity): localize UpdateUser validator messages
marcelo-maciel Jul 20, 2026
66629d1
feat(identity): localize remaining validator messages
marcelo-maciel Jul 20, 2026
d3de596
feat(admin): react-i18next bootstrap with en-US/pt-BR common catalog
marcelo-maciel Jul 20, 2026
01b8f5f
feat(admin): send Accept-Language and hydrate UI from persisted User.…
marcelo-maciel Jul 20, 2026
27a6084
feat(dashboard): react-i18next i18n setup, Accept-Language and locale…
marcelo-maciel Jul 20, 2026
246707b
feat(admin): topbar language switcher
marcelo-maciel Jul 20, 2026
ea152e1
feat(dashboard): topbar language switcher
marcelo-maciel Jul 20, 2026
3df8297
feat(admin): locale-aware formatting + localize app shell
marcelo-maciel Jul 20, 2026
91df1de
feat(dashboard): locale-aware formatting + localize app shell
marcelo-maciel Jul 20, 2026
1712801
feat(admin): localize auth, identity and settings screens
marcelo-maciel Jul 20, 2026
8982eca
feat(admin): localize billing, tenants, webhooks, audits and remainin…
marcelo-maciel Jul 20, 2026
25e2f19
feat(dashboard): localize auth screens and shell banners
marcelo-maciel Jul 20, 2026
0714589
feat(dashboard): localize settings, identity, overview and billing sc…
marcelo-maciel Jul 20, 2026
b616942
feat(dashboard): localize catalog screens
marcelo-maciel Jul 20, 2026
f6c8a63
feat(dashboard): localize tickets screens
marcelo-maciel Jul 20, 2026
91cff61
feat(dashboard): localize files screens
marcelo-maciel Jul 20, 2026
49d8543
feat(dashboard): localize audits, state pages, and relative-time form…
marcelo-maciel Jul 20, 2026
2c05f7f
feat(dashboard): localize command palette and notifications
marcelo-maciel Jul 20, 2026
03cd144
feat(dashboard): localize system screens (sessions, trash) and list p…
marcelo-maciel Jul 20, 2026
0498f6b
feat(dashboard): localize identity screens (roles, groups, users, det…
marcelo-maciel Jul 20, 2026
21def81
feat(dashboard): localize wallet and invoice detail screens
marcelo-maciel Jul 20, 2026
0b0d37f
feat(dashboard): localize chat vertical (rail, pane, composer, messag…
marcelo-maciel Jul 20, 2026
e830595
feat(dashboard): localize health page, route error, and shared dialog…
marcelo-maciel Jul 20, 2026
e4cf1da
fix(identity): strip stray UTF-8 BOM from validators (repo convention…
marcelo-maciel Jul 20, 2026
12fb151
fix(i18n): harden locale switch against profile-load races and locali…
marcelo-maciel Jul 20, 2026
59ad57f
feat(test): harden i18n tests (impersonation claims, dashboard regres…
marcelo-maciel Jul 20, 2026
c834b9d
fix(deps): pin System.Security.Cryptography.Xml to 10.0.10
marcelo-maciel Jul 21, 2026
e3f970c
feat(i18n): wire react-i18next across admin and dashboard
marcelo-maciel Jul 21, 2026
1352240
feat(i18n): seed shared resources and localize token endpoint
marcelo-maciel Jul 21, 2026
fad48c3
feat(i18n): localize exception bodies at the boundary via resource keys
marcelo-maciel Jul 21, 2026
6c90603
feat(i18n): localize shared PagedQueryValidator messages
marcelo-maciel Jul 21, 2026
1ae107b
docs(i18n): add localization convention rule
marcelo-maciel Jul 21, 2026
3436ed1
feat(i18n): localize Files module exceptions
marcelo-maciel Jul 21, 2026
db728c7
feat(i18n): seed Error.TenantContextRequired cross-cutting key in Core
marcelo-maciel Jul 21, 2026
f90ce6b
docs(i18n): clarify module-catalog localizer choice for validators
marcelo-maciel Jul 21, 2026
05cf7d0
feat(i18n): localize Webhooks module exceptions and validator
marcelo-maciel Jul 21, 2026
d934694
feat(i18n): localize Catalog module exceptions and validator
marcelo-maciel Jul 21, 2026
4cfdc35
feat(i18n): localize Billing module exceptions
marcelo-maciel Jul 21, 2026
cc9b0e6
feat(i18n): localize Multitenancy module exceptions and validators
marcelo-maciel Jul 21, 2026
3ebbcd4
feat(i18n): localize Auditing module exceptions and validators
marcelo-maciel Jul 21, 2026
e01d749
feat(i18n): localize Notifications module exceptions
marcelo-maciel Jul 21, 2026
0e3a675
feat(i18n): localize Chat module exceptions and validator
marcelo-maciel Jul 21, 2026
2818839
feat(i18n): localize Identity module exceptions
marcelo-maciel Jul 21, 2026
a6096e7
feat(i18n): localize remaining admin form placeholders
marcelo-maciel Jul 21, 2026
707cc82
fix(i18n): localize remaining exception details (AdjustStock, generic…
marcelo-maciel Jul 22, 2026
186a01e
fix(i18n): localize Tickets module, Forbidden, storage quota and visi…
marcelo-maciel Jul 22, 2026
987ec3b
fix(i18n): localize impersonation and user-image validation messages
marcelo-maciel Jul 22, 2026
c82b866
fix(i18n): localize audit-typed exception details via localizable BCL…
marcelo-maciel Jul 22, 2026
08c455b
fix(i18n): localize the handler-reachable dev-facing exception details
marcelo-maciel Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 3 additions & 2 deletions .agents/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
63 changes: 63 additions & 0 deletions .agents/rules/localization.md
Original file line number Diff line number Diff line change
@@ -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 (`<Module>Resources`)** — domain-specific messages owned by the module: `src/Modules/<Module>/Modules.<Module>/Localization/<Module>Resources.cs` (marker `public sealed class <Module>Resources;`) + co-located `<Module>Resources.resx` (en) + `<Module>Resources.pt.resx` (pt). `ResourcesPath = ""` (co-located), so the resx manifest name must equal the marker's full type name.

Key naming: `Error.<Module>.<Case>` for domain messages (`Catalog.ProductNotFound`), `Error.<CrossCutting>` / `Validation.<Case>` 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<XCommand>
{
public XCommandValidator(IStringLocalizer<SharedResources> 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<SharedResources>` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<<Module>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<T>(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 `<Module>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.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
105 changes: 104 additions & 1 deletion clients/admin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions clients/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion clients/admin/public/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"defaultTenant": "root",
"dashboardUrl": "http://localhost:5174",
"inactivityIdleMs": 600000,
"inactivityWarningMs": 60000
"inactivityWarningMs": 60000,
"defaultLanguage": "en-US"
}
4 changes: 3 additions & 1 deletion clients/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -10,6 +11,7 @@ import { ThemeProvider, useTheme } from "@/components/theme/theme-provider";
import { router } from "@/routes";

export function App() {
const { t } = useTranslation("common");
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
Expand All @@ -22,7 +24,7 @@ export function App() {
fallback={
<div
role="status"
aria-label="Loading"
aria-label={t("loading.label")}
className="grid min-h-dvh place-items-center bg-[var(--color-background)]"
/>
}
Expand Down
35 changes: 35 additions & 0 deletions clients/admin/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -76,6 +78,39 @@ export async function setProfileImage(imageUrl: string | null): Promise<void> {
});
}

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<void> {
const profile = await getMyProfile();
await apiFetch<void>(`${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;
Expand Down
4 changes: 3 additions & 1 deletion clients/admin/src/auth/protected-route.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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
Expand All @@ -25,7 +27,7 @@ export function ProtectedRoute({ permissions = [] }: ProtectedRouteProps) {
role="status"
aria-busy="true"
>
<span className="sr-only">Restoring your session…</span>
<span className="sr-only">{t("protectedRoute.restoring")}</span>
<span
className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden
Expand Down
4 changes: 3 additions & 1 deletion clients/admin/src/auth/route-guard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/auth/use-auth";
import { ForbiddenView } from "@/components/forbidden-view";

Expand All @@ -25,14 +26,15 @@ type RouteGuardProps = {
*/
export function RouteGuard({ perms, children }: RouteGuardProps) {
const { user, permissionsHydrated } = useAuth();
const { t } = useTranslation("common");

if (!permissionsHydrated) {
return (
<div
className="flex min-h-[60vh] items-center justify-center text-sm font-mono uppercase tracking-[0.18em] text-[var(--color-muted-foreground)]"
aria-busy
>
Resolving permissions
{t("routeGuard.resolving")}
<span className="caret text-[var(--color-accent-signal)]" aria-hidden />
</div>
);
Expand Down
Loading
Loading