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..c723615991 --- /dev/null +++ b/.agents/rules/localization.md @@ -0,0 +1,77 @@ +# 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 decides which words the client reads; it never decides how the API formats numbers or dates.** + +## Culture negotiation (already wired — don't re-add) + +`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request **UI** culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures.Tags`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically. + +**`CultureInfo.CurrentUICulture` only — `CurrentCulture` stays invariant.** An API that emits JSON must not shift `ToString()`, `Parse()` or interpolation per request; both React apps format at the presentation layer. `RequestLocalizationMiddleware` assigns both cultures unconditionally, so the culture half is pinned rather than left alone: `DefaultRequestCulture` carries `(InvariantCulture, configured default)` and `SupportedCultures` is `null` so the middleware skips culture filtering. Do **not** "fix" this by adding `AddSupportedCultures(...)`; `Formatting_culture_stays_invariant_while_ui_culture_negotiates` fails if you do. + +`SupportedCultures.Tags` is **specific tags only**, no neutrals. A request asking for a bare `pt`, or for an unsupported variant like `pt-PT`, resolves to the configured default rather than being served a language it was not translated into. The React apps canonicalize variants onto supported tags (`CANON` in `clients/*/src/i18n.ts`) before calling the API, so app traffic is unaffected; a hand-rolled client sending bare `pt` gets the default. Adding a language means: add its specific tag to `Tags`, add a `*.{tag}.resx` per catalog, add its JSON catalogs to both apps, and remove it from `CANON` if it was being folded into another tag. + +## 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` (neutral / en-US) + `Resources.pt-BR.resx`. `ResourcesPath = ""` (co-located), so the resx manifest name must equal the marker's full type name. + +Catalogs are named for **specific** cultures (`.pt-BR`, never a neutral `.pt`), matching the front-end catalog folders. The neutral, un-suffixed `.resx` is the en-US / ultimate-fallback catalog. + +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}}`. + +**Placeholder arguments must be culture-insensitive.** The localizer's indexer calls `string.Format` under `CurrentCulture`, which is invariant (above). Pass `int`/`long`/`string`/enum — never a `double`, `decimal`, `DateTime` or `TimeSpan.TotalX`, which would render with an invariant separator instead of the reader's. Where a count is conceptually whole, expose it as an `int` at the source rather than converting at the call site (see `GetAuditsQueryHandler.MaxWindowDays` next to `MaxWindow`). Money and dates belong in structured response fields formatted by the client, not interpolated into a message. + +## 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. + +## Known behaviour (documented, not bugs) + +- **The `locale` claim lags a language switch by one token.** The culture provider reads the JWT `locale` claim, so a switch does not reach the API until the next token issue. The front-end persists the choice to the profile and re-mints, so it converges; in the window between, the shell can be in the new language while an API error still comes back in the old one. Deliberate: the alternative is a per-request DB read on every authenticated call. +- **Impersonation carries the operator's language, not the target's.** `StartImpersonationCommandHandler` strips the target's `locale` claim so the operator keeps reading in their own language, and the cross-app handoff URL carries `locale` because the dashboard is normally on a different origin and cannot read admin's `i18nextLng`. During impersonation the switcher is client-side only — it must not PUT onto the impersonated user's profile. +- **SignalR does not carry the app locale.** The hub client builds its own requests instead of going through `apiFetch`, so `Accept-Language` on the negotiate is the browser's. Applies to every session. `handoff-locale.spec.ts` names the exception explicitly so any *other* channel that stops carrying the locale fails the test. + +## Tests (required with every catalog change) + +- **Parity** — every key present in both the neutral and the `pt-BR` catalog, for Core and every `Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test. +- **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/.github/workflows/backend.yml b/.github/workflows/backend.yml index dc5e395f7c..8c6d59f36f 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -96,7 +96,7 @@ jobs: - name: Run unit tests with coverage run: | - for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Webhooks; do + for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Tickets Webhooks; do echo "::group::${proj}.Tests" dotnet test "src/Tests/${proj}.Tests" -c Release --no-build \ --collect:"XPlat Code Coverage" --settings coverage.runsettings \ 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/deploy/docker/README.md b/deploy/docker/README.md index bcb1304593..0219164b7c 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -8,9 +8,9 @@ This brings up the full stack on a single host: | `admin` | `fsh/admin:local` | `FSH_ADMIN_PORT` (default 8081) | Operator console (nginx + React) | | `dashboard` | `fsh/dashboard:local` | `FSH_DASHBOARD_PORT` (default 8082) | Tenant dashboard (nginx + React) | | `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user | -| `postgres` | `postgres:17-alpine` | (internal) | Identity, tenant catalog, module schemas | -| `redis` | `redis:7-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | -| `minio` | `minio/minio:latest` | (internal) | S3-compatible blob store for the Files module | +| `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas | +| `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | +| `minio` | `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` | (internal) | S3-compatible blob store for the Files module | The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine. diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index d43c744f5b..dea9a817ff 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -54,7 +54,8 @@ services: # - "6379:6379" minio: - image: minio/minio:latest + # quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. + image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z container_name: fsh-minio restart: unless-stopped command: ["server", "/data", "--console-address", ":9001"] @@ -79,7 +80,8 @@ services: # policy is set — objects are served via the API / presigned URLs, not a # public bucket. minio-init: - image: minio/mc:latest + # quay.io: minio/mc is gone from Docker Hub too. Tag pinned; quay stopped moving :latest. + image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z container_name: fsh-minio-init restart: "no" depends_on: diff --git a/src/BuildingBlocks/Core/Core.csproj b/src/BuildingBlocks/Core/Core.csproj index 3c2e01bfdb..0af6dc03eb 100644 --- a/src/BuildingBlocks/Core/Core.csproj +++ b/src/BuildingBlocks/Core/Core.csproj @@ -3,6 +3,8 @@ FSH.Framework.Core FSH.Framework.Core + + $(NoWarn);S2094 diff --git a/src/BuildingBlocks/Core/Exceptions/CustomException.cs b/src/BuildingBlocks/Core/Exceptions/CustomException.cs index 02dfa51699..ddc183c5d7 100644 --- a/src/BuildingBlocks/Core/Exceptions/CustomException.cs +++ b/src/BuildingBlocks/Core/Exceptions/CustomException.cs @@ -7,7 +7,7 @@ namespace FSH.Framework.Core.Exceptions; /// FullStackHero exception used for consistent error handling across the stack. /// Includes HTTP status codes and optional detailed error messages. /// -public class CustomException : Exception +public class CustomException : Exception, ILocalizableMessage { /// /// A list of error messages (e.g., validation errors, business rules). @@ -19,6 +19,24 @@ public class CustomException : Exception /// public HttpStatusCode StatusCode { get; } + /// + /// Optional resource key resolved against to localize the + /// response body under the request culture. When null, the literal + /// is used. The message itself always stays the (English) fallback for logs. + /// + public string? MessageKey { get; init; } + + /// + /// Format arguments applied to the localized message ({0}, {1}, …). + /// + public IReadOnlyList MessageArgs { get; init; } = []; + + /// + /// Marker type identifying the resource catalog for . When null, + /// the shared (Core) catalog is used; module-specific keys point it at the module's own catalog. + /// + public Type? ResourceSource { get; init; } + /// /// Initializes a new instance of the class with default message and internal server error status. /// diff --git a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs index 5033c897b4..2a26e7f0a4 100644 --- a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs +++ b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs @@ -12,6 +12,7 @@ public class ForbiddenException : CustomException public ForbiddenException() : base("Unauthorized access.", Array.Empty(), HttpStatusCode.Forbidden) { + MessageKey = "Error.ForbiddenAccess"; } /// diff --git a/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs b/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs new file mode 100644 index 0000000000..1aa1d7a698 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs @@ -0,0 +1,20 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// Implemented by exceptions whose response Detail can be localized from a resource key. +/// Lets GlobalExceptionHandler translate the body under the request culture while the +/// exception type stays intact — needed for BCL types the audit severity classifier keys off +/// (e.g. , ). +/// The stays the English fallback for logs and unresolved keys. +/// +public interface ILocalizableMessage +{ + /// Resource key resolved against ; null keeps the literal message. + string? MessageKey { get; } + + /// Format arguments applied to the localized message ({0}, {1}, …). + IReadOnlyList MessageArgs { get; } + + /// Marker type identifying the resource catalog; null uses the shared (Core) catalog. + Type? ResourceSource { get; } +} diff --git a/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs b/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs new file mode 100644 index 0000000000..0bd3d44009 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// whose 404 response Detail is localized via . +/// Subclasses the BCL type on purpose so audit exception-type fixtures and severity classification that +/// key off keep working, while the body still translates under the +/// request culture. The base message stays the English log fallback. +/// +public sealed class LocalizedKeyNotFoundException : KeyNotFoundException, ILocalizableMessage +{ + public string? MessageKey { get; init; } + public IReadOnlyList MessageArgs { get; init; } = []; + public Type? ResourceSource { get; init; } + + public LocalizedKeyNotFoundException() + { + } + + public LocalizedKeyNotFoundException(string message) + : base(message) + { + } + + public LocalizedKeyNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs b/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs new file mode 100644 index 0000000000..c17db1dd97 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// whose 401 response Detail is localized via +/// . Subclasses the BCL type on purpose so the audit severity classifier +/// (which maps to Warning) keeps working, while the body +/// still translates under the request culture. The base message stays the English log fallback. +/// +public sealed class LocalizedUnauthorizedAccessException : UnauthorizedAccessException, ILocalizableMessage +{ + public string? MessageKey { get; init; } + public IReadOnlyList MessageArgs { get; init; } = []; + public Type? ResourceSource { get; init; } + + public LocalizedUnauthorizedAccessException() + { + } + + public LocalizedUnauthorizedAccessException(string message) + : base(message) + { + } + + public LocalizedUnauthorizedAccessException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs index b3815a4b7b..3acf01fd1d 100644 --- a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs +++ b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs @@ -12,6 +12,7 @@ public class UnauthorizedException : CustomException public UnauthorizedException() : base("Authentication failed.", Array.Empty(), HttpStatusCode.Unauthorized) { + MessageKey = "Error.AuthenticationFailed"; } /// diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.cs b/src/BuildingBlocks/Core/Localization/SharedResources.cs new file mode 100644 index 0000000000..8dd0ce5452 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Framework.Core.Localization; + +/// Marker type binding IStringLocalizer<SharedResources> to the shared resx catalog. +public sealed class SharedResources; diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx new file mode 100644 index 0000000000..71374e1381 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ocorreram um ou mais erros de validação. + + + Ocorreram um ou mais erros de validação. + + + Não autorizado + + + É necessário autenticar-se para acessar este recurso. + + + Não encontrado + + + Requisição inválida + + + Conflito + + + Acesso negado + + + Nenhum usuário autenticado. + + + Tenant inválido ou ausente. + + + O contexto do tenant é obrigatório. + + + Ocorreu um erro inesperado + + + Ocorreu um erro inesperado. Tente novamente mais tarde. + + + Limite de aplicação + + + Contas de superadministrador devem usar o aplicativo de administração. Entre por lá, não pelo painel da organização. + + + Falha na autenticação. + + + Acesso não autorizado. + + + Cota de armazenamento excedida ({0}/{1} bytes). + + + O valor de Take deve estar entre 1 e {0}. + + + A duração deve estar entre 1 e {0} minutos. + + + Somente estas extensões são permitidas: {0} + + + O arquivo deve ter no máximo {0} MB. + + + Opções de banco de dados não encontradas. + + + O provedor de armazenamento {0} do Hangfire não é suportado. + + + O ID do usuário é obrigatório. + + + Você não pode enviar uma nova imagem e excluir a atual ao mesmo tempo. + + + Idioma não suportado. + + + O ID do grupo é obrigatório. + + + É necessário pelo menos um ID de usuário. + + + Os IDs de usuário não podem ser vazios ou conter apenas espaços. + + + O nome do grupo é obrigatório. + + + O nome do grupo não pode exceder 256 caracteres. + + + A descrição não pode exceder 1024 caracteres. + + + O ID da função é obrigatório. + + + O nome da função é obrigatório. + + + O número da página deve ser maior ou igual a 1. + + + O tamanho da página deve ser maior ou igual a 1. + + + O tamanho da página deve estar entre 1 e 100. + + + A expressão de ordenação não pode exceder 200 caracteres. + + + O ID da sessão é obrigatório. + + + O motivo não pode exceder 500 caracteres. + + + A lista de funções do usuário é obrigatória. + + + O código de confirmação é obrigatório. + + + A organização é obrigatória. + + + A senha atual é obrigatória. + + + A nova senha é obrigatória. + + + A nova senha deve ser diferente da senha atual. + + + Esta senha foi usada recentemente. Escolha uma senha diferente. + + + As senhas não coincidem. + + + O nome é obrigatório. + + + O nome não pode exceder 100 caracteres. + + + O sobrenome é obrigatório. + + + O sobrenome não pode exceder 100 caracteres. + + + O e-mail é obrigatório. + + + É necessário um endereço de e-mail válido. + + + O nome de usuário é obrigatório. + + + O nome de usuário deve ter pelo menos 3 caracteres. + + + O nome de usuário não pode exceder 50 caracteres. + + + A senha é obrigatória. + + + A senha deve ter pelo menos 6 caracteres. + + + A confirmação de senha é obrigatória. + + + O número de telefone não pode exceder 20 caracteres. + + diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.resx b/src/BuildingBlocks/Core/Localization/SharedResources.resx new file mode 100644 index 0000000000..7e1ebe5b2e --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.resx @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + One or more validation errors occurred. + + + One or more validation errors occurred. + + + Unauthorized + + + Authentication is required to access this resource. + + + Not Found + + + Bad Request + + + Conflict + + + Forbidden + + + No authenticated user. + + + Invalid or missing tenant. + + + Tenant context is required. + + + An unexpected error occurred + + + An unexpected error occurred. Please try again later. + + + App boundary + + + SuperAdmin accounts must use the admin app. Sign in there instead of the tenant dashboard. + + + Authentication failed. + + + Unauthorized access. + + + Storage quota exceeded ({0}/{1} bytes). + + + Take must be between 1 and {0}. + + + Duration must be between 1 and {0} minutes. + + + Only these extensions are allowed: {0} + + + File must be <= {0} MB. + + + Database options not found. + + + Hangfire storage provider {0} is not supported. + + + User ID is required. + + + You cannot upload a new image and delete the current one simultaneously. + + + Unsupported locale. + + + Group ID is required. + + + At least one user ID is required. + + + User IDs cannot be empty or whitespace. + + + Group name is required. + + + Group name must not exceed 256 characters. + + + Description must not exceed 1024 characters. + + + Role ID is required. + + + Role name is required. + + + Page number must be greater than or equal to 1. + + + Page size must be greater than or equal to 1. + + + Page size must be between 1 and 100. + + + Sort expression must not exceed 200 characters. + + + Session ID is required. + + + Reason must not exceed 500 characters. + + + User roles list is required. + + + Confirmation code is required. + + + Tenant is required. + + + Current password is required. + + + New password is required. + + + New password must be different from the current password. + + + This password has been used recently. Please choose a different password. + + + Passwords do not match. + + + First name is required. + + + First name must not exceed 100 characters. + + + Last name is required. + + + Last name must not exceed 100 characters. + + + Email is required. + + + A valid email address is required. + + + Username is required. + + + Username must be at least 3 characters. + + + Username must not exceed 50 characters. + + + Password is required. + + + Password must be at least 6 characters. + + + Password confirmation is required. + + + Phone number must not exceed 20 characters. + + diff --git a/src/BuildingBlocks/Core/Localization/SupportedCultures.cs b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs new file mode 100644 index 0000000000..e462c4f775 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs @@ -0,0 +1,24 @@ +using System.Collections.Frozen; + +namespace FSH.Framework.Core.Localization; + +/// Canonical set of cultures the platform supports for user-facing localization. +public static class SupportedCultures +{ + /// Guaranteed ultimate fallback culture, served by the neutral (un-suffixed) catalog. + public const string Default = "en-US"; + + /// + /// Specific tags a user may persist, the switcher offers, and Accept-Language is matched against. + /// Deliberately specific-only, with no neutral entries: every catalog is named for a specific + /// culture (*.pt-BR.resx), matching the front-end catalogs. A request asking for a bare + /// pt, or for an unsupported variant like pt-PT, therefore resolves to + /// rather than being silently served Brazilian strings. Adding a language + /// means adding its specific tag here plus a *.{tag}.resx per catalog. + /// Frozen rather than an array: a public static array is writable by any caller, and the + /// whitelist a validator and a culture provider both trust cannot be a mutable global. + /// Ordinal on purpose — a wrong-case tag is a client bug, not a variant. + /// + public static readonly FrozenSet Tags = + new[] { "en-US", "pt-BR" }.ToFrozenSet(StringComparer.Ordinal); +} diff --git a/src/BuildingBlocks/Jobs/Extensions.cs b/src/BuildingBlocks/Jobs/Extensions.cs index 9ab773b0fc..57a9b4da58 100644 --- a/src/BuildingBlocks/Jobs/Extensions.cs +++ b/src/BuildingBlocks/Jobs/Extensions.cs @@ -32,7 +32,10 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services) { var configuration = provider.GetRequiredService(); var dbOptions = configuration.GetSection(nameof(DatabaseOptions)).Get() - ?? throw new CustomException("Database options not found"); + ?? throw new CustomException("Database options not found") + { + MessageKey = "Jobs.DatabaseOptionsNotFound", + }; switch (dbOptions.Provider.ToUpperInvariant()) { @@ -48,7 +51,11 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services) break; default: - throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported"); + throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported") + { + MessageKey = "Jobs.UnsupportedStorageProvider", + MessageArgs = [dbOptions.Provider], + }; } config.UseActivator(new FshJobActivator(provider.GetRequiredService())); diff --git a/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs b/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs index 2dcef1323f..3938bf8aef 100644 --- a/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs +++ b/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs @@ -69,7 +69,11 @@ public async Task UploadAsync(FileUploadRequest request, FileType fil throw new CustomException( $"Storage quota exceeded ({check.CurrentUsage}/{check.Limit} bytes).", errors: null, - HttpStatusCode.InsufficientStorage); + HttpStatusCode.InsufficientStorage) + { + MessageKey = "Storage.QuotaExceeded", + MessageArgs = [check.CurrentUsage, check.Limit], + }; } try diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs index 8e8987dae7..8ba2db36c8 100644 --- a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -1,21 +1,141 @@ using System.Diagnostics; using System; +using System.Globalization; +using System.Net; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Core.Localization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Serilog.Context; namespace FSH.Framework.Web.Exceptions; -public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +public class GlobalExceptionHandler( + ILogger logger, + IStringLocalizer localizer, + IStringLocalizerFactory localizerFactory) : IExceptionHandler { + // Returns null for a status with no title of its own. Deliberately NOT a catch-all + // "Error.Unexpected": that key exists, so ResourceNotFound would be false and a 409 would + // report "An unexpected error occurred" alongside Status 409 and a Detail describing a + // perfectly ordinary business-rule conflict — a title contradicting its own status code. + // A null key keeps the pre-localization behaviour (the exception type name) for every + // status not translated here, which is at least status-consistent. + private static string? TitleKeyFor(HttpStatusCode statusCode) => statusCode switch + { + HttpStatusCode.NotFound => "Error.NotFound", + HttpStatusCode.Unauthorized => "Error.Unauthorized", + HttpStatusCode.Forbidden => "Error.Forbidden", + HttpStatusCode.BadRequest => "Error.BadRequest", + HttpStatusCode.Conflict => "Error.Conflict", + _ => null, + }; + + // Resolves the localized Detail for an exception carrying a MessageKey, under the request culture. + // The [key, args] indexer runs string.Format; a stray '{' in the resx would throw FormatException + // from inside the handler, so fall back to the (English) message on a format error or a missing key. + // A null key keeps the literal message. Shared by the CustomException, Unauthorized and NotFound + // branches so a localized BCL subclass (ILocalizableMessage) translates the same way. + private string LocalizeDetail(ILocalizableMessage localizable, string fallbackMessage) + { + if (localizable.MessageKey is null) + { + return fallbackMessage; + } + + var moduleLocalizer = localizerFactory.Create(localizable.ResourceSource ?? typeof(SharedResources)); + try + { + var message = localizable.MessageArgs.Count == 0 + ? moduleLocalizer[localizable.MessageKey] + : moduleLocalizer[localizable.MessageKey, LocalizeArguments(localizable.MessageArgs, moduleLocalizer)]; + return message.ResourceNotFound ? fallbackMessage : message.Value; + } + catch (FormatException) + { + return fallbackMessage; + } + } + + // An enum argument would otherwise reach the user as its C# member name, leaving a translated + // sentence ending in an English word ("um chamado no status Closed"). Each member is looked up as + // "{EnumType}.{Member}" in the same catalog as the message itself. A member with no entry keeps + // ToString(), which is what every argument does today. + private static object[] LocalizeArguments(IReadOnlyList args, IStringLocalizer localizer) + { + var localized = new object[args.Count]; + for (var i = 0; i < args.Count; i++) + { + if (args[i] is Enum member) + { + var entry = localizer[$"{member.GetType().Name}.{member}"]; + localized[i] = entry.ResourceNotFound ? member.ToString() : entry.Value; + } + else + { + localized[i] = args[i]; + } + } + + return localized; + } + + // Writes the localized Detail and, when the exception carries a MessageKey, surfaces that key as a + // stable machine-readable "code". Detail is prose under the request culture, so a client that needs + // to branch on a specific error (a terminal state, a dedicated screen, a retry) keys off the code + // instead of matching text that changes with Accept-Language. + private void ApplyLocalizedDetail(ProblemDetails problemDetails, ILocalizableMessage localizable, string fallbackMessage) + { + problemDetails.Detail = LocalizeDetail(localizable, fallbackMessage); + + if (localizable.MessageKey is not null) + { + problemDetails.Extensions["code"] = localizable.MessageKey; + } + } + + // UseExceptionHandler sits ABOVE UseHeroLocalization in the pipeline, and + // RequestLocalizationMiddleware assigns CultureInfo.CurrentUICulture inside its own async frame — + // an assignment that belongs to that frame's ExecutionContext and is already gone by the time an + // exception unwinds up to this handler. Every localizer below would therefore resolve under the + // culture of the host process (the invariant one in a container with no LANG), and answer from the + // neutral resx no matter what the client asked for. The negotiated culture survives on the request + // itself, so take it from there and restore the ambient one afterwards. + // + // CurrentUICulture only: AddHeroLocalization pins CurrentCulture to invariant on purpose, so that + // no request can shift numeric or date formatting anywhere in the pipeline. public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(httpContext); ArgumentNullException.ThrowIfNull(exception); + var requestUiCulture = httpContext.Features.Get()?.RequestCulture.UICulture; + + // No feature means the exception escaped before localization ran (CORS, security headers, + // forwarded headers). Nothing was negotiated, so the ambient culture is all there is. + if (requestUiCulture is null) + { + return await WriteProblemDetailsAsync(httpContext, exception, requestUiCulture: null, cancellationToken).ConfigureAwait(false); + } + + var previousUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = requestUiCulture; + try + { + return await WriteProblemDetailsAsync(httpContext, exception, requestUiCulture, cancellationToken).ConfigureAwait(false); + } + finally + { + CultureInfo.CurrentUICulture = previousUiCulture; + } + } + + private async ValueTask WriteProblemDetailsAsync(HttpContext httpContext, Exception exception, CultureInfo? requestUiCulture, CancellationToken cancellationToken) + { var problemDetails = new ProblemDetails { Instance = httpContext.Request.Path @@ -28,8 +148,8 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e statusCode = StatusCodes.Status400BadRequest; problemDetails.Status = statusCode; - problemDetails.Title = "Validation error"; - problemDetails.Detail = "One or more validation errors occurred."; + problemDetails.Title = localizer["Error.Validation"]; + problemDetails.Detail = localizer["Error.Validation.Detail"]; problemDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; var errors = fluentException.Errors @@ -43,10 +163,13 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e else if (exception is CustomException e) { statusCode = (int)e.StatusCode; - problemDetails.Status = statusCode; - problemDetails.Title = e.GetType().Name; - problemDetails.Detail = e.Message; + + var titleKey = TitleKeyFor(e.StatusCode); + var title = titleKey is null ? null : localizer[titleKey]; + problemDetails.Title = title is null || title.ResourceNotFound ? e.GetType().Name : title.Value; + + ApplyLocalizedDetail(problemDetails, e, e.Message); if (e.ErrorMessages is { Count: > 0 }) { @@ -57,15 +180,29 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e { statusCode = StatusCodes.Status401Unauthorized; problemDetails.Status = statusCode; - problemDetails.Title = "Unauthorized"; - problemDetails.Detail = exception.Message; + problemDetails.Title = localizer["Error.Unauthorized"]; + if (exception is ILocalizableMessage unauthorizedLoc) + { + ApplyLocalizedDetail(problemDetails, unauthorizedLoc, exception.Message); + } + else + { + problemDetails.Detail = exception.Message; + } } else if (exception is KeyNotFoundException) { statusCode = StatusCodes.Status404NotFound; problemDetails.Status = statusCode; - problemDetails.Title = "Not Found"; - problemDetails.Detail = exception.Message; + problemDetails.Title = localizer["Error.NotFound"]; + if (exception is ILocalizableMessage notFoundLoc) + { + ApplyLocalizedDetail(problemDetails, notFoundLoc, exception.Message); + } + else + { + problemDetails.Detail = exception.Message; + } } else if (exception is BadHttpRequestException badRequest) { @@ -73,19 +210,28 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e // Client error carrying the correct status (usually 400) — honour it instead of falling through to a generic 500. statusCode = badRequest.StatusCode; problemDetails.Status = statusCode; - problemDetails.Title = "Bad Request"; + problemDetails.Title = localizer["Error.BadRequest"]; problemDetails.Detail = badRequest.Message; } else { statusCode = StatusCodes.Status500InternalServerError; problemDetails.Status = statusCode; - problemDetails.Title = "An unexpected error occurred"; - problemDetails.Detail = "An unexpected error occurred. Please try again later."; + problemDetails.Title = localizer["Error.Unexpected"]; + problemDetails.Detail = localizer["Error.Unexpected.Detail"]; } httpContext.Response.StatusCode = statusCode; + // ExceptionHandlerMiddleware clears the response before re-executing, which drops the + // Content-Language RequestLocalizationMiddleware had already written. Put it back, so a client + // can tell which culture the prose in this body is in. The invariant culture has an empty name + // and is not a valid header value. + if (requestUiCulture is not null && requestUiCulture.Name.Length > 0) + { + httpContext.Response.Headers.ContentLanguage = requestUiCulture.Name; + } + // Surface trace and correlation IDs so clients/support can correlate errors to traces var traceId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier; problemDetails.Extensions["traceId"] = traceId; @@ -94,12 +240,18 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e ?? httpContext.TraceIdentifier; problemDetails.Extensions["correlationId"] = correlationId; - LogContext.PushProperty("exception_title", problemDetails.Title); - LogContext.PushProperty("exception_detail", problemDetails.Detail); - LogContext.PushProperty("exception_statusCode", problemDetails.Status); - LogContext.PushProperty("exception_stackTrace", exception.StackTrace); - - logger.LogError("Exception at {Path} - {StatusCode} {Title}", httpContext.Request.Path.Value?.Replace(Environment.NewLine, string.Empty), statusCode, problemDetails.Title); + // Log the raw (English) exception message and type, never the localized ProblemDetails body, + // so log entries stay culture-independent regardless of the request's negotiated culture. + // PushProperty returns an IDisposable that pops the property on dispose; scope it to the LogError + // call so it does not leak onto every subsequent log entry of the request (AsyncLocal contamination). + var logPath = httpContext.Request.Path.Value?.Replace(Environment.NewLine, string.Empty); + using (LogContext.PushProperty("exception_type", exception.GetType().Name)) + using (LogContext.PushProperty("exception_detail", exception.Message)) + using (LogContext.PushProperty("exception_statusCode", statusCode)) + using (LogContext.PushProperty("exception_stackTrace", exception.StackTrace)) + { + logger.LogError("Exception at {Path} - {StatusCode} {Type}", logPath, statusCode, exception.GetType().Name); + } await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken).ConfigureAwait(false); return true; diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 50c6568fda..5b21d659dd 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -9,6 +9,7 @@ using FSH.Framework.Web.Exceptions; using FSH.Framework.Web.FeatureFlags; using FSH.Framework.Web.Idempotency; +using FSH.Framework.Web.Localization; using FSH.Framework.Web.Sse; using FSH.Framework.Web.Health; using FSH.Framework.Web.Mediator.Behaviors; @@ -129,6 +130,7 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild builder.Services.AddHeroQuotas(builder.Configuration); } + builder.Services.AddHeroLocalization(builder.Configuration); builder.Services.AddExceptionHandler(); builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); builder.Services.AddProblemDetails(); @@ -185,6 +187,10 @@ public static WebApplication UseHeroPlatform(this WebApplication app, Action + /// Registers request localization: resx-backed IStringLocalizer and a UI-culture-provider + /// chain of Query → user locale claim → Accept-Language → configured default → en-US. + /// The per-deployment default is read from LocalizationOptions:DefaultCulture and validated + /// against the whitelist, so garbage config falls back to the guaranteed default culture. + /// Negotiation drives only — + /// stays invariant, so no request can shift numeric, + /// date or string formatting anywhere in the pipeline. + /// + public static IServiceCollection AddHeroLocalization(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var configured = configuration["LocalizationOptions:DefaultCulture"]; + var defaultCulture = SupportedCultures.Tags.Contains(configured!) ? configured! : SupportedCultures.Default; + + // ResourcesPath = "" because SharedResources and its resx live in the same folder/namespace + // (co-located). A non-empty path would double the prefix and IStringLocalizer would silently + // fall back to the raw key. The resx-resolution test guards this value. + services.AddLocalization(o => o.ResourcesPath = ""); + + services.Configure(o => + { + // UI-culture-only. RequestLocalizationMiddleware.SetCurrentThreadCulture assigns BOTH + // CurrentCulture and CurrentUICulture unconditionally, so there is no "leave formatting + // alone" switch: the culture half has to be pinned instead. Two things make that work. + // 1. DefaultRequestCulture carries the pair, and the middleware resolves the culture half + // as `cultureInfo ??= DefaultRequestCulture.Culture`. Pinning that half to invariant + // makes invariant the only value CurrentCulture can ever take. + // 2. SupportedCultures = null makes the middleware skip culture filtering entirely. + // A one-element [InvariantCulture] list would behave the same but log + // `UnsupportedCultures` on EVERY request: the middleware's parent-culture walk bails + // out at the empty culture name, so invariant is unmatchable by design. + // An API that emits JSON has no business shifting ToString()/Parse() per request; both + // React apps already format at the presentation layer. See #1344 review. + o.DefaultRequestCulture = new RequestCulture(CultureInfo.InvariantCulture, new CultureInfo(defaultCulture)); + o.SupportedCultures = null; + o.AddSupportedUICultures([.. SupportedCultures.Tags]); + o.ApplyCurrentCultureToResponseHeaders = true; + + // Default order is [Query(0), Cookie(1), AcceptLanguage(2)]. Drop the cookie provider by + // type (order-independent, so a framework reshuffle of the defaults can't silently remove + // the wrong provider) and insert the user-claim provider right after query, so the final + // chain is Query → UserLocaleClaim → AcceptLanguage → configured default → en-US neutral resx. + var cookieProvider = o.RequestCultureProviders + .FirstOrDefault(p => p is CookieRequestCultureProvider); + if (cookieProvider is not null) + { + o.RequestCultureProviders.Remove(cookieProvider); + } + + o.RequestCultureProviders.Insert(1, new UserLocaleRequestCultureProvider()); + }); + + return services; + } + + public static IApplicationBuilder UseHeroLocalization(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseRequestLocalization(); + } +} diff --git a/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs b/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs new file mode 100644 index 0000000000..8c0e3a7e45 --- /dev/null +++ b/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs @@ -0,0 +1,31 @@ +using System.Security.Claims; +using FSH.Framework.Core.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; + +namespace FSH.Framework.Web.Localization; + +/// +/// Resolves the request culture from the authenticated user's locale claim +/// (mirrors the persisted User.Locale). Sits after the query-string provider and before +/// the Accept-Language provider: a supported claim wins over the browser header, an unsupported +/// or absent claim falls through to the next provider. +/// +public sealed class UserLocaleRequestCultureProvider : RequestCultureProvider +{ + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + var claim = httpContext.User.FindFirstValue("locale"); + if (IsSupported(claim)) + { + return Task.FromResult(new ProviderCultureResult(claim!)); + } + + return NullProviderCultureResult; + } + + private static bool IsSupported(string? tag) => + !string.IsNullOrWhiteSpace(tag) && SupportedCultures.Tags.Contains(tag); +} diff --git a/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs b/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs index e00af4e26a..93129df818 100644 --- a/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs +++ b/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Persistence; +using Microsoft.Extensions.Localization; namespace FSH.Framework.Web.Validation; @@ -10,9 +12,9 @@ namespace FSH.Framework.Web.Validation; /// /// public class MyQueryValidator : AbstractValidator<MyQuery> /// { -/// public MyQueryValidator() +/// public MyQueryValidator(IStringLocalizer<SharedResources> localizer) /// { -/// Include(new PagedQueryValidator<MyQuery>()); +/// Include(new PagedQueryValidator<MyQuery>(localizer)); /// // Add additional rules... /// } /// } @@ -20,21 +22,21 @@ namespace FSH.Framework.Web.Validation; public sealed class PagedQueryValidator : AbstractValidator where T : IPagedQuery { - public PagedQueryValidator() + public PagedQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.PageNumber) .GreaterThan(0) .When(q => q.PageNumber.HasValue) - .WithMessage("Page number must be greater than 0."); + .WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(q => q.PageSize) .InclusiveBetween(1, 100) .When(q => q.PageSize.HasValue) - .WithMessage("Page size must be between 1 and 100."); + .WithMessage(_ => localizer["Validation.PageSizeRange"]); RuleFor(q => q.Sort) .MaximumLength(200) .When(q => !string.IsNullOrEmpty(q.Sort)) - .WithMessage("Sort expression must not exceed 200 characters."); + .WithMessage(_ => localizer["Validation.SortMaxLength"]); } } \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..854deb9530 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -9,7 +9,8 @@ - + + @@ -122,9 +123,10 @@ - - - + + + + diff --git a/src/FSH.Starter.slnx b/src/FSH.Starter.slnx index 998d538836..c51beb777a 100644 --- a/src/FSH.Starter.slnx +++ b/src/FSH.Starter.slnx @@ -80,6 +80,7 @@ + diff --git a/src/Host/FSH.Starter.AppHost/AppHost.cs b/src/Host/FSH.Starter.AppHost/AppHost.cs index e7a70abd05..4fc689599d 100644 --- a/src/Host/FSH.Starter.AppHost/AppHost.cs +++ b/src/Host/FSH.Starter.AppHost/AppHost.cs @@ -52,7 +52,10 @@ var minioUser = builder.AddParameter("minio-user", "minioadmin"); var minioPassword = builder.AddParameter("minio-password", "minioadmin", secret: true); +// quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. var minio = builder.AddContainer("minio", "minio/minio") + .WithImageRegistry("quay.io") + .WithImageTag("RELEASE.2025-09-07T16-13-09Z") .WithArgs("server", "/data", "--console-address", ":9001") .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api") .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console") @@ -73,6 +76,8 @@ """).ReplaceLineEndings("\n"); var minioInit = builder.AddContainer("minio-init", "minio/mc") + .WithImageRegistry("quay.io") + .WithImageTag("RELEASE.2025-08-13T08-35-41Z") .WithEntrypoint("/bin/sh") .WithArgs("-c", minioInitScript) .WithEnvironment("MC_USER", minioUser) diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.Designer.cs new file mode 100644 index 0000000000..329e6c67ed --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.Designer.cs @@ -0,0 +1,774 @@ +// +using System; +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20260918053951_AddUserLocale")] + partial class AddUserLocale + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName", "TenantId") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Locale") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ObjectId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName", "TenantId") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("CreatedOnUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsSystemGroup") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("ModifiedBy"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("ModifiedAt"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Name"); + + b.ToTable("Groups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("GroupId", "RoleId"); + + b.HasIndex("GroupId"); + + b.HasIndex("RoleId"); + + b.ToTable("GroupRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.ImpersonationGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClientId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImpersonatedTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImpersonatedUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImpersonatedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokeReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedByUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.HasIndex("ActorUserId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ActorUserId_StartedAtUtc"); + + b.HasIndex("ImpersonatedTenantId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ImpersonatedTenantId_StartedAtUtc"); + + b.ToTable("ImpersonationGrants", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("AddedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BrowserVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OsVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("GroupRoles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("UserGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Navigation("GroupRoles"); + + b.Navigation("UserGroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.cs new file mode 100644 index 0000000000..566fb18c49 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Identity +{ + /// + public partial class AddUserLocale : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Locale", + schema: "identity", + table: "Users", + type: "character varying(10)", + maxLength: 10, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Locale", + schema: "identity", + table: "Users"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs index 34efbeb275..89d3e50615 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs @@ -128,6 +128,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastPasswordChangeDate") .HasColumnType("timestamp with time zone"); + b.Property("Locale") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + b.Property("LockoutEnabled") .HasColumnType("boolean"); diff --git a/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs b/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs index 9dc0554d0f..d03ac8301b 100644 --- a/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs +++ b/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; using System.Diagnostics; @@ -56,13 +57,21 @@ public static Builder ForException(Exception ex, ExceptionArea area = ExceptionA eventType: AuditEventType.Exception, severity: severity ?? DefaultSeverity(ex), payload: new ExceptionEventPayload(area, - ex.GetType().FullName ?? "Exception", + RealExceptionType(ex).FullName ?? "Exception", ex.Message ?? string.Empty, StackTop(ex, maxFrames: 20), ToDict(ex.Data), routeOrLocation)); } + // Localization wrappers (LocalizedKeyNotFoundException, LocalizedUnauthorizedAccessException) exist + // only to translate the response body; for audit type identity and exceptionType filtering they must + // present as their BCL base so queries stay stable. CustomException-derived types keep their own identity. + private static Type RealExceptionType(Exception ex) => + ex is ILocalizableMessage and not CustomException && ex.GetType().BaseType is { } baseType + ? baseType + : ex.GetType(); + private static AuditSeverity DefaultSeverity(Exception ex) { if (ex is OperationCanceledException) diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs index ec9e845dee..122edc7123 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs @@ -1,5 +1,7 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAuditById; using FSH.Modules.Auditing.Persistence; using Mediator; @@ -31,9 +33,15 @@ public async ValueTask Handle(GetAuditByIdQuery query, Cancellat if (record is null) { - // KeyNotFoundException maps to 404 globally. Kept (not framework NotFoundException) - // because audit exception-type fixtures and severity classification key off this type. - throw new KeyNotFoundException($"Audit record {query.Id} not found."); + // LocalizedKeyNotFoundException maps to 404 globally and still keys off KeyNotFoundException + // (its base) for audit exception-type fixtures and severity classification, while the body + // localizes via MessageKey under the request culture. + throw new LocalizedKeyNotFoundException($"Audit record {query.Id} not found.") + { + MessageKey = "Auditing.AuditRecordNotFound", + MessageArgs = [query.Id], + ResourceSource = typeof(AuditingResources), + }; } JsonElement payload; diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs index 90b954f77a..142cb1bc47 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Auditing.Contracts.Authorization; using FSH.Modules.Auditing.Contracts.Dtos; using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Persistence; using FSH.Modules.Identity.Contracts.Services; using Mediator; @@ -13,7 +14,8 @@ namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; public sealed class GetAuditSummaryQueryHandler : IQueryHandler { - public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(90); + public const int MaxWindowDays = 90; + public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(MaxWindowDays); public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(7); private readonly AuditDbContext _dbContext; @@ -104,7 +106,11 @@ requested is not null .ConfigureAwait(false); if (!allowed) { - throw new ForbiddenException("Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant."); + throw new ForbiddenException("Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant.") + { + MessageKey = "Error.Auditing.CrossTenantSummaryForbidden", + ResourceSource = typeof(AuditingResources), + }; } return _dbContext.AuditRecords diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs index 76a39601a8..c1f0d82888 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs @@ -1,21 +1,26 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; public sealed class GetAuditSummaryQueryValidator : AbstractValidator { - public GetAuditSummaryQueryValidator() + public GetAuditSummaryQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || (q.ToUtc.Value - q.FromUtc.Value) <= GetAuditSummaryQueryHandler.MaxWindow) - .WithMessage($"Audit summary window cannot exceed {GetAuditSummaryQueryHandler.MaxWindow.TotalDays:0} days."); + // MaxWindowDays, not MaxWindow.TotalDays: the localizer formats arguments with + // string.Format under the current culture, and a double in a localized message is + // culture-sensitive by construction. An int cannot render a decimal separator. + .WithMessage(_ => localizer["Validation.SummaryWindowExceeded", GetAuditSummaryQueryHandler.MaxWindowDays]); } } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs index f14e46bad8..865d33e975 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs @@ -6,6 +6,7 @@ using FSH.Modules.Auditing.Contracts.Authorization; using FSH.Modules.Auditing.Contracts.Dtos; using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Persistence; using FSH.Modules.Identity.Contracts.Services; using Mediator; @@ -21,7 +22,10 @@ public sealed class GetAuditsQueryHandler : IQueryHandler - public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(90); + public const int MaxWindowDays = 90; + + /// + public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(MaxWindowDays); /// /// Default lookback when the caller does not supply a from/to. Keeps the @@ -157,7 +161,11 @@ requested is not null .ConfigureAwait(false); if (!allowed) { - throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant."); + throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant.") + { + MessageKey = "Error.Auditing.CrossTenantAccessForbidden", + ResourceSource = typeof(AuditingResources), + }; } return _dbContext.AuditRecords diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs index 3b3fb1a4ac..0591b7b433 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs @@ -1,18 +1,23 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAudits; public sealed class GetAuditsQueryValidator : AbstractValidator { - public GetAuditsQueryValidator() + public GetAuditsQueryValidator( + IStringLocalizer localizer, + IStringLocalizer auditLocalizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => auditLocalizer["Validation.DateRangeOrder"]); // Reject oversized windows up-front (user sees a 400, not a silent clamp). The handler // still clamps as defence in depth (e.g. when only one endpoint is supplied). @@ -21,6 +26,9 @@ public GetAuditsQueryValidator() !q.FromUtc.HasValue || !q.ToUtc.HasValue || (q.ToUtc.Value - q.FromUtc.Value) <= GetAuditsQueryHandler.MaxWindow) - .WithMessage($"Audit query window cannot exceed {GetAuditsQueryHandler.MaxWindow.TotalDays:0} days."); + // MaxWindowDays, not MaxWindow.TotalDays: the localizer formats arguments with + // string.Format under the current culture, and a double in a localized message is + // culture-sensitive by construction. An int cannot render a decimal separator. + .WithMessage(_ => auditLocalizer["Validation.WindowExceeded", GetAuditsQueryHandler.MaxWindowDays]); } } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs index 3b00a8b5f9..d4039f0d25 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; public sealed class GetAuditsByCorrelationQueryValidator : AbstractValidator { - public GetAuditsByCorrelationQueryValidator() + public GetAuditsByCorrelationQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.CorrelationId) .NotEmpty(); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs index 8e8cdfbf13..74ffab9edc 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; public sealed class GetAuditsByTraceQueryValidator : AbstractValidator { - public GetAuditsByTraceQueryValidator() + public GetAuditsByTraceQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.TraceId) .NotEmpty(); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs index 0b08a67f82..9d5754ea16 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits; public sealed class GetExceptionAuditsQueryValidator : AbstractValidator { - public GetExceptionAuditsQueryValidator() + public GetExceptionAuditsQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs index 05d69a7334..7bc1a471ec 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits; public sealed class GetSecurityAuditsQueryValidator : AbstractValidator { - public GetSecurityAuditsQueryValidator() + public GetSecurityAuditsQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs new file mode 100644 index 0000000000..072a952413 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Auditing.Localization; + +/// Marker type binding IStringLocalizer<AuditingResources> to the Auditing resx catalog. +public sealed class AuditingResources; diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx new file mode 100644 index 0000000000..327d056243 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + O acesso a auditorias de outros inquilinos requer Permissions.AuditTrails.ViewCrossTenant. + + + O resumo de auditorias de outros inquilinos requer Permissions.AuditTrails.ViewCrossTenant. + + + FromUtc deve ser menor ou igual a ToUtc. + + + A janela de consulta de auditoria não pode exceder {0} dias. + + + A janela do resumo de auditoria não pode exceder {0} dias. + + + Registro de auditoria {0} não encontrado. + + diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx new file mode 100644 index 0000000000..ed95eb4dda --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant. + + + Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant. + + + FromUtc must be less than or equal to ToUtc. + + + Audit query window cannot exceed {0} days. + + + Audit summary window cannot exceed {0} days. + + + Audit record {0} not found. + + diff --git a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj index 2c8b0a32de..ff7f4b1e61 100644 --- a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj +++ b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj @@ -3,7 +3,8 @@ FSH.Modules.Auditing FSH.Modules.Auditing - $(NoWarn);CA1031;CA1308;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1308;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs index c99a1b0c2a..9743e2c623 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Contracts.v1.Invoices; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; @@ -19,10 +20,17 @@ public async ValueTask Handle(GenerateInvoicesCommand command, Cancellation // Platform-wide invoice generation runs across EVERY tenant — it is a root-operator action. // A tenant admin (who also holds Billing.Manage) must not be able to trigger it. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; if (callerTenantId != MultitenancyConstants.Root.Id) { - throw new ForbiddenException("Only the root operator may generate invoices across tenants."); + throw new ForbiddenException("Only the root operator may generate invoices across tenants.") + { + MessageKey = "Billing.OnlyRootOperatorMayGenerateInvoices", + ResourceSource = typeof(BillingResources), + }; } return await billing.GenerateInvoicesForAllTenantsAsync(command.PeriodYear, command.PeriodMonth, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs index 62f4602d04..24bd651ab6 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Billing.Contracts.Dtos; using FSH.Modules.Billing.Contracts.v1.Invoices; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(GetInvoiceByIdQuery query, Cancellatio // BillingDbContext isn't tenant-filtered (raw DbContext for cross-tenant admin visibility): root // reads any invoice by id; a tenant caller is pinned to its own so it can't read another's. Mirrors GetSubscriptionQueryHandler. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var invoice = await dbContext.Invoices.AsNoTracking() @@ -30,7 +34,12 @@ public async ValueTask Handle(GetInvoiceByIdQuery query, Cancellatio i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found."); + ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [query.InvoiceId], + ResourceSource = typeof(BillingResources), + }; return invoice.ToDto(); } diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs index 4b6c88b5a3..303831089a 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(GetInvoicePdfQuery query, Cancel // BillingDbContext is not tenant-filtered: root may download ANY tenant's invoice PDF; a tenant // caller is pinned to its own, so a cross-tenant id resolves to 404 and never leaks a PDF. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var invoice = await dbContext.Invoices.AsNoTracking() @@ -30,7 +34,12 @@ public async ValueTask Handle(GetInvoicePdfQuery query, Cancel i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found."); + ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [query.InvoiceId], + ResourceSource = typeof(BillingResources), + }; var dto = invoice.ToDto(); var content = renderer.Render(dto); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs index ef07f2ebcd..a603fd8142 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs @@ -22,7 +22,10 @@ public async ValueTask> Handle(GetInvoicesQuery query, // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally // narrowed via query.TenantId); every other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs index 8485c5a373..f700bd5e29 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask> Handle(GetMyInvoicesQuery quer ArgumentNullException.ThrowIfNull(query); var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var q = dbContext.Invoices.AsNoTracking() .Include(i => i.LineItems) diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs index da86c3187d..f71eb9cb40 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Billing.Contracts.v1.Plans; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetPlanTermQuery query, Cancella #pragma warning restore CA1308 var plan = await dbContext.Plans.AsNoTracking() .FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Active plan with key '{query.PlanKey}' not found."); + ?? throw new NotFoundException($"Active plan with key '{query.PlanKey}' not found.") + { + MessageKey = "Billing.ActivePlanNotFound", + MessageArgs = [query.PlanKey], + ResourceSource = typeof(BillingResources), + }; return new PlanTermResponse( plan.Id, diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs index 233b749bd2..03b274b5a6 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Billing.Contracts.v1.Plans; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -14,7 +15,12 @@ public async ValueTask Handle(UpdatePlanCommand command, CancellationToken ArgumentNullException.ThrowIfNull(command); var plan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Id == command.PlanId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {command.PlanId} not found."); + ?? throw new NotFoundException($"Plan {command.PlanId} not found.") + { + MessageKey = "Billing.PlanNotFound", + MessageArgs = [command.PlanId], + ResourceSource = typeof(BillingResources), + }; plan.Update(command.Name, command.MonthlyBasePrice, command.OverageRates, command.Interval, command.AnnualPrice); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs index 2348de90ff..0f1e368d59 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Billing.Contracts.v1.Subscriptions; using FSH.Modules.Billing.Data; using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(AssignSubscriptionCommand command, Cancellat // Only root may target an arbitrary tenant; a tenant caller is pinned to its own, so it can't // (re)assign or cancel another tenant's subscription via a foreign tenant id in the body. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var targetTenantId = isRoot ? command.TenantId : callerTenantId; @@ -29,7 +33,12 @@ public async ValueTask Handle(AssignSubscriptionCommand command, Cancellat var key = command.PlanKey.ToLowerInvariant(); #pragma warning restore CA1308 var plan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Active plan with key '{command.PlanKey}' not found."); + ?? throw new NotFoundException($"Active plan with key '{command.PlanKey}' not found.") + { + MessageKey = "Billing.ActivePlanNotFound", + MessageArgs = [command.PlanKey], + ResourceSource = typeof(BillingResources), + }; var now = DateTime.UtcNow; var current = await dbContext.Subscriptions diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs index 5e0f8c8538..6c55f9d7df 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs @@ -19,7 +19,10 @@ public sealed class GetSubscriptionQueryHandler( ArgumentNullException.ThrowIfNull(query); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; // BillingDbContext is not tenant-filtered, so a tenant caller is pinned to its OWN // subscription and only root may pass an arbitrary tenant id (else cross-tenant reads). diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs index 930c39de9c..996a9cb910 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs @@ -21,7 +21,10 @@ public async ValueTask> Handle( // Only the root operator may capture usage for an arbitrary tenant; a tenant caller is pinned // to its own tenant so it can't fabricate another tenant's usage/overage snapshots. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var targetTenantId = isRoot ? command.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs index a49afdae72..395052b770 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs @@ -21,7 +21,10 @@ public async ValueTask> Handle(GetUsageSnapshots // UsageSnapshots is not tenant-filtered. Only the root operator may read across tenants // (optionally narrowed via query.TenantId); any other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs index d1b92dca6e..e64e0c8663 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Contracts.v1.Wallets; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,29 @@ public async ValueTask Handle(ApproveTopupRequestCommand command, Cancella ArgumentNullException.ThrowIfNull(command); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var request = await db.TopupRequests .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {command.Id} not found."); + ?? throw new NotFoundException($"Top-up request {command.Id} not found.") + { + MessageKey = "Billing.TopupRequestNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(BillingResources), + }; if (!isRoot && request.TenantId != callerTenantId) { - throw new UnauthorizedException("You can only approve top-up requests for your own tenant."); + throw new UnauthorizedException("You can only approve top-up requests for your own tenant.") + { + MessageKey = "Billing.CannotApproveTopupForOtherTenant", + ResourceSource = typeof(BillingResources), + }; } // For root, operate on the request's own tenant; for non-root, callerTenantId equals request.TenantId. diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs index b5621e5b6f..2d74244ce8 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs @@ -21,7 +21,10 @@ public async ValueTask Handle(CreateTopupRequestCommand command, Cancellat // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var requestedBy = currentUser.IsAuthenticated() ? currentUser.GetUserId().ToString() : null; var request = TopupRequest.Create(tenantId, command.Amount, "USD", command.Note, requestedBy); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs index d87440550b..e1ee030975 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs @@ -22,7 +22,10 @@ public async ValueTask> Handle(GetMyTopupRequests // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var q = dbContext.TopupRequests.AsNoTracking() .Where(r => r.TenantId == tenantId); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs index 37512d4582..d77dd0f535 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask Handle(GetMyWalletQuery query, CancellationTok // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var wallet = await billingService.GetOrCreateWalletAsync(tenantId, "USD", cancellationToken).ConfigureAwait(false); return wallet.ToDto(); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs index 51adf97f43..8da6936a97 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs @@ -23,7 +23,10 @@ public async ValueTask> Handle(GetTopupRequestsQu // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally // narrowed via query.TenantId); every other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs index 34ff4ea4c4..f7f444f500 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Billing.Contracts; using FSH.Modules.Billing.Contracts.v1.Wallets; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,29 @@ public async ValueTask Handle(RejectTopupRequestCommand command, Cancellat ArgumentNullException.ThrowIfNull(command); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var request = await db.TopupRequests .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {command.Id} not found."); + ?? throw new NotFoundException($"Top-up request {command.Id} not found.") + { + MessageKey = "Billing.TopupRequestNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(BillingResources), + }; if (!isRoot && request.TenantId != callerTenantId) { - throw new UnauthorizedException("You can only reject top-up requests for your own tenant."); + throw new UnauthorizedException("You can only reject top-up requests for your own tenant.") + { + MessageKey = "Billing.CannotRejectTopupForOtherTenant", + ResourceSource = typeof(BillingResources), + }; } if (request.Status != TopupRequestStatus.Pending) @@ -38,7 +51,12 @@ public async ValueTask Handle(RejectTopupRequestCommand command, Cancellat throw new CustomException( $"Top-up request {command.Id} cannot be rejected because it is {request.Status} (only Pending requests can be rejected).", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Billing.TopupRequestCannotBeRejected", + MessageArgs = [command.Id, request.Status], + ResourceSource = typeof(BillingResources), + }; } request.Reject(command.Reason); diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs new file mode 100644 index 0000000000..1ab5911ad5 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Billing.Localization; + +/// Marker type binding IStringLocalizer<BillingResources> to the Billing resx catalog. +public sealed class BillingResources; diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx new file mode 100644 index 0000000000..bf841aa8e3 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Fatura {0} não encontrada. + + + Plano {0} não encontrado. + + + Plano {0} não encontrado para o tenant {1}. + + + Plano ativo com a chave '{0}' não encontrado. + + + Solicitação de recarga {0} não encontrada. + + + Solicitação de recarga {0} não encontrada ou não está pendente. + + + Apenas o operador raiz pode gerar faturas entre tenants. + + + Você só pode rejeitar solicitações de recarga do seu próprio tenant. + + + Você só pode aprovar solicitações de recarga do seu próprio tenant. + + + Não é possível rejeitar a solicitação de recarga {0} porque ela está {1} (somente solicitações Pendentes podem ser rejeitadas). + + + Pendente + + + Faturada + + + Concluída + + + Rejeitada + + + Cancelada + + diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx new file mode 100644 index 0000000000..6b0efe7612 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Invoice {0} not found. + + + Plan {0} not found. + + + Plan {0} not found for tenant {1}. + + + Active plan with key '{0}' not found. + + + Top-up request {0} not found. + + + Top-up request {0} not found or not pending. + + + Only the root operator may generate invoices across tenants. + + + You can only reject top-up requests for your own tenant. + + + You can only approve top-up requests for your own tenant. + + + Top-up request {0} cannot be rejected because it is {1} (only Pending requests can be rejected). + + + Pending + + + Invoiced + + + Completed + + + Rejected + + + Cancelled + + diff --git a/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj b/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj index 75f90198a1..429ae3d723 100644 --- a/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj +++ b/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj @@ -3,7 +3,7 @@ FSH.Modules.Billing FSH.Modules.Billing - $(NoWarn);CA1031;CA1711;CA1812;CA1859;S3267 + $(NoWarn);CA1031;CA1711;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs index 5b684ab0b4..0b30ade16c 100644 --- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs +++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Billing.Contracts.Events; using FSH.Modules.Billing.Data; using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -75,7 +76,12 @@ public BillingService( } var plan = await _db.Plans.FirstOrDefaultAsync(p => p.Id == subscription.PlanId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {subscription.PlanId} not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Plan {subscription.PlanId} not found for tenant {tenantId}.") + { + MessageKey = "Billing.PlanNotFoundForTenant", + MessageArgs = [subscription.PlanId, tenantId], + ResourceSource = typeof(BillingResources), + }; var snapshots = await _usageReporter.CaptureForPeriodAsync(tenantId, periodYear, periodMonth, cancellationToken).ConfigureAwait(false); @@ -188,7 +194,12 @@ public async Task CreateTopupInvoiceAsync(string tenantId, Guid topupRe var request = await _db.TopupRequests .FirstOrDefaultAsync(r => r.Id == topupRequestId && r.TenantId == tenantId && r.Status == TopupRequestStatus.Pending, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {topupRequestId} not found or not pending."); + ?? throw new NotFoundException($"Top-up request {topupRequestId} not found or not pending.") + { + MessageKey = "Billing.TopupRequestNotFoundOrNotPending", + MessageArgs = [topupRequestId], + ResourceSource = typeof(BillingResources), + }; var now = _timeProvider.GetUtcNow().UtcDateTime; var invoiceNumber = BuildTopupInvoiceNumber(tenantId, now, topupRequestId); @@ -289,13 +300,21 @@ public async Task VoidInvoiceAsync(Guid invoiceId, string? reason, CancellationT private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken cancellationToken) { var callerTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; return await _db.Invoices .FirstOrDefaultAsync(i => i.Id == invoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {invoiceId} not found."); + ?? throw new NotFoundException($"Invoice {invoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [invoiceId], + ResourceSource = typeof(BillingResources), + }; } public async Task CreateSubscriptionInvoiceAsync( @@ -308,7 +327,12 @@ private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken c ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); var plan = await _db.Plans.FirstOrDefaultAsync(p => p.Id == planId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {planId} not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Plan {planId} not found for tenant {tenantId}.") + { + MessageKey = "Billing.PlanNotFoundForTenant", + MessageArgs = [planId, tenantId], + ResourceSource = typeof(BillingResources), + }; var termPrice = plan.TermPrice; if (termPrice.Amount <= 0m) diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs index 95130c2bcd..4d89c4bbcf 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask Handle(CreateBrandCommand command, CancellationToke throw new CustomException( $"A brand with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.BrandNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Brands.Add(brand); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs index f32da24e77..34965b7575 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteBrandCommand command, CancellationToke var brand = await dbContext.Brands .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; dbContext.Brands.Remove(brand); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs index 0963c6a11d..2c62cdccc6 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetBrandByIdQuery query, CancellationTok .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == query.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {query.BrandId} not found."); + ?? throw new NotFoundException($"Brand {query.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [query.BrandId], + ResourceSource = typeof(CatalogResources), + }; return new BrandDto( brand.Id, diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs index 061e97df26..a71cae0134 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,7 +21,12 @@ public async ValueTask Handle(RestoreBrandCommand command, CancellationTok .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; brand.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs index 0e5ba4c14a..45416dde79 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateBrandCommand command, CancellationToke var brand = await dbContext.Brands .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; brand.Update(command.Name, command.Description, command.LogoUrl); @@ -29,7 +35,12 @@ public async ValueTask Handle(UpdateBrandCommand command, CancellationToke throw new CustomException( $"Another brand with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherBrandNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs index adc5edfb6e..451d15618d 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,12 @@ public async ValueTask Handle(CreateCategoryCommand command, CancellationT .ConfigureAwait(false); if (!parentExists) { - throw new NotFoundException($"Parent category {parentId} not found."); + throw new NotFoundException($"Parent category {parentId} not found.") + { + MessageKey = "Catalog.ParentCategoryNotFound", + MessageArgs = [parentId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -36,7 +42,12 @@ public async ValueTask Handle(CreateCategoryCommand command, CancellationT throw new CustomException( $"A category with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.CategoryNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Categories.Add(category); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs index a596dce866..60d97e8e16 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(DeleteCategoryCommand command, CancellationT var category = await dbContext.Categories .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; bool hasChildren = await dbContext.Categories .AnyAsync(c => c.ParentCategoryId == category.Id, cancellationToken) @@ -27,7 +33,11 @@ public async ValueTask Handle(DeleteCategoryCommand command, CancellationT throw new CustomException( "Cannot delete a category that has child categories. Move or remove the children first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.CategoryHasChildren", + ResourceSource = typeof(CatalogResources), + }; } dbContext.Categories.Remove(category); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs index 793fb49bcd..ce9552acac 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetCategoryByIdQuery query, Cancellat .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == query.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {query.CategoryId} not found."); + ?? throw new NotFoundException($"Category {query.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [query.CategoryId], + ResourceSource = typeof(CatalogResources), + }; return new CategoryDto(c.Id, c.Name, c.Slug, c.Description, c.ParentCategoryId, c.CreatedAtUtc, c.UpdatedAtUtc, c.DeletedOnUtc, c.DeletedBy); } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs index 3b29c8540d..ad36397147 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreCategoryCommand command, Cancellation .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; category.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs index 9fa8a5053d..61242c7e5f 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT var category = await dbContext.Categories .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; if (command.ParentCategoryId is { } parentId) { @@ -26,7 +32,11 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( "A category cannot be its own parent.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Catalog.CategoryCannotBeOwnParent", + ResourceSource = typeof(CatalogResources), + }; } // Walk parent chain to detect cycles (parent → ancestor of self) @@ -39,7 +49,11 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( "Setting this parent would create a cycle.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Catalog.CategoryParentCycle", + ResourceSource = typeof(CatalogResources), + }; } cursor = await dbContext.Categories .Where(c => c.Id == cur) @@ -59,7 +73,12 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( $"Another category with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherCategoryNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs index 1bbb943009..dc2248f7f3 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Products.AddProductImage; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(AddProductImageCommand command, C var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; var image = product.AddImage(command.FileAssetId, command.Url); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs index a82c413ce0..6156c8f6e5 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(AdjustProductStockCommand command, Cancellati var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; try { @@ -25,7 +31,12 @@ public async ValueTask Handle(AdjustProductStockCommand command, Cancellati } catch (InvalidOperationException ex) { - throw new CustomException(ex.Message, (IEnumerable?)null, HttpStatusCode.Conflict); + throw new CustomException(ex.Message, (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Catalog.StockAdjustmentNegative", + MessageArgs = [command.Delta, product.Stock], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs index 6f2b5b3d07..1070bc2833 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; using FSH.Modules.Catalog.Contracts.v1.Products; +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Catalog.Features.v1.Products.AdjustProductStock; public sealed class AdjustProductStockCommandValidator : AbstractValidator { - public AdjustProductStockCommandValidator() + public AdjustProductStockCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.ProductId).NotEmpty(); - RuleFor(x => x.Delta).NotEqual(0).WithMessage("Delta must be non-zero."); + RuleFor(x => x.Delta).NotEqual(0).WithMessage(_ => localizer["Validation.DeltaNonZero"]); } } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs index 8a101e9155..4abe3b18bc 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(ChangeProductPriceCommand command, Cancellat var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.ChangePrice(new Money(command.Amount, command.Currency)); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs index d3e75608a1..4a6701c851 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo .ConfigureAwait(false); if (!brandExists) { - throw new NotFoundException($"Brand {command.BrandId} not found."); + throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; } bool categoryExists = await dbContext.Categories @@ -29,7 +35,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo .ConfigureAwait(false); if (!categoryExists) { - throw new NotFoundException($"Category {command.CategoryId} not found."); + throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; } var product = Product.Create( @@ -49,7 +60,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo throw new CustomException( $"A product with SKU '{product.Sku}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.ProductSkuAlreadyExists", + MessageArgs = [product.Sku], + ResourceSource = typeof(CatalogResources), + }; } bool slugTaken = await dbContext.Products @@ -60,7 +76,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo throw new CustomException( $"A product with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.ProductNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Products.Add(product); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs index 01be205865..43f04c46f8 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -19,7 +20,12 @@ public async ValueTask Handle(DeleteProductCommand command, CancellationTo .IgnoreAutoIncludes() .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; dbContext.Products.Remove(product); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs index 7e36cc4cee..243b7362d1 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetProductByIdQuery query, Cancellatio .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == query.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {query.ProductId} not found."); + ?? throw new NotFoundException($"Product {query.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [query.ProductId], + ResourceSource = typeof(CatalogResources), + }; return product.ToDto(); } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs index db0db8006c..fbc0fc9865 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.RemoveProductImage; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,12 +17,22 @@ public async ValueTask Handle(RemoveProductImageCommand command, Cancellat var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; // Domain throws InvalidOperationException for unknown imageId; translate to 404. if (!product.Images.Any(i => i.Id == command.ImageId)) { - throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}."); + throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}.") + { + MessageKey = "Catalog.ProductImageNotFound", + MessageArgs = [command.ImageId, command.ProductId], + ResourceSource = typeof(CatalogResources), + }; } product.RemoveImage(command.ImageId); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs index 7441d60e8e..5ec439d6f1 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.ReorderProductImages; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ReorderProductImagesCommand command, Cancell var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.ReorderImages(command.OrderedImageIds); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs index 235baee8d3..e6424dab84 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreProductCommand command, CancellationT .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs index 7b9232d82a..1d1522f078 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.SetProductThumbnail; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,13 +17,23 @@ public async ValueTask Handle(SetProductThumbnailCommand command, Cancella var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; // Domain throws InvalidOperationException for unknown imageId; translate to a // framework-aware 404 so the API surfaces NotFound rather than a 500. if (!product.Images.Any(i => i.Id == command.ImageId)) { - throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}."); + throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}.") + { + MessageKey = "Catalog.ProductImageNotFound", + MessageArgs = [command.ImageId, command.ProductId], + ResourceSource = typeof(CatalogResources), + }; } product.SetThumbnail(command.ImageId); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs index 0d99b89eab..ff84ca4611 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; if (product.BrandId != command.BrandId) { @@ -26,7 +32,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo .ConfigureAwait(false); if (!brandExists) { - throw new NotFoundException($"Brand {command.BrandId} not found."); + throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -37,7 +48,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo .ConfigureAwait(false); if (!categoryExists) { - throw new NotFoundException($"Category {command.CategoryId} not found."); + throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -56,7 +72,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo throw new CustomException( $"Another product with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherProductNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs new file mode 100644 index 0000000000..6258320c1c --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Catalog.Localization; + +/// Marker type binding IStringLocalizer<CatalogResources> to the Catalog resx catalog. +public sealed class CatalogResources; diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx new file mode 100644 index 0000000000..5f08aaceeb --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Produto {0} não encontrado. + + + Marca {0} não encontrada. + + + Categoria {0} não encontrada. + + + Categoria pai {0} não encontrada. + + + Imagem {0} não encontrada no produto {1}. + + + Já existe um produto com o SKU '{0}'. + + + Já existe um produto com o nome '{0}'. + + + Já existe outro produto com o nome '{0}'. + + + Já existe uma marca com o nome '{0}'. + + + Já existe outra marca com o nome '{0}'. + + + Já existe uma categoria com o nome '{0}'. + + + Já existe outra categoria com o nome '{0}'. + + + Uma categoria não pode ser pai de si mesma. + + + Definir este pai criaria um ciclo. + + + Não é possível excluir uma categoria que possui categorias filhas. Mova ou remova as filhas primeiro. + + + O delta deve ser diferente de zero. + + + O ajuste de estoque de {0} resultaria em estoque negativo (atual: {1}). + + diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx new file mode 100644 index 0000000000..7594ae3200 --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Product {0} not found. + + + Brand {0} not found. + + + Category {0} not found. + + + Parent category {0} not found. + + + Image {0} not found on product {1}. + + + A product with SKU '{0}' already exists. + + + A product with name '{0}' already exists. + + + Another product with name '{0}' already exists. + + + A brand with name '{0}' already exists. + + + Another brand with name '{0}' already exists. + + + A category with name '{0}' already exists. + + + Another category with name '{0}' already exists. + + + A category cannot be its own parent. + + + Setting this parent would create a cycle. + + + Cannot delete a category that has child categories. Move or remove the children first. + + + Delta must be non-zero. + + + Stock adjustment of {0} would result in negative stock (current: {1}). + + diff --git a/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj b/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj index f8f033ed05..72c4e33471 100644 --- a/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj +++ b/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj @@ -3,7 +3,8 @@ FSH.Modules.Catalog FSH.Modules.Catalog - $(NoWarn);CA1031;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs index c80fe1ccd4..a3011a9bc8 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,18 +22,26 @@ public async ValueTask Handle(AddChannelMembersCommand cmd, CancellationTo { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Members can invite to public channels they belong to; private channels require Admin. var caller = channel.RequireMember(currentUserId); if (channel.IsPrivate && caller.Role != ChannelMemberRole.Admin) { - throw new ForbiddenException("Only channel admins can add members to private channels."); + throw new ForbiddenException("Only channel admins can add members to private channels.") + { + MessageKey = "Chat.OnlyAdminsCanAddMembersToPrivateChannel", + ResourceSource = typeof(ChatResources), + }; } var newlyAdded = new List(); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs index a1654617a3..fd19956d49 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,11 +18,15 @@ public async ValueTask Handle(ArchiveChannelCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireAdmin(userId.ToString()); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs index 308dee6655..6de899d1a4 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs @@ -18,7 +18,7 @@ public async ValueTask Handle(CreateChannelCommand cmd, CancellationToken var userId = currentUser.GetUserId().ToString(); if (userId == Guid.Empty.ToString()) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; } var channel = ChatChannel.CreateChannel(cmd.Name, cmd.Description, cmd.IsPrivate, userId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs index e29d15045c..bb9846dff4 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs @@ -20,7 +20,7 @@ public async ValueTask> Handle(DiscoverChannelsQu { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs index 0e52a8c295..8f3c1e7251 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,13 +22,17 @@ public async ValueTask Handle(FindOrCreateDmCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var otherIds = cmd.UserIds.Distinct(StringComparer.Ordinal).ToList(); if (otherIds.Any(id => string.Equals(id, currentUserId, StringComparison.Ordinal))) { - throw new CustomException("Cannot DM yourself.", (IEnumerable?)null, System.Net.HttpStatusCode.BadRequest); + throw new CustomException("Cannot DM yourself.", (IEnumerable?)null, System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Chat.CannotDmYourself", + ResourceSource = typeof(ChatResources), + }; } if (otherIds.Count == 1) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs index d766d06112..0a36112071 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,13 +19,17 @@ public async ValueTask Handle(GetChannelByIdQuery q, CancellationTok { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.AsNoTracking() .FirstOrDefaultAsync(c => c.Id == q.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Private channels & DMs: must be a member. Public channels: anyone with View can see them. if (channel.IsPrivate) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs index eca1da32f7..f448a41843 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs @@ -19,7 +19,7 @@ public async ValueTask> Handle(ListMyChannelsQuer { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs index 8aa1ea9b44..d65d6cb06e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,19 +21,30 @@ public async ValueTask Handle(MarkChannelReadCommand cmd, CancellationToke { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); // Verify the marker message actually exists in this channel. var exists = await db.Messages .AnyAsync(m => m.Id == cmd.MessageId && m.ChannelId == cmd.ChannelId, cancellationToken) .ConfigureAwait(false); - if (!exists) throw new NotFoundException("Message not found in this channel."); + if (!exists) + { + throw new NotFoundException("Message not found in this channel.") + { + MessageKey = "Chat.MessageNotFoundInChannel", + ResourceSource = typeof(ChatResources), + }; + } channel.MarkRead(currentUserId, cmd.MessageId); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs index 0e0f06e757..a229200610 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,12 +22,16 @@ public async ValueTask Handle(RemoveChannelMemberCommand cmd, Cancellation { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Self-leave is always allowed for the current user. Removing someone else requires Admin. var isSelfLeave = string.Equals(cmd.UserId, currentUserId, StringComparison.Ordinal); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs index c878d4e8ce..3966e232d1 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,11 @@ public async ValueTask Handle(RestoreChannelCommand cmd, CancellationToken var channel = await db.Channels.IgnoreQueryFilters() .FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; if (!channel.IsDeleted) return Unit.Value; // idempotent channel.Restore(); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs index 980fdea854..d449df5f9e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,11 +18,15 @@ public async ValueTask Handle(UpdateChannelCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireAdmin(userId.ToString()); channel.Rename(cmd.Name, cmd.Description); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs index b217e9b3c9..f38e7d6c6f 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Domain; +using FSH.Modules.Chat.Localization; namespace FSH.Modules.Chat.Features.v1.Internal; @@ -14,7 +15,11 @@ public static ChannelMember RequireMember(this ChatChannel channel, string userI { var member = channel.Members.FirstOrDefault(m => string.Equals(m.UserId, userId, StringComparison.Ordinal)); // Use NotFoundException (404) instead of Forbidden so non-members can't probe channel existence. - return member ?? throw new NotFoundException("Channel not found."); + return member ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; } public static ChannelMember RequireAdmin(this ChatChannel channel, string userId) @@ -22,7 +27,11 @@ public static ChannelMember RequireAdmin(this ChatChannel channel, string userId var member = channel.RequireMember(userId); if (member.Role != ChannelMemberRole.Admin) { - throw new ForbiddenException("Channel admin role required."); + throw new ForbiddenException("Channel admin role required.") + { + MessageKey = "Chat.ChannelAdminRoleRequired", + ResourceSource = typeof(ChatResources), + }; } return member; } diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs index 0127455da7..e29a70ec77 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using FSH.Modules.Identity.Contracts.Services; using Mediator; using Microsoft.AspNetCore.SignalR; @@ -23,16 +24,24 @@ public async ValueTask Handle(DeleteMessageCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); bool isModerator = await permissions diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs index 92ce8fad72..1c4065b1dd 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,25 @@ public async ValueTask Handle(EditMessageCommand cmd, CancellationToken ca { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; // Verify membership through the parent channel (don't leak existence to non-members). var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Edit(cmd.Body, currentUserId); // domain enforces author-only diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs index 20f3213b5c..46253936d5 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,12 +23,16 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); var rows = await db.Messages diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs index e845953d78..088563bc68 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,12 +23,16 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); // Top-level only (no thread replies). Guid v7 monotonic → Id desc = time desc. diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs index 538e110717..814cc4c7d0 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,7 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); // Load the parent so we can authorize the caller through the channel. @@ -31,11 +32,19 @@ public async ValueTask> Handle( .Select(m => new { m.Id, m.ChannelId }) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == parent.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); IQueryable q = db.Messages diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs index 47a1e07442..3ea9c8f473 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(PinMessageCommand cmd, CancellationToken can { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Pin(currentUserId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs index b2c2511dc3..40d375c123 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs @@ -10,6 +10,7 @@ using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using FSH.Modules.Chat.Services; using Mediator; using Microsoft.AspNetCore.SignalR; @@ -29,12 +30,16 @@ public async ValueTask Handle(SendMessageCommand cmd, CancellationTo { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); @@ -43,15 +48,27 @@ public async ValueTask Handle(SendMessageCommand cmd, CancellationTo { parent = await db.Messages.FirstOrDefaultAsync(m => m.Id == parentId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; if (parent.ChannelId != channel.Id) { - throw new CustomException("Parent message belongs to a different channel.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException("Parent message belongs to a different channel.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Chat.ParentMessageDifferentChannel", + ResourceSource = typeof(ChatResources), + }; } if (parent.ParentMessageId.HasValue) { // 1-level deep only per spec. - throw new CustomException("Cannot reply to a reply — threads are single-level only.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException("Cannot reply to a reply — threads are single-level only.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Chat.CannotReplyToReply", + ResourceSource = typeof(ChatResources), + }; } } diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs index f385568961..0252dfe299 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs @@ -1,11 +1,13 @@ using FluentValidation; using FSH.Modules.Chat.Contracts.v1.Commands; +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Chat.Features.v1.Messages.SendMessage; public sealed class SendMessageCommandValidator : AbstractValidator { - public SendMessageCommandValidator() + public SendMessageCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.ChannelId).NotEmpty(); // Body is optional when an attachment is present (Slack/Teams parity — "here's the file" with @@ -13,7 +15,7 @@ public SendMessageCommandValidator() RuleFor(x => x.Body) .NotEmpty() .When(x => x.Attachments is null || x.Attachments.Count == 0) - .WithMessage("Either a body or an attachment is required."); + .WithMessage(_ => localizer["Validation.BodyOrAttachmentRequired"]); RuleFor(x => x.Body) .MaximumLength(32_768) .When(x => !string.IsNullOrEmpty(x.Body)); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs index 65beefe000..5fc34f0b35 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(UnpinMessageCommand cmd, CancellationToken c { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Unpin(currentUserId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs index bd22475b8a..b85d987840 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,25 @@ public async ValueTask Handle(AddReactionCommand cmd, CancellationToken ca { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; // Authorize through the parent channel — don't leak existence to non-members. var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); var added = message.AddReaction(currentUserId, cmd.Emoji); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs index 996127ed01..695085bc4e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(RemoveReactionCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); if (!message.RemoveReaction(currentUserId, cmd.Emoji)) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs index 0f1ce68f26..e68b402953 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs @@ -23,7 +23,7 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, query.Page); diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs new file mode 100644 index 0000000000..bfdb7f34ea --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Chat.Localization; + +/// Marker type binding IStringLocalizer<ChatResources> to the Chat resx catalog. +public sealed class ChatResources; diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx new file mode 100644 index 0000000000..0713cbd0b5 --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Canal não encontrado. + + + É necessário ser administrador do canal. + + + Apenas administradores do canal podem adicionar membros a canais privados. + + + Mensagem não encontrada. + + + Mensagem não encontrada neste canal. + + + Mensagem pai não encontrada. + + + A mensagem pai pertence a outro canal. + + + Não é possível responder a uma resposta; as threads têm apenas um nível. + + + Não é possível iniciar uma conversa direta consigo mesmo. + + + É necessário informar um texto ou um anexo. + + diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx new file mode 100644 index 0000000000..5319e61aa1 --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Channel not found. + + + Channel admin role required. + + + Only channel admins can add members to private channels. + + + Message not found. + + + Message not found in this channel. + + + Parent message not found. + + + Parent message belongs to a different channel. + + + Cannot reply to a reply — threads are single-level only. + + + Cannot DM yourself. + + + Either a body or an attachment is required. + + diff --git a/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj b/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj index 80aba39d01..12383f226e 100644 --- a/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj +++ b/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj @@ -3,7 +3,8 @@ FSH.Modules.Chat FSH.Modules.Chat - $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267;S2094 diff --git a/src/Modules/Files/Modules.Files/Domain/FileAsset.cs b/src/Modules/Files/Modules.Files/Domain/FileAsset.cs index f8c8e150d7..f1630df52c 100644 --- a/src/Modules/Files/Modules.Files/Domain/FileAsset.cs +++ b/src/Modules/Files/Modules.Files/Domain/FileAsset.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Domain.Events; +using FSH.Modules.Files.Localization; namespace FSH.Modules.Files.Domain; @@ -87,7 +88,12 @@ public void MarkAvailable(long actualSize, ScanStatus scanResult) throw new CustomException( $"Cannot finalize file in status {Status}.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Files.CannotFinalizeInStatus", + MessageArgs = [Status], + ResourceSource = typeof(FilesResources), + }; } if (actualSize <= 0) { @@ -126,7 +132,12 @@ public void ChangeVisibility(Visibility next) throw new CustomException( $"Cannot change visibility while file is in status {Status}.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Files.CannotChangeVisibilityInStatus", + MessageArgs = [Status], + ResourceSource = typeof(FilesResources), + }; } if (Visibility == next) return; Visibility = next; diff --git a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs index 91f37c1f02..9713652697 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs @@ -7,6 +7,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -29,21 +30,38 @@ public async ValueTask Handle(ChangeFileVisibilityCommand cmd, Can throw new CustomException( $"Unknown visibility value '{cmd.Visibility}'.", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Files.UnknownVisibility", + MessageArgs = [cmd.Visibility], + ResourceSource = typeof(FilesResources), + }; } var f = await db.FileAssets .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new ForbiddenException("no policy"); + ?? throw new ForbiddenException("no policy") + { + MessageKey = "Files.NoAccessPolicy", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanChangeVisibilityAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("not allowed to change this file's visibility"); + throw new ForbiddenException("not allowed to change this file's visibility") + { + MessageKey = "Files.NotAllowedToChangeVisibility", + ResourceSource = typeof(FilesResources), + }; } f.ChangeVisibility(cmd.Visibility); diff --git a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs index 7cf3f3d510..07057289ab 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; using FSH.Modules.Files.Contracts.v1.Commands; +using FSH.Modules.Files.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Files.Features.v1.ChangeVisibility; public sealed class ChangeFileVisibilityCommandValidator : AbstractValidator { - public ChangeFileVisibilityCommandValidator() + public ChangeFileVisibilityCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.FileAssetId).NotEmpty(); RuleFor(x => x.Visibility) .IsInEnum() - .WithMessage("Visibility must be Public or Private."); + .WithMessage(_ => localizer["Files.VisibilityInvalid"]); } } diff --git a/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs index 95190356a0..cfea26f961 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Files.Contracts; using FSH.Modules.Files.Contracts.v1.Commands; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,15 +23,27 @@ public async ValueTask Handle(DeleteFileCommand cmd, CancellationToken can var f = await db.FileAssets .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new ForbiddenException("no policy"); + ?? throw new ForbiddenException("no policy") + { + MessageKey = "Files.NoAccessPolicy", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanDeleteAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("not allowed to delete this file"); + throw new ForbiddenException("not allowed to delete this file") + { + MessageKey = "Files.NotAllowedToDelete", + ResourceSource = typeof(FilesResources), + }; } // Soft-delete: AuditableEntitySaveChangesInterceptor sets IsDeleted/DeletedOnUtc/DeletedBy on diff --git a/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs index 8288acbe42..26a72648d9 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs @@ -12,6 +12,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -30,25 +31,44 @@ public sealed class FinalizeUploadCommandHandler( public async ValueTask Handle(FinalizeUploadCommand cmd, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(cmd); - var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant"); + var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; var userId = currentUser.GetUserId().ToString(); var asset = await db.FileAssets .FirstOrDefaultAsync(f => f.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; if (!string.Equals(asset.CreatedByUserId, userId, StringComparison.Ordinal)) { - throw new ForbiddenException("not your pending file"); + throw new ForbiddenException("not your pending file") + { + MessageKey = "Files.NotYourPendingFile", + ResourceSource = typeof(FilesResources), + }; } if (asset.Status != FileAssetStatus.PendingUpload) { - throw new CustomException("file already finalized", (IEnumerable?)null, HttpStatusCode.Conflict); + throw new CustomException("file already finalized", (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Files.AlreadyFinalized", + ResourceSource = typeof(FilesResources), + }; } var head = await storage.HeadObjectAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false) - ?? throw new CustomException("upload not received", (IEnumerable?)null, HttpStatusCode.Conflict); + ?? throw new CustomException("upload not received", (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Files.UploadNotReceived", + ResourceSource = typeof(FilesResources), + }; // Allow declared+1% slack (S3 may differ slightly on multipart). Reject larger sizes. var maxAllowed = asset.SizeBytes + Math.Max(1024L, asset.SizeBytes / 100); @@ -60,7 +80,12 @@ public async ValueTask Handle(FinalizeUploadCommand cmd, Cancellat throw new CustomException( $"uploaded size ({head.SizeBytes}) exceeds declared ({asset.SizeBytes})", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.UploadedSizeExceedsDeclared", + MessageArgs = [head.SizeBytes, asset.SizeBytes], + ResourceSource = typeof(FilesResources), + }; } if (!string.Equals(head.ContentType, asset.ContentType, StringComparison.OrdinalIgnoreCase)) @@ -71,7 +96,11 @@ public async ValueTask Handle(FinalizeUploadCommand cmd, Cancellat throw new CustomException( "uploaded content-type mismatch", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.ContentTypeMismatch", + ResourceSource = typeof(FilesResources), + }; } var scanResult = await scanner.ScanAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs index 7dd4d06b64..8a0adace50 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Contracts.v1.Queries; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,16 +28,28 @@ public async ValueTask Handle(GetFileDownloadUrlQuery var f = await db.FileAssets.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == q.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanReadAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new NotFoundException("file not found"); + throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; } var ttl = TimeSpan.FromMinutes(options.Value.DownloadUrlTtlMinutes); diff --git a/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs index 32f8366244..cf8a85a80f 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs @@ -7,6 +7,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,16 +28,29 @@ public async ValueTask Handle(GetFileMetadataQuery q, Cancellation var f = await db.FileAssets.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == q.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); + // don't leak existence on missing policy var policy = policies.Resolve(f.OwnerType) - ?? throw new NotFoundException("file not found"); // don't leak existence on missing policy + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanReadAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new NotFoundException("file not found"); + throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; } // Public files get a durable URL safe to persist long-term, while private files mint a diff --git a/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs index 935c5a13ed..72b99dccb1 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs @@ -24,7 +24,10 @@ public async ValueTask> Handle(ListMyFilesQuery var userId = currentUser.GetUserId().ToString(); if (string.IsNullOrEmpty(userId) || userId == Guid.Empty.ToString()) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } var page = Math.Max(1, q.Page); diff --git a/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs index c5d837cd14..4ba9696d50 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs @@ -9,6 +9,7 @@ using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.Extensions.Options; @@ -28,17 +29,28 @@ public async ValueTask Handle(RequestUploadUrlCommand c { ArgumentNullException.ThrowIfNull(cmd); - var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant"); + var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; var userId = currentUser.GetUserId(); if (userId == Guid.Empty) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } // Category lookup + extension/size validation. if (!options.Value.Categories.TryGetValue(cmd.Category, out var category)) { - throw new CustomException($"Unknown category '{cmd.Category}'.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException($"Unknown category '{cmd.Category}'.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Files.UnknownCategory", + MessageArgs = [cmd.Category], + ResourceSource = typeof(FilesResources), + }; } var extension = Path.GetExtension(cmd.FileName); @@ -48,7 +60,12 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"Extension '{extension}' not allowed for category '{cmd.Category}'.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.ExtensionNotAllowed", + MessageArgs = [extension, cmd.Category], + ResourceSource = typeof(FilesResources), + }; } if (cmd.SizeBytes > category.MaxBytes) @@ -56,15 +73,29 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"File exceeds max size of {category.MaxBytes} bytes for category '{cmd.Category}'.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.FileExceedsMaxSize", + MessageArgs = [category.MaxBytes, cmd.Category], + ResourceSource = typeof(FilesResources), + }; } // Authorization: policy must exist and allow the attach. var policy = policies.Resolve(cmd.OwnerType) - ?? throw new ForbiddenException($"No file access policy registered for owner type '{cmd.OwnerType}'."); + ?? throw new ForbiddenException($"No file access policy registered for owner type '{cmd.OwnerType}'.") + { + MessageKey = "Files.NoPolicyForOwnerType", + MessageArgs = [cmd.OwnerType], + ResourceSource = typeof(FilesResources), + }; if (!await policy.CanAttachAsync(cmd.OwnerId, userId.ToString(), cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("Not allowed to attach files to this owner."); + throw new ForbiddenException("Not allowed to attach files to this owner.") + { + MessageKey = "Files.NotAllowedToAttach", + ResourceSource = typeof(FilesResources), + }; } // Quota pre-check (no debit yet — debit happens on finalize with actual bytes). @@ -74,7 +105,12 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"Storage quota exceeded ({quotaCheck.CurrentUsage}/{quotaCheck.Limit} bytes).", (IEnumerable?)null, - (HttpStatusCode)507); + (HttpStatusCode)507) + { + MessageKey = "Files.StorageQuotaExceeded", + MessageArgs = [quotaCheck.CurrentUsage, quotaCheck.Limit], + ResourceSource = typeof(FilesResources), + }; } // Generate id + storage key + presigned URL. diff --git a/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs index 724cd21e70..561948afce 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Files.Contracts.v1.Commands; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,11 @@ public async ValueTask Handle(RestoreFileCommand cmd, CancellationToken ca .IgnoreQueryFilters() .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; if (!f.IsDeleted) { diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.cs b/src/Modules/Files/Modules.Files/Localization/FilesResources.cs new file mode 100644 index 0000000000..0c66119c6c --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Files.Localization; + +/// Marker type binding IStringLocalizer<FilesResources> to the Files resx catalog. +public sealed class FilesResources; diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx new file mode 100644 index 0000000000..630d451323 --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Não é possível finalizar o arquivo no status {0}. + + + Não é possível alterar a visibilidade enquanto o arquivo está no status {0}. + + + Valor de visibilidade desconhecido '{0}'. + + + Arquivo não encontrado. + + + Nenhuma política de acesso a arquivos registrada. + + + Você não tem permissão para alterar a visibilidade deste arquivo. + + + Você não tem permissão para excluir este arquivo. + + + Este arquivo pendente não pertence a você. + + + O arquivo já foi finalizado. + + + O upload não foi recebido. + + + O tamanho enviado ({0}) excede o tamanho declarado ({1}). + + + O tipo de conteúdo enviado não corresponde. + + + Categoria desconhecida '{0}'. + + + A extensão '{0}' não é permitida para a categoria '{1}'. + + + O arquivo excede o tamanho máximo de {0} bytes para a categoria '{1}'. + + + Nenhuma política de acesso a arquivos registrada para o tipo de proprietário '{0}'. + + + Você não tem permissão para anexar arquivos a este proprietário. + + + Cota de armazenamento excedida ({0}/{1} bytes). + + + A visibilidade deve ser Pública ou Privada. + + + Aguardando envio + + + Disponível + + + Em quarentena + + + Público + + + Privado + + diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.resx b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx new file mode 100644 index 0000000000..f4db13b156 --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cannot finalize file in status {0}. + + + Cannot change visibility while file is in status {0}. + + + Unknown visibility value '{0}'. + + + File not found. + + + No file access policy is registered. + + + You are not allowed to change this file's visibility. + + + You are not allowed to delete this file. + + + This pending file does not belong to you. + + + File is already finalized. + + + Upload was not received. + + + Uploaded size ({0}) exceeds the declared size ({1}). + + + Uploaded content type does not match. + + + Unknown category '{0}'. + + + Extension '{0}' is not allowed for category '{1}'. + + + File exceeds the maximum size of {0} bytes for category '{1}'. + + + No file access policy is registered for owner type '{0}'. + + + You are not allowed to attach files to this owner. + + + Storage quota exceeded ({0}/{1} bytes). + + + Visibility must be Public or Private. + + + Pending upload + + + Available + + + Quarantined + + + Public + + + Private + + diff --git a/src/Modules/Files/Modules.Files/Modules.Files.csproj b/src/Modules/Files/Modules.Files/Modules.Files.csproj index d259693c85..83d34fe5ff 100644 --- a/src/Modules/Files/Modules.Files/Modules.Files.csproj +++ b/src/Modules/Files/Modules.Files/Modules.Files.csproj @@ -3,7 +3,8 @@ FSH.Modules.Files FSH.Modules.Files - $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267;S2094 diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs index 0ccd71384e..343cb17dbb 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs @@ -22,4 +22,7 @@ public class UserDto /// Whether the user has enrolled in TOTP-based two-factor authentication. public bool TwoFactorEnabled { get; set; } + + /// BCP 47 UI language tag (e.g. "pt-BR"); null resolves to the default culture. + public string? Locale { get; set; } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs index f305b4a782..5cc4bbed96 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs @@ -26,7 +26,7 @@ public interface IUserProfileService /// /// Updates a user's profile. /// - Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default); + Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default); /// /// Sets the profile image URL directly (no upload). Used by the presigned-upload flow: diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs index 91ab3467fa..edc323581a 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs @@ -15,7 +15,7 @@ public interface IUserService Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken); Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default); Task RegisterAsync(string firstName, string lastName, string email, string userName, string password, string confirmPassword, string phoneNumber, string origin, CancellationToken cancellationToken); - Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default); + Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default); Task DeleteAsync(string userId, CancellationToken cancellationToken = default); Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken); Task AdminConfirmEmailAsync(string userId, CancellationToken cancellationToken = default); diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs index 09292a46bc..90c7740862 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs @@ -12,4 +12,5 @@ public class UpdateUserCommand : ICommand public string? Email { get; set; } public FileUploadRequest? Image { get; set; } public bool DeleteCurrentImage { get; set; } + public string? Locale { get; set; } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs index 76919c432e..849c04cef3 100644 --- a/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs @@ -1,4 +1,5 @@ using FSH.Framework.Core.Exceptions; +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Constants; using FSH.Modules.Identity.Contracts.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -8,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; @@ -99,14 +101,20 @@ public void Configure(string? name, JwtBearerOptions options) // from "no token at all" — both produce 401 but for very different reasons. bool hadAuthHeader = !string.IsNullOrEmpty(context.HttpContext.Request.Headers.Authorization); + // Resolved per request: UseRequestLocalization runs ahead of UseAuthorization, + // which is where this challenge is emitted, so the negotiated UI culture is already + // in place and this 401 reads in the same language as every other error. + var localizer = context.HttpContext.RequestServices + .GetRequiredService>(); + // RFC 9457 ProblemDetails — matches the contract the rest of the API uses // for error responses (via the global exception handler). var problem = new ProblemDetails { Type = "https://datatracker.ietf.org/doc/html/rfc7235#section-3.1", - Title = "Unauthorized", + Title = localizer["Error.Unauthorized"], Status = StatusCodes.Status401Unauthorized, - Detail = "Authentication is required to access this resource.", + Detail = localizer["Error.AuthenticationRequired"], Instance = context.HttpContext.Request.Path, }; diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs index e13d7842f2..7dc1105f5e 100644 --- a/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs @@ -19,6 +19,13 @@ public void Configure(EntityTypeBuilder builder) builder .Property(u => u.ObjectId) .HasMaxLength(256); + + // A BCP-47 tag is short and bounded; 10 covers language-script-region (zh-Hant-TW). Writes are + // additionally constrained to SupportedCultures.Tags by UpdateUserCommandValidator, so this is + // the storage-level backstop, not the validation. + builder + .Property(u => u.Locale) + .HasMaxLength(10); } } diff --git a/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs b/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs index 270d87fd96..536abe934c 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs @@ -15,6 +15,9 @@ public class FshUser : IdentityUser, IHasDomainEvents public string? RefreshToken { get; set; } public DateTime RefreshTokenExpiryTime { get; set; } + /// BCP 47 UI language tag (e.g. "pt-BR"); null resolves to the default culture. + public string? Locale { get; set; } + public string? ObjectId { get; set; } /// Timestamp when the user last changed their password diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs index feb1a05203..fc8a3185a9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -32,7 +33,12 @@ public async ValueTask Handle(AddUsersToGroupCommand co if (!groupExists) { - throw new NotFoundException($"Group with ID '{command.GroupId}' not found."); + throw new NotFoundException($"Group with ID '{command.GroupId}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [command.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Validate user IDs exist @@ -44,7 +50,12 @@ public async ValueTask Handle(AddUsersToGroupCommand co var invalidUserIds = command.UserIds.Except(existingUserIds).ToList(); if (invalidUserIds.Count > 0) { - throw new NotFoundException($"Users not found: {string.Join(", ", invalidUserIds)}"); + throw new NotFoundException($"Users not found: {string.Join(", ", invalidUserIds)}") + { + MessageKey = "Identity.UsersNotFound", + MessageArgs = [string.Join(", ", invalidUserIds)], + ResourceSource = typeof(IdentityResources), + }; } // Get existing memberships diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs index e90a9a564d..6c15fbcb9f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs @@ -1,18 +1,20 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; public sealed class AddUsersToGroupCommandValidator : AbstractValidator { - public AddUsersToGroupCommandValidator() + public AddUsersToGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.UserIds) - .NotEmpty().WithMessage("At least one user ID is required.") + .NotEmpty().WithMessage(_ => localizer["Validation.AtLeastOneUserIdRequired"]) .Must(ids => ids.All(id => !string.IsNullOrWhiteSpace(id))) - .WithMessage("User IDs cannot be empty or whitespace."); + .WithMessage(_ => localizer["Validation.UserIdsNotEmptyOrWhitespace"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs index d0e1493737..c933d7512b 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -30,7 +31,12 @@ public async ValueTask Handle(CreateGroupCommand command, Cancellation if (nameExists) { - throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict) + { + MessageKey = "Identity.GroupNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(IdentityResources), + }; } // Validate role IDs exist — fetch Id+Name in a single query to avoid a second roundtrip later @@ -46,7 +52,12 @@ public async ValueTask Handle(CreateGroupCommand command, Cancellation var invalidRoleIds = command.RoleIds.Except(resolvedRoles.Select(r => r.Id)).ToList(); if (invalidRoleIds.Count > 0) { - throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}") + { + MessageKey = "Identity.RolesNotFoundWithIds", + MessageArgs = [string.Join(", ", invalidRoleIds)], + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs index 42a8dd668e..f112b51976 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; public sealed class CreateGroupCommandValidator : AbstractValidator { - public CreateGroupCommandValidator() + public CreateGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupNameRequired"]) + .MaximumLength(256).WithMessage(_ => localizer["Validation.GroupNameMaxLength"]); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(_ => localizer["Validation.DescriptionMaxLength"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs index 37626d12d4..3143680330 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,11 +28,20 @@ public async ValueTask Handle(DeleteGroupCommand command, CancellationToke var group = await _dbContext.Groups .FirstOrDefaultAsync(g => g.Id == command.Id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{command.Id}' not found."); + ?? throw new NotFoundException($"Group with ID '{command.Id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(IdentityResources), + }; if (group.IsSystemGroup) { - throw new ForbiddenException("System groups cannot be deleted."); + throw new ForbiddenException("System groups cannot be deleted.") + { + MessageKey = "Identity.SystemGroupsCannotBeDeleted", + ResourceSource = typeof(IdentityResources), + }; } // Snapshot members before delete; soft-delete flips IsDeleted but membership rows diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs index 4805b8769a..4d2834c5a7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; public sealed class DeleteGroupCommandValidator : AbstractValidator { - public DeleteGroupCommandValidator() + public DeleteGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs index bc29827213..3f6683ae62 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,12 @@ public async ValueTask Handle(GetGroupByIdQuery query, CancellationTok .AsNoTracking() .Include(g => g.GroupRoles) .FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{query.Id}' not found."); + ?? throw new NotFoundException($"Group with ID '{query.Id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [query.Id], + ResourceSource = typeof(IdentityResources), + }; var memberCount = await _dbContext.UserGroups .AsNoTracking() diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs index a8689ceb7b..1de6594088 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupMembers; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask> Handle(GetGroupMembersQuery if (!groupExists) { - throw new NotFoundException($"Group with ID '{query.GroupId}' not found."); + throw new NotFoundException($"Group with ID '{query.GroupId}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [query.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Get memberships with user info diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs index e10d1eee9c..21e614d0ab 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -28,14 +29,23 @@ public async ValueTask Handle(RemoveUserFromGroupCommand command, Cancella if (membership is null) { - throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'."); + throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'.") + { + MessageKey = "Identity.UserNotMemberOfGroup", + MessageArgs = [command.UserId, command.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Default groups (e.g. seeded "All Users") require every tenant user to be a member, so // removing one breaks that invariant and leaves later registrants in a half-populated group. if (membership.Group is not null && membership.Group.IsDefault) { - throw new ForbiddenException("Users cannot be removed from a default group."); + throw new ForbiddenException("Users cannot be removed from a default group.") + { + MessageKey = "Identity.CannotRemoveFromDefaultGroup", + ResourceSource = typeof(IdentityResources), + }; } _dbContext.UserGroups.Remove(membership); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs index da5ce2bd3d..418b3210ff 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; public sealed class RemoveUserFromGroupCommandValidator : AbstractValidator { - public RemoveUserFromGroupCommandValidator() + public RemoveUserFromGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs index 37d6e00c1a..f8c7b331d9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -33,7 +34,11 @@ public async ValueTask Handle(UpdateGroupCommand command, Cancellation // assignments are all part of the seed contract that the startup syncer relies on. if (group.IsSystemGroup) { - throw new ForbiddenException("System groups cannot be modified."); + throw new ForbiddenException("System groups cannot be modified.") + { + MessageKey = "Identity.SystemGroupsCannotBeModified", + ResourceSource = typeof(IdentityResources), + }; } await ValidateUniqueNameAsync(command.Id, command.Name, cancellationToken); @@ -69,7 +74,12 @@ private async Task GetGroupAsync(Guid id, CancellationToken cancellationT return await _dbContext.Groups .Include(g => g.GroupRoles) .FirstOrDefaultAsync(g => g.Id == id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{id}' not found."); + ?? throw new NotFoundException($"Group with ID '{id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [id], + ResourceSource = typeof(IdentityResources), + }; } private async Task ValidateUniqueNameAsync(Guid excludeId, string name, CancellationToken cancellationToken) @@ -79,7 +89,12 @@ private async Task ValidateUniqueNameAsync(Guid excludeId, string name, Cancella if (nameExists) { - throw new CustomException($"Group with name '{name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + throw new CustomException($"Group with name '{name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict) + { + MessageKey = "Identity.GroupNameAlreadyExists", + MessageArgs = [name], + ResourceSource = typeof(IdentityResources), + }; } } @@ -98,7 +113,12 @@ private async Task ValidateRoleIdsAsync(IReadOnlyList? roleIds, Cancella var invalidRoleIds = roleIds.Except(existingRoleIds).ToList(); if (invalidRoleIds.Count > 0) { - throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}") + { + MessageKey = "Identity.RolesNotFoundWithIds", + MessageArgs = [string.Join(", ", invalidRoleIds)], + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs index 4c111e0c05..3442077ddc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs @@ -1,20 +1,22 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; public sealed class UpdateGroupCommandValidator : AbstractValidator { - public UpdateGroupCommandValidator() + public UpdateGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupNameRequired"]) + .MaximumLength(256).WithMessage(_ => localizer["Validation.GroupNameMaxLength"]); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(_ => localizer["Validation.DescriptionMaxLength"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs index d731e131fb..08509554e3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Impersonation.EndImpersonation; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -66,7 +67,11 @@ public async ValueTask Handle( throw new CustomException( "current session is not an impersonation session", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.NotAnImpersonationSession", + ResourceSource = typeof(IdentityResources), + }; } var impersonatedUserId = _currentUser.GetUserId().ToString(); @@ -93,7 +98,11 @@ public async ValueTask Handle( if (actorClaimsResult is null) { - throw new NotFoundException("original actor not found"); + throw new NotFoundException("original actor not found") + { + MessageKey = "Identity.OriginalActorNotFound", + ResourceSource = typeof(IdentityResources), + }; } var (subject, actorClaims) = actorClaimsResult.Value; diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs index 65b8377a41..20e4b57dfe 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask> Handle( ArgumentNullException.ThrowIfNull(request); var callerTenant = currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var isRoot = string.Equals(callerTenant, MultitenancyConstants.Root.Id, StringComparison.Ordinal); // Tenant scoping: root operators target any tenant; tenant admins are locked to their diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs index 912dff296d..8dd4bac96f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Impersonation.GetImpersonationGrants; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Impersonation.GetImpersonationGrants; @@ -7,11 +9,11 @@ public sealed class GetImpersonationGrantsQueryValidator : AbstractValidator localizer) { RuleFor(q => q.Take) .GreaterThan(0) .LessThanOrEqualTo(MaxTake) - .WithMessage($"Take must be between 1 and {MaxTake}."); + .WithMessage(_ => localizer["Validation.ImpersonationTakeRange", MaxTake]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs index 9dd7a8a24a..aaa859e5f0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Impersonation; using FSH.Modules.Identity.Contracts.v1.Impersonation.RevokeImpersonationGrant; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; @@ -31,20 +32,31 @@ public async ValueTask Handle( var callerUserId = currentUser.GetUserId().ToString(); var callerTenantId = currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var isRoot = string.Equals(callerTenantId, MultitenancyConstants.Root.Id, StringComparison.Ordinal); // Enforce visibility before revoking: tenant admins may only revoke grants in their own // tenant. Cross-tenant grants return 404 (not 403) so existence isn't confirmed out of scope. var grant = await grantService.GetByIdAsync(request.GrantId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException("impersonation grant not found"); + ?? throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; var withinTenant = string.Equals(grant.ImpersonatedTenantId, callerTenantId, StringComparison.Ordinal) || string.Equals(grant.ActorTenantId, callerTenantId, StringComparison.Ordinal); if (!isRoot && !withinTenant) { - throw new NotFoundException("impersonation grant not found"); + throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; } var updated = await grantService.RevokeAsync( diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs index c5a4288fc0..4876de715f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs @@ -6,6 +6,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Impersonation; using FSH.Modules.Identity.Contracts.v1.Impersonation.StartImpersonation; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -58,7 +59,10 @@ public async ValueTask Handle( var actorUserId = _currentUser.GetUserId().ToString(); var actorTenantId = _currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var actorUserName = _currentUser.Name; // Cross-tenant impersonation requires the actor to be in the root tenant. Tenant admins @@ -66,7 +70,11 @@ public async ValueTask Handle( if (!string.Equals(actorTenantId, MultitenancyConstants.Root.Id, StringComparison.Ordinal) && !string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal)) { - throw new ForbiddenException("cross-tenant impersonation is restricted to platform operators"); + throw new ForbiddenException("cross-tenant impersonation is restricted to platform operators") + { + MessageKey = "Identity.CrossTenantImpersonationRestricted", + ResourceSource = typeof(IdentityResources), + }; } // Prevent self-impersonation (pointless, confuses the audit trail). Caller error → explicit 4xx, @@ -74,7 +82,11 @@ public async ValueTask Handle( if (string.Equals(actorUserId, request.TargetUserId, StringComparison.Ordinal) && string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal)) { - throw new CustomException("cannot impersonate yourself", errors: null, System.Net.HttpStatusCode.BadRequest); + throw new CustomException("cannot impersonate yourself", errors: null, System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.CannotImpersonateYourself", + ResourceSource = typeof(IdentityResources), + }; } // Prevent nesting: if the caller is already impersonating, require end-impersonation first. @@ -85,7 +97,11 @@ public async ValueTask Handle( throw new CustomException( "end current impersonation before starting a new one", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.EndImpersonationFirst", + ResourceSource = typeof(IdentityResources), + }; } var targetClaimsResult = await _identityService @@ -93,7 +109,11 @@ public async ValueTask Handle( if (targetClaimsResult is null) { - throw new NotFoundException("target user not found"); + throw new NotFoundException("target user not found") + { + MessageKey = "Identity.TargetUserNotFound", + ResourceSource = typeof(IdentityResources), + }; } var (subject, claims) = targetClaimsResult.Value; @@ -104,7 +124,9 @@ public async ValueTask Handle( // ImpersonationGrant row and the issued JWT share the same jti. var jti = Guid.NewGuid().ToString("N"); var impersonationClaims = claims - .Where(c => c.Type != JwtRegisteredClaimNames.Jti) + // Drop the target's locale: language is a presentation concern, so the operator reads in + // THEIR own language (falls through to Accept-Language), not the impersonated user's. + .Where(c => c.Type != JwtRegisteredClaimNames.Jti && c.Type != "locale") .Concat( [ new Claim(JwtRegisteredClaimNames.Jti, jti), diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs index 69647afe7a..6a2f8cafd3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Impersonation.StartImpersonation; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Impersonation.StartImpersonation; @@ -12,7 +14,7 @@ public sealed class StartImpersonationCommandValidator : AbstractValidator public const int MaxImpersonationMinutes = 60; - public StartImpersonationCommandValidator() + public StartImpersonationCommandValidator(IStringLocalizer localizer) { RuleFor(p => p.TargetUserId) .Cascade(CascadeMode.Stop) @@ -25,7 +27,7 @@ public StartImpersonationCommandValidator() RuleFor(p => p.DurationMinutes!.Value) .GreaterThan(0) .LessThanOrEqualTo(MaxImpersonationMinutes) - .WithMessage($"Duration must be between 1 and {MaxImpersonationMinutes} minutes.") + .WithMessage(_ => localizer["Validation.ImpersonationDurationRange", MaxImpersonationMinutes]) .When(p => p.DurationMinutes.HasValue); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs index bf213c86a5..a015186dbc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.DeleteRole; public sealed class DeleteRoleCommandValidator : AbstractValidator { - public DeleteRoleCommandValidator() + public DeleteRoleCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Role ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.RoleIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs index 183ad47f34..5a51837f1a 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.GetRoles; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.GetRoles; public sealed class GetRolesQueryValidator : AbstractValidator { - public GetRolesQueryValidator() + public GetRolesQueryValidator(IStringLocalizer localizer) { RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(x => x.PageSize) - .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageSizeMinimum"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs index 70fb1e8cc6..6083ff757e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs @@ -9,6 +9,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -57,10 +58,18 @@ public async Task> GetRolesAsync( CancellationToken cancellationToken = default) { if (roleManager is null) - throw new NotFoundException("RoleManager not resolved. Check Identity registration."); + throw new NotFoundException("RoleManager not resolved. Check Identity registration.") + { + MessageKey = "Identity.RoleManagerNotResolved", + ResourceSource = typeof(IdentityResources), + }; if (roleManager.Roles is null) - throw new NotFoundException("Role store not configured. Ensure .AddRoles() and EF stores."); + throw new NotFoundException("Role store not configured. Ensure .AddRoles() and EF stores.") + { + MessageKey = "Identity.RoleStoreNotConfigured", + ResourceSource = typeof(IdentityResources), + }; var page = Math.Max(1, pageNumber); var size = Math.Clamp(pageSize, 1, 200); @@ -97,7 +106,11 @@ public async Task> GetRolesAsync( { FshRole? role = await roleManager.FindByIdAsync(id); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; } @@ -111,9 +124,9 @@ public async Task CreateOrUpdateRoleAsync(string roleId, string name, s if (role != null) { // System roles cannot be modified — neither renamed nor re-described. - EnsureNotSystemRole(role.Name, "System roles cannot be modified."); + EnsureNotSystemRole(role.Name, "System roles cannot be modified.", "Identity.SystemRoleCannotBeModified"); // And no custom role can be renamed to a system role's name. - EnsureNotSystemRole(name, "Cannot rename a role to a system role's name."); + EnsureNotSystemRole(name, "Cannot rename a role to a system role's name.", "Identity.CannotRenameToSystemRole"); role.Name = name; role.Description = description; @@ -122,7 +135,7 @@ public async Task CreateOrUpdateRoleAsync(string roleId, string name, s else { // No new role can be created using a system role's name. - EnsureNotSystemRole(name, "Cannot create a role using a system role's name."); + EnsureNotSystemRole(name, "Cannot create a role using a system role's name.", "Identity.CannotCreateWithSystemRoleName"); role = new FshRole(name, description); await roleManager.CreateAsync(role); @@ -135,9 +148,13 @@ public async Task DeleteRoleAsync(string id, CancellationToken cancellationToken { FshRole? role = await roleManager.FindByIdAsync(id); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; - EnsureNotSystemRole(role.Name, "System roles cannot be deleted."); + EnsureNotSystemRole(role.Name, "System roles cannot be deleted.", "Identity.SystemRolesCannotBeDeleted"); // Snapshot affected users BEFORE the cascade removes the role-mapping rows, // otherwise the lookup returns an empty set after delete. @@ -149,7 +166,11 @@ public async Task DeleteRoleAsync(string id, CancellationToken cancellationToken public async Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken = default) { var role = await GetRoleAsync(id, cancellationToken); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; role.Permissions = await context.RoleClaims .AsNoTracking() @@ -165,9 +186,13 @@ public async Task UpdatePermissionsAsync(string roleId, List per ArgumentNullException.ThrowIfNull(permissions); var role = await roleManager.FindByIdAsync(roleId) - ?? throw new NotFoundException("role not found"); + ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; - EnsureNotSystemRole(role.Name, "System role permissions are managed by the framework and cannot be modified."); + EnsureNotSystemRole(role.Name, "System role permissions are managed by the framework and cannot be modified.", "Identity.SystemRolePermissionsManaged"); FilterRootPermissions(permissions); var currentClaims = await roleManager.GetClaimsAsync(role); @@ -181,11 +206,15 @@ public async Task UpdatePermissionsAsync(string roleId, List per return "permissions updated"; } - private static void EnsureNotSystemRole(string? roleName, string message) + private static void EnsureNotSystemRole(string? roleName, string message, string messageKey) { if (!string.IsNullOrEmpty(roleName) && RoleConstants.IsDefault(roleName)) { - throw new CustomException(message, Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException(message, Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = messageKey, + ResourceSource = typeof(IdentityResources), + }; } } @@ -214,7 +243,11 @@ private async Task RemoveRevokedPermissionsAsync(FshRole role, IList error.Description).ToList(); - throw new CustomException("operation failed", errors); + throw new CustomException("operation failed", errors) + { + MessageKey = "Identity.OperationFailed", + ResourceSource = typeof(IdentityResources), + }; } } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs index e206420a88..94f45bd2c2 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs @@ -1,12 +1,14 @@ -using FluentValidation; +using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; public sealed class UpsertRoleCommandValidator : AbstractValidator { - public UpsertRoleCommandValidator() + public UpsertRoleCommandValidator(IStringLocalizer localizer) { - RuleFor(x => x.Name).NotEmpty().WithMessage("Role name is required."); + RuleFor(x => x.Name).NotEmpty().WithMessage(_ => localizer["Validation.RoleNameRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs index f9b8449cb6..3e642608fb 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; public sealed class AdminRevokeAllSessionsCommandValidator : AbstractValidator { - public AdminRevokeAllSessionsCommandValidator() + public AdminRevokeAllSessionsCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(_ => localizer["Validation.ReasonMaxLength"]) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs index 8e59cb4ec9..f4edf82bbf 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs @@ -1,20 +1,22 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; public sealed class AdminRevokeSessionCommandValidator : AbstractValidator { - public AdminRevokeSessionCommandValidator() + public AdminRevokeSessionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.SessionIdRequired"]); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(_ => localizer["Validation.ReasonMaxLength"]) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs index 06b3786ba8..95bbf174d7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.GetTenantSessions; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.GetTenantSessions; public sealed class GetTenantSessionsValidator : AbstractValidator { - public GetTenantSessionsValidator() + public GetTenantSessionsValidator(IStringLocalizer localizer) { RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(x => x.PageSize) - .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageSizeMinimum"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs index c0ceb12b33..d5e79ba14d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; public sealed class RevokeSessionCommandValidator : AbstractValidator { - public RevokeSessionCommandValidator() + public RevokeSessionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.SessionIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs index dd66630e31..543fe28a71 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -51,7 +52,11 @@ public async ValueTask Handle( if (validated is null) { await _securityAudit.TokenRevokedAsync("unknown", clientId!, "InvalidRefreshToken", cancellationToken); - throw new UnauthorizedException("Invalid refresh token."); + throw new UnauthorizedException("Invalid refresh token.") + { + MessageKey = "Identity.InvalidRefreshToken", + ResourceSource = typeof(IdentityResources), + }; } var (subject, claims) = validated.Value; @@ -62,7 +67,11 @@ public async ValueTask Handle( if (!isSessionValid) { await _securityAudit.TokenRevokedAsync(subject, clientId!, "SessionRevoked", cancellationToken); - throw new UnauthorizedException("Session has been revoked."); + throw new UnauthorizedException("Session has been revoked.") + { + MessageKey = "Identity.SessionRevoked", + ResourceSource = typeof(IdentityResources), + }; } // Optionally, cross-check the provided access token subject @@ -87,7 +96,11 @@ public async ValueTask Handle( !string.Equals(accessTokenSubject, subject, StringComparison.Ordinal)) { await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenSubjectMismatch", cancellationToken); - throw new UnauthorizedException("Access token subject mismatch."); + throw new UnauthorizedException("Access token subject mismatch.") + { + MessageKey = "Identity.AccessTokenSubjectMismatch", + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs index 24bffb7030..3b90223383 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs @@ -1,5 +1,6 @@ using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; using FSH.Framework.Eventing.Outbox; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Auditing.Contracts; @@ -7,6 +8,7 @@ using FSH.Modules.Identity.Contracts.Events; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.Security.Claims; @@ -70,7 +72,11 @@ await _securityAudit.LoginFailedAsync( ip: ip, ct: cancellationToken); - throw new UnauthorizedAccessException("Invalid credentials."); + throw new LocalizedUnauthorizedAccessException("Invalid credentials.") + { + MessageKey = "Identity.InvalidCredentials", + ResourceSource = typeof(IdentityResources), + }; } // Unpack subject + claims diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs index 4d7bd0dd67..11601944e0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; @@ -8,6 +9,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; using System.ComponentModel; namespace FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; @@ -35,14 +37,15 @@ [AllowAnonymous] async Task, UnauthorizedHttpResult, P [DefaultValue("root")][FromHeader] string tenant, [FromHeader(Name = AppHeader)] string? app, [FromServices] IMediator mediator, + [FromServices] IStringLocalizer localizer, CancellationToken ct) => { if (IsRootViaDashboard(tenant, app)) { return TypedResults.Problem( statusCode: StatusCodes.Status403Forbidden, - title: "App boundary", - detail: "SuperAdmin accounts must use the admin app. Sign in there instead of the tenant dashboard."); + title: localizer["Error.AppBoundary"], + detail: localizer["Error.AppBoundary.Detail"]); } var token = await mediator.Send(command, ct); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs index fa7c7ff9b4..0f000f2fcd 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -31,13 +32,22 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Require current password so a stolen access token alone can't downgrade // account security. if (!await _userManager.CheckPasswordAsync(user, command.CurrentPassword)) { - throw new UnauthorizedException("Current password is incorrect."); + throw new UnauthorizedException("Current password is incorrect.") + { + MessageKey = "Identity.CurrentPasswordIncorrect", + ResourceSource = typeof(IdentityResources), + }; } await _userManager.SetTwoFactorEnabledAsync(user, false); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs index 2a95e8678c..39ccfdf2f0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -35,13 +36,22 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Always reset so calling enroll twice rotates the secret — prevents stale codes // from a prior incomplete enrollment from silently succeeding. await _userManager.ResetAuthenticatorKeyAsync(user); var sharedKey = await _userManager.GetAuthenticatorKeyAsync(user) - ?? throw new CustomException("Failed to generate authenticator key."); + ?? throw new CustomException("Failed to generate authenticator key.") + { + MessageKey = "Identity.FailedToGenerateAuthenticatorKey", + ResourceSource = typeof(IdentityResources), + }; var email = user.Email ?? user.UserName ?? user.Id; var authenticatorUri = string.Format( diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs index edc6a52ac9..151ed497c6 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -31,7 +32,12 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; var sanitized = command.Code.Replace(" ", string.Empty, StringComparison.Ordinal); var valid = await _userManager.VerifyTwoFactorTokenAsync( @@ -44,7 +50,11 @@ public async ValueTask Handle( throw new CustomException( "The authenticator code is invalid.", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AuthenticatorCodeInvalid", + ResourceSource = typeof(IdentityResources), + }; } await _userManager.SetTwoFactorEnabledAsync(user, true); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs index 71523b2f76..2c606b98b3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.AdminConfirmEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.AdminConfirmEmail; public sealed class AdminConfirmEmailCommandValidator : AbstractValidator { - public AdminConfirmEmailCommandValidator() + public AdminConfirmEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs index 703d43d398..500c4242b4 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.AssignUserRoles; public sealed class AssignUserRolesCommandValidator : AbstractValidator { - public AssignUserRolesCommandValidator() + public AssignUserRolesCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.UserRoles) - .NotNull().WithMessage("User roles list is required."); + .NotNull().WithMessage(_ => localizer["Validation.UserRolesRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs index 0581ae1878..6563029bb8 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs @@ -1,7 +1,9 @@ using FluentValidation; using FSH.Framework.Core.Context; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ChangePassword; @@ -12,26 +14,27 @@ public sealed class ChangePasswordValidator : AbstractValidator localizer) { _passwordHistoryService = passwordHistoryService; _currentUser = currentUser; RuleFor(p => p.Password) .NotEmpty() - .WithMessage("Current password is required."); + .WithMessage(_ => localizer["Validation.CurrentPasswordRequired"]); RuleFor(p => p.NewPassword) .NotEmpty() - .WithMessage("New password is required.") + .WithMessage(_ => localizer["Validation.NewPasswordRequired"]) .NotEqual(p => p.Password) - .WithMessage("New password must be different from the current password.") + .WithMessage(_ => localizer["Validation.NewPasswordMustDiffer"]) .MustAsync(NotBeInPasswordHistoryAsync) - .WithMessage("This password has been used recently. Please choose a different password."); + .WithMessage(_ => localizer["Validation.PasswordRecentlyUsed"]); RuleFor(p => p.ConfirmNewPassword) .Equal(p => p.NewPassword) - .WithMessage("Passwords do not match."); + .WithMessage(_ => localizer["Validation.PasswordsDoNotMatch"]); } private async Task NotBeInPasswordHistoryAsync(string newPassword, CancellationToken cancellationToken) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs index 54805a491b..d8b599e9fe 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs @@ -1,19 +1,21 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; public sealed class ConfirmEmailCommandValidator : AbstractValidator { - public ConfirmEmailCommandValidator() + public ConfirmEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.Code) - .NotEmpty().WithMessage("Confirmation code is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.ConfirmationCodeRequired"]); RuleFor(x => x.Tenant) - .NotEmpty().WithMessage("Tenant is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.TenantRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs index f5410d84ac..f73908b372 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.DeleteUser; public sealed class DeleteUserCommandValidator : AbstractValidator { - public DeleteUserCommandValidator() + public DeleteUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs index a7d6704751..1d833cf798 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Users.GetUserGroups; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask> Handle(GetUserGroupsQuery query, C if (!userExists) { - throw new NotFoundException($"User with ID '{query.UserId}' not found."); + throw new NotFoundException($"User with ID '{query.UserId}' not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [query.UserId], + ResourceSource = typeof(IdentityResources), + }; } // Get user's groups diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs index 54d774b2da..f52db83b99 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs @@ -1,39 +1,41 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.RegisterUser; public sealed class RegisterUserCommandValidator : AbstractValidator { - public RegisterUserCommandValidator() + public RegisterUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.FirstName) - .NotEmpty().WithMessage("First name is required.") - .MaximumLength(100).WithMessage("First name must not exceed 100 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.FirstNameRequired"]) + .MaximumLength(100).WithMessage(_ => localizer["Validation.FirstNameMaxLength"]); RuleFor(x => x.LastName) - .NotEmpty().WithMessage("Last name is required.") - .MaximumLength(100).WithMessage("Last name must not exceed 100 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.LastNameRequired"]) + .MaximumLength(100).WithMessage(_ => localizer["Validation.LastNameMaxLength"]); RuleFor(x => x.Email) - .NotEmpty().WithMessage("Email is required.") - .EmailAddress().WithMessage("A valid email address is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.EmailRequired"]) + .EmailAddress().WithMessage(_ => localizer["Validation.EmailInvalid"]); RuleFor(x => x.UserName) - .NotEmpty().WithMessage("Username is required.") - .MinimumLength(3).WithMessage("Username must be at least 3 characters.") - .MaximumLength(50).WithMessage("Username must not exceed 50 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.UsernameRequired"]) + .MinimumLength(3).WithMessage(_ => localizer["Validation.UsernameMinLength"]) + .MaximumLength(50).WithMessage(_ => localizer["Validation.UsernameMaxLength"]); RuleFor(x => x.Password) - .NotEmpty().WithMessage("Password is required.") - .MinimumLength(6).WithMessage("Password must be at least 6 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.PasswordRequired"]) + .MinimumLength(6).WithMessage(_ => localizer["Validation.PasswordMinLength"]); RuleFor(x => x.ConfirmPassword) - .NotEmpty().WithMessage("Password confirmation is required.") - .Equal(x => x.Password).WithMessage("Passwords do not match."); + .NotEmpty().WithMessage(_ => localizer["Validation.PasswordConfirmationRequired"]) + .Equal(x => x.Password).WithMessage(_ => localizer["Validation.PasswordsDoNotMatch"]); RuleFor(x => x.PhoneNumber) - .MaximumLength(20).WithMessage("Phone number must not exceed 20 characters.") + .MaximumLength(20).WithMessage(_ => localizer["Validation.PhoneNumberMaxLength"]) .When(x => x.PhoneNumber is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs index 34d29f7b1b..28cd866e23 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ResendConfirmationEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ResendConfirmationEmail; public sealed class ResendConfirmationEmailCommandValidator : AbstractValidator { - public ResendConfirmationEmailCommandValidator() + public ResendConfirmationEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs index 7069e6f653..a5328bc3d5 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.SearchUsers; public sealed class SearchUsersQueryValidator : AbstractValidator { - public SearchUsersQueryValidator() + public SearchUsersQueryValidator(IStringLocalizer localizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); RuleFor(q => q.Search) .MaximumLength(200) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs index 236b76e778..36a9fc6304 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs @@ -18,7 +18,10 @@ public async ValueTask Handle(SetProfileImageCommand command, Cancellation var userId = currentUser.GetUserId(); if (userId == Guid.Empty) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } await profileService diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs index 4eece88de2..2df3f36a54 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; public sealed class ToggleUserStatusCommandValidator : AbstractValidator { - public ToggleUserStatusCommandValidator() + public ToggleUserStatusCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs index 9b6608e03a..64772a10a5 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -24,6 +24,7 @@ await _userService.UpdateAsync( command.PhoneNumber ?? string.Empty, command.Image!, command.DeleteCurrentImage, + command.Locale, cancellationToken).ConfigureAwait(false); return Unit.Value; diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs index 6fc722d9d9..b8b4ef32ac 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -1,16 +1,18 @@ -using FluentValidation; +using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Storage; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.UpdateUser; public sealed class UpdateUserCommandValidator : AbstractValidator { - public UpdateUserCommandValidator() + public UpdateUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) .NotEmpty() - .WithMessage("User ID is required."); + .WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.FirstName) .MaximumLength(50) @@ -31,12 +33,17 @@ public UpdateUserCommandValidator() When(x => x.Image is not null, () => { RuleFor(x => x.Image!) - .SetValidator(new UserImageValidator(FileType.Image)); + .SetValidator(new UserImageValidator(FileType.Image, localizer)); }); // Prevent deleting and uploading image at the same time RuleFor(x => x) .Must(x => !(x.DeleteCurrentImage && x.Image is not null)) - .WithMessage("You cannot upload a new image and delete the current one simultaneously."); + .WithMessage(_ => localizer["Validation.ImageUploadDeleteConflict"]); + + RuleFor(x => x.Locale) + .Must(l => SupportedCultures.Tags.Contains(l!)) + .When(x => !string.IsNullOrWhiteSpace(x.Locale)) + .WithMessage(_ => localizer["Validation.UnsupportedLocale"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs index 283bfae87b..faf930994e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs @@ -1,24 +1,26 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Storage; using FSH.Framework.Storage; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users; public sealed class UserImageValidator : AbstractValidator { - public UserImageValidator() : this(FileType.Image) { } - public UserImageValidator(FileType fileType) + public UserImageValidator(IStringLocalizer localizer) : this(FileType.Image, localizer) { } + public UserImageValidator(FileType fileType, IStringLocalizer localizer) { var rules = FileTypeMetadata.GetRules(fileType); RuleFor(x => x.FileName) .NotEmpty() .Must(file => rules.AllowedExtensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) - .WithMessage($"Only these extensions are allowed: {string.Join(", ", rules.AllowedExtensions)}"); + .WithMessage(_ => localizer["Validation.AllowedExtensions", string.Join(", ", rules.AllowedExtensions)]); RuleFor(x => x.Data) .NotEmpty() .Must(data => data.Count <= rules.MaxSizeInMB * 1024 * 1024) - .WithMessage($"File must be <= {rules.MaxSizeInMB} MB."); + .WithMessage(_ => localizer["Validation.MaxFileSize", rules.MaxSizeInMB]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs new file mode 100644 index 0000000000..880bb0749f --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Identity.Localization; + +/// Marker type binding IStringLocalizer<IdentityResources> to the Identity resx catalog. +public sealed class IdentityResources; diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx new file mode 100644 index 0000000000..c9e3c5d814 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Grupo com ID '{0}' não encontrado. + + + Usuários não encontrados: {0} + + + Já existe um grupo com o nome '{0}'. + + + Funções não encontradas: {0} + + + Grupos do sistema não podem ser excluídos. + + + O usuário '{0}' não é membro do grupo '{1}'. + + + Usuários não podem ser removidos de um grupo padrão. + + + Grupos do sistema não podem ser modificados. + + + Função não encontrada. + + + Funções não encontradas. + + + Funções do sistema não podem ser modificadas. + + + Não é possível renomear uma função para o nome de uma função do sistema. + + + Não é possível criar uma função usando o nome de uma função do sistema. + + + Funções do sistema não podem ser excluídas. + + + As permissões de funções do sistema são gerenciadas pelo framework e não podem ser modificadas. + + + A operação falhou. + + + A sessão atual não é uma sessão de personificação. + + + Ator original não encontrado. + + + Concessão de personificação não encontrada. + + + A personificação entre tenants é restrita a operadores da plataforma. + + + Não é possível personificar a si mesmo. + + + Encerre a personificação atual antes de iniciar uma nova. + + + Usuário alvo não encontrado. + + + Usuário não encontrado. + + + Usuário {0} não encontrado. + + + A senha atual está incorreta. + + + Falha ao gerar a chave do autenticador. + + + O código do autenticador é inválido. + + + Token de atualização inválido. + + + A sessão foi revogada. + + + O assunto do token de acesso não corresponde. + + + two_factor_required: É necessário um código de autenticador para concluir o login. + + + two_factor_invalid: O código do autenticador é inválido ou expirou. + + + A conta está temporariamente bloqueada devido a muitas tentativas de login malsucedidas. Tente novamente mais tarde. + + + O token de atualização é inválido ou expirou. + + + O usuário está desativado. + + + E-mail não confirmado. + + + O tenant {0} está desativado. + + + A validade do tenant {0} expirou. + + + Erro ao redefinir a senha. + + + Falha ao alterar a senha. + + + Falha ao atualizar o perfil. + + + Falha ao atualizar a imagem do perfil. + + + Ocorreu um erro ao confirmar o e-mail. + + + Ocorreu um erro ao confirmar {0}. + + + Ocorreu um erro ao confirmar o e-mail de {0}: {1} + + + O e-mail de {0} já está confirmado. + + + Ocorreu um erro ao confirmar o número de telefone. + + + Ocorreu um erro ao confirmar o número de telefone {0}. + + + A claim de e-mail é obrigatória para autenticação externa. + + + Falha ao criar o usuário a partir do principal externo. + + + As senhas não coincidem. + + + Não foi possível registrar o usuário. + + + Administradores não podem remover a própria função de administrador. + + + O administrador do tenant raiz não pode ser rebaixado. + + + O tenant deve manter pelo menos um administrador. + + + Apenas administradores podem alterar o status do usuário. + + + Usuários não podem desativar a si mesmos. + + + Administradores não podem ser desativados. + + + O tenant deve ter pelo menos um administrador ativo. + + + Falha ao alternar o status. + + + Credenciais inválidas. + + + Não é possível visualizar sessões de outro usuário + + + Não é possível revogar a sessão de outro usuário + + + Não é possível revogar sessões de outro usuário + + + RoleManager<FshRole> não resolvido. Verifique o registro do Identity. + + + Repositório de funções não configurado. Garanta .AddRoles<FshRole>() e os stores do EF. + + + Método reservado para inicialização em escopo. + + diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx new file mode 100644 index 0000000000..5cc85fa437 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Group with ID '{0}' not found. + + + Users not found: {0} + + + Group with name '{0}' already exists. + + + Roles not found: {0} + + + System groups cannot be deleted. + + + User '{0}' is not a member of group '{1}'. + + + Users cannot be removed from a default group. + + + System groups cannot be modified. + + + Role not found. + + + Roles not found. + + + System roles cannot be modified. + + + Cannot rename a role to a system role's name. + + + Cannot create a role using a system role's name. + + + System roles cannot be deleted. + + + System role permissions are managed by the framework and cannot be modified. + + + Operation failed. + + + Current session is not an impersonation session. + + + Original actor not found. + + + Impersonation grant not found. + + + Cross-tenant impersonation is restricted to platform operators. + + + Cannot impersonate yourself. + + + End current impersonation before starting a new one. + + + Target user not found. + + + User not found. + + + User {0} not found. + + + Current password is incorrect. + + + Failed to generate authenticator key. + + + The authenticator code is invalid. + + + Invalid refresh token. + + + Session has been revoked. + + + Access token subject mismatch. + + + two_factor_required: An authenticator code is required to complete sign-in. + + + two_factor_invalid: The authenticator code is invalid or expired. + + + Account is temporarily locked due to too many failed login attempts. Try again later. + + + Refresh token is invalid or expired. + + + User is deactivated. + + + Email not confirmed. + + + Tenant {0} is deactivated. + + + Tenant {0} validity has expired. + + + Error resetting password. + + + Failed to change password. + + + Update profile failed. + + + Update profile image failed. + + + An error occurred while confirming E-Mail. + + + An error occurred while confirming {0}. + + + An error occurred while confirming the email for {0}: {1} + + + The email for {0} is already confirmed. + + + An error occurred while confirming phone number. + + + An error occurred while confirming phone number {0}. + + + Email claim is required for external authentication. + + + Failed to create user from external principal. + + + Passwords do not match. + + + Unable to register the user. + + + Administrators cannot remove their own admin role. + + + The root tenant administrator cannot be demoted. + + + Tenant must retain at least one administrator. + + + Only administrators can change user status. + + + Users cannot deactivate themselves. + + + Administrators cannot be deactivated. + + + Tenant must have at least one active administrator. + + + Toggle status failed. + + + Invalid credentials. + + + Cannot view sessions for another user + + + Cannot revoke session for another user + + + Cannot revoke sessions for another user + + + RoleManager<FshRole> not resolved. Check Identity registration. + + + Role store not configured. Ensure .AddRoles<FshRole>() and EF stores. + + + Method reserved for in-scope initialization + + diff --git a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj index 2ef3b71607..7f3773dcfe 100644 --- a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj +++ b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj @@ -3,7 +3,7 @@ FSH.Modules.Identity FSH.Modules.Identity - $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862;CA2227 + $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862;CA2227;S2094 diff --git a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs index 2fa5ff12d3..cb60d2af57 100644 --- a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Identity.Claims; using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Localization; using System.Security.Claims; namespace FSH.Modules.Identity.Services; @@ -42,7 +43,11 @@ public void SetCurrentUser(ClaimsPrincipal user) { if (_user != null) { - throw new CustomException("Method reserved for in-scope initialization"); + throw new CustomException("Method reserved for in-scope initialization") + { + MessageKey = "Identity.InScopeInitializationOnly", + ResourceSource = typeof(IdentityResources), + }; } _user = user; @@ -52,7 +57,11 @@ public void SetCurrentUserId(string userId) { if (_userId != Guid.Empty) { - throw new CustomException("Method reserved for in-scope initialization"); + throw new CustomException("Method reserved for in-scope initialization") + { + MessageKey = "Identity.InScopeInitializationOnly", + ResourceSource = typeof(IdentityResources), + }; } if (!string.IsNullOrEmpty(userId)) diff --git a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs index a403a2363e..76e07c0ac0 100644 --- a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -72,7 +73,11 @@ private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCo throw new CustomException( "two_factor_required: An authenticator code is required to complete sign-in.", errors: null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Identity.TwoFactorRequired", + ResourceSource = typeof(IdentityResources), + }; } var valid = await _userManager.VerifyTwoFactorTokenAsync( @@ -83,7 +88,11 @@ private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCo if (!valid) { _logger.LogWarning("Invalid two-factor code for user {UserId}", user.Id); - throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired."); + throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired.") + { + MessageKey = "Identity.TwoFactorInvalid", + ResourceSource = typeof(IdentityResources), + }; } } @@ -116,7 +125,11 @@ public async Task StoreRefreshTokenAsync(string subject, string refreshToken, Da if (updated == 0) { - throw new UnauthorizedException("user not found"); + throw new UnauthorizedException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; } if (_logger.IsEnabled(LogLevel.Debug)) @@ -199,7 +212,11 @@ private async Task FindAndValidateUserByCredentialsAsync(string email, throw new CustomException( "Account is temporarily locked due to too many failed login attempts. Try again later.", errors: null, - HttpStatusCode.Locked); + HttpStatusCode.Locked) + { + MessageKey = "Identity.AccountLocked", + ResourceSource = typeof(IdentityResources), + }; } if (!await _userManager.CheckPasswordAsync(user, password)) @@ -243,7 +260,11 @@ private async Task FindUserByRefreshTokenAsync(string refreshToken, str if (user is null) { _logger.LogWarning("No user found with matching refresh token hash for tenant {TenantId}", tenantId); - throw new UnauthorizedException("refresh token is invalid or expired"); + throw new UnauthorizedException("refresh token is invalid or expired") + { + MessageKey = "Identity.RefreshTokenInvalidOrExpired", + ResourceSource = typeof(IdentityResources), + }; } return user; @@ -257,7 +278,11 @@ private void ValidateRefreshTokenExpiry(FshUser user) _logger.LogWarning( "Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}", user.Id, user.RefreshTokenExpiryTime, now); - throw new UnauthorizedException("refresh token is invalid or expired"); + throw new UnauthorizedException("refresh token is invalid or expired") + { + MessageKey = "Identity.RefreshTokenInvalidOrExpired", + ResourceSource = typeof(IdentityResources), + }; } } @@ -265,12 +290,20 @@ private static void ValidateUserStatus(FshUser user) { if (!user.IsActive) { - throw new UnauthorizedException("user is deactivated"); + throw new UnauthorizedException("user is deactivated") + { + MessageKey = "Identity.UserDeactivated", + ResourceSource = typeof(IdentityResources), + }; } if (!user.EmailConfirmed) { - throw new UnauthorizedException("email not confirmed"); + throw new UnauthorizedException("email not confirmed") + { + MessageKey = "Identity.EmailNotConfirmed", + ResourceSource = typeof(IdentityResources), + }; } } @@ -283,14 +316,24 @@ private void ValidateTenantStatus(AppTenantInfo tenant) if (!tenant.IsActive) { - throw new UnauthorizedException($"tenant {tenant.Id} is deactivated"); + throw new UnauthorizedException($"tenant {tenant.Id} is deactivated") + { + MessageKey = "Identity.TenantDeactivated", + MessageArgs = [tenant.Id], + ResourceSource = typeof(IdentityResources), + }; } // Honor the billing grace period: a lapsed tenant can still authenticate until // ValidUpto + grace (matching the request-time guard in MultitenancyModule). if (_timeProvider.GetUtcNow().UtcDateTime > tenant.ValidUpto.AddDays(_gracePeriodDays)) { - throw new UnauthorizedException($"tenant {tenant.Id} validity has expired"); + throw new UnauthorizedException($"tenant {tenant.Id} validity has expired") + { + MessageKey = "Identity.TenantValidityExpired", + MessageArgs = [tenant.Id], + ResourceSource = typeof(IdentityResources), + }; } } @@ -301,11 +344,11 @@ private async Task> BuildUserClaimsAsync(FshUser user, string tenant return claims; } - private static List CreateBasicClaims(FshUser user, string tenantId) + internal static List CreateBasicClaims(FshUser user, string tenantId) { var fullName = $"{user.FirstName} {user.LastName}".Trim(); - return - [ + var claims = new List + { new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // RFC 7519 short-form sub/name/email emitted alongside legacy ClaimTypes.* so JWT consumers read them per spec. // `name` is published explicitly because the default outbound map turns ClaimTypes.Name into `unique_name`, not `name`. @@ -320,7 +363,17 @@ private static List CreateBasicClaims(FshUser user, string tenantId) new(ClaimTypes.Surname, user.LastName ?? string.Empty), new(ClaimConstants.Tenant, tenantId), new(ClaimConstants.ImageUrl, user.ImageUrl?.ToString() ?? string.Empty) - ]; + }; + + // OIDC-standard `locale` claim, emitted ONLY when the user explicitly chose a language. + // A null/blank Locale emits no claim so the culture-resolution chain falls to Accept-Language + // rather than forcing the deployment default onto users who never picked one. + if (!string.IsNullOrWhiteSpace(user.Locale)) + { + claims.Add(new Claim("locale", user.Locale)); + } + + return claims; } private async Task AddRoleClaimsAsync(List claims, FshUser user, CancellationToken ct) diff --git a/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs b/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs index 6ed5ebef65..56cf011147 100644 --- a/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Impersonation; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; @@ -82,7 +83,11 @@ public async Task RevokeAsync( var grant = await db.ImpersonationGrants .FirstOrDefaultAsync(g => g.Id == id, ct) .ConfigureAwait(false) - ?? throw new NotFoundException("impersonation grant not found"); + ?? throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; if (grant.IsTerminal) { diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs index 1f41f867a6..e194d9b5ca 100644 --- a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs @@ -6,6 +6,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using UAParser; @@ -40,7 +41,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(_multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("Invalid tenant"); + throw new UnauthorizedException("Invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -88,7 +92,11 @@ public async Task> GetUserSessionsAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot view sessions for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot view sessions for another user") + { + MessageKey = "Identity.CannotViewOthersSessions", + ResourceSource = typeof(IdentityResources), + }; } var now = _timeProvider.GetUtcNow().UtcDateTime; @@ -196,7 +204,11 @@ public async Task RevokeSessionAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(session.UserId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot revoke session for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot revoke session for another user") + { + MessageKey = "Identity.CannotRevokeOthersSession", + ResourceSource = typeof(IdentityResources), + }; } var tenantId = _multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; @@ -224,7 +236,11 @@ public async Task RevokeAllSessionsAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot revoke sessions for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot revoke sessions for another user") + { + MessageKey = "Identity.CannotRevokeOthersSessions", + ResourceSource = typeof(IdentityResources), + }; } var query = _db.UserSessions diff --git a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs index f29a3eb8fd..b0bfbb5795 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs @@ -7,6 +7,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using System.Collections.ObjectModel; @@ -66,7 +67,11 @@ public async Task ResetPasswordAsync(string email, string password, string token var user = await userManager.FindByEmailAsync(email); if (user == null) { - throw new NotFoundException("user not found"); + throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; } token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); @@ -75,7 +80,11 @@ public async Task ResetPasswordAsync(string email, string password, string token if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - throw new CustomException("error resetting password", errors); + throw new CustomException("error resetting password", errors) + { + MessageKey = "Identity.ErrorResettingPassword", + ResourceSource = typeof(IdentityResources), + }; } // Raise domain event for password reset @@ -88,14 +97,22 @@ public async Task ChangePasswordAsync(string password, string newPassword, strin { var user = await userManager.FindByIdAsync(userId); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; var result = await userManager.ChangePasswordAsync(user, password, newPassword); if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - throw new CustomException("failed to change password", errors); + throw new CustomException("failed to change password", errors) + { + MessageKey = "Identity.FailedToChangePassword", + ResourceSource = typeof(IdentityResources), + }; } // Raise domain event for password change @@ -114,7 +131,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index c96c90384b..746e67faca 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -34,7 +35,11 @@ public async Task GetAsync(string userId, CancellationToken cancellatio .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; return new UserDto { @@ -48,6 +53,7 @@ public async Task GetAsync(string userId, CancellationToken cancellatio EmailConfirmed = user.EmailConfirmed, PhoneNumber = user.PhoneNumber, TwoFactorEnabled = user.TwoFactorEnabled, + Locale = user.Locale, }; } @@ -75,11 +81,15 @@ public async Task> GetListAsync(CancellationToken cancellationToke return result; } - public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default) + public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default) { var user = await userManager.FindByIdAsync(userId); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; Uri imageUri = user.ImageUrl ?? null!; // image is optional: text-only edits forward a null FileUploadRequest, so guard before @@ -101,6 +111,17 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, user.FirstName = firstName; user.LastName = lastName; + // An absent locale means "not provided by this update" — preserve the existing value so a + // text-only profile edit never clears a language the user already chose. Blank counts as + // absent, matching the validator: its allow-list rule is guarded by + // .When(!IsNullOrWhiteSpace), so "" never reaches the allow-list and must not reach the + // user either. A form that serialises its untouched locale field as "" would otherwise + // wipe the preference on every unrelated save. + if (!string.IsNullOrWhiteSpace(locale)) + { + user.Locale = locale; + } + string? currentPhoneNumber = await userManager.GetPhoneNumberAsync(user); if (phoneNumber != currentPhoneNumber) { @@ -112,7 +133,11 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, if (!result.Succeeded) { - throw new CustomException("Update profile failed"); + throw new CustomException("Update profile failed") + { + MessageKey = "Identity.UpdateProfileFailed", + ResourceSource = typeof(IdentityResources), + }; } } @@ -120,7 +145,11 @@ public async Task SetImageUrlAsync(string userId, string? imageUrl, Cancellation { EnsureValidTenant(); var user = await userManager.FindByIdAsync(userId) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; user.ImageUrl = string.IsNullOrWhiteSpace(imageUrl) ? null @@ -129,7 +158,11 @@ public async Task SetImageUrlAsync(string userId, string? imageUrl, Cancellation var result = await userManager.UpdateAsync(user); if (!result.Succeeded) { - throw new CustomException("Update profile image failed"); + throw new CustomException("Update profile image failed") + { + MessageKey = "Identity.UpdateProfileImageFailed", + ResourceSource = typeof(IdentityResources), + }; } await signInManager.RefreshSignInAsync(user); @@ -157,7 +190,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index 79409e4379..ad2dff8911 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -11,6 +11,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; @@ -79,14 +80,23 @@ public async Task ConfirmEmailAsync(string userId, string code, string t .Where(u => u.Id == userId && !u.EmailConfirmed) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new CustomException("An error occurred while confirming E-Mail."); + _ = user ?? throw new CustomException("An error occurred while confirming E-Mail.") + { + MessageKey = "Identity.EmailConfirmationError", + ResourceSource = typeof(IdentityResources), + }; code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); var result = await userManager.ConfirmEmailAsync(user, code); return result.Succeeded ? string.Format(CultureInfo.InvariantCulture, "Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email) - : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)); + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)) + { + MessageKey = "Identity.EmailConfirmationFailedFor", + MessageArgs = [user.Email!], + ResourceSource = typeof(IdentityResources), + }; } public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancellationToken = default) @@ -96,7 +106,12 @@ public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancel var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException($"User {userId} was not found."); + ?? throw new NotFoundException($"User {userId} was not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Idempotent: a second confirm is a no-op rather than an error. if (user.EmailConfirmed) @@ -112,7 +127,12 @@ public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancel CultureInfo.InvariantCulture, "An error occurred while confirming the email for {0}: {1}", user.Email, - string.Join("; ", result.Errors.Select(e => e.Description)))); + string.Join("; ", result.Errors.Select(e => e.Description)))) + { + MessageKey = "Identity.EmailConfirmationFailedWithErrors", + MessageArgs = [user.Email!, string.Join("; ", result.Errors.Select(e => e.Description))], + ResourceSource = typeof(IdentityResources), + }; } } @@ -123,14 +143,24 @@ public async Task ResendConfirmationEmailAsync(string userId, string origin, Can var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException($"User {userId} was not found."); + ?? throw new NotFoundException($"User {userId} was not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; if (user.EmailConfirmed) { throw new CustomException(string.Format( CultureInfo.InvariantCulture, "The email for {0} is already confirmed.", - user.Email)); + user.Email)) + { + MessageKey = "Identity.EmailAlreadyConfirmed", + MessageArgs = [user.Email!], + ResourceSource = typeof(IdentityResources), + }; } await SendConfirmationEmailAsync(user, origin, cancellationToken); @@ -144,21 +174,33 @@ public async Task ConfirmPhoneNumberAsync(string userId, string code, Ca .Where(u => u.Id == userId && !u.PhoneNumberConfirmed) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new CustomException("An error occurred while confirming phone number."); + _ = user ?? throw new CustomException("An error occurred while confirming phone number.") + { + MessageKey = "Identity.PhoneConfirmationError", + ResourceSource = typeof(IdentityResources), + }; code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); var result = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber!, code); return result.Succeeded ? string.Format(CultureInfo.InvariantCulture, "Phone number {0} confirmed successfully.", user.PhoneNumber) - : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber)); + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber)) + { + MessageKey = "Identity.PhoneConfirmationFailedFor", + MessageArgs = [user.PhoneNumber!], + ResourceSource = typeof(IdentityResources), + }; } private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -166,7 +208,11 @@ private static string ExtractEmailFromPrincipal(ClaimsPrincipal principal) { return principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue("email") - ?? throw new CustomException("Email claim is required for external authentication."); + ?? throw new CustomException("Email claim is required for external authentication.") + { + MessageKey = "Identity.EmailClaimRequired", + ResourceSource = typeof(IdentityResources), + }; } private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal principal, string email) @@ -193,7 +239,11 @@ private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal princip throw new CustomException( "Failed to create user from external principal.", errors, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.FailedToCreateUserFromPrincipal", + ResourceSource = typeof(IdentityResources), + }; } return user; @@ -233,7 +283,11 @@ private static void ValidatePasswordMatch(string password, string confirmPasswor throw new CustomException( "Passwords do not match.", errors: null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.PasswordsDoNotMatch", + ResourceSource = typeof(IdentityResources), + }; } } @@ -267,7 +321,11 @@ private async Task CreateUserWithPasswordAsync( throw new CustomException( "Unable to register the user.", errors, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.UnableToRegisterUser", + ResourceSource = typeof(IdentityResources), + }; } return user; diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs index 52f55a60a3..4cab8f3255 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -26,7 +27,11 @@ public async Task AssignRolesAsync(string userId, List user var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; await ValidateAdminRoleChangeAsync(user, userRoles); @@ -44,10 +49,18 @@ public async Task AssignRolesAsync(string userId, List user public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken) - ?? throw new NotFoundException("roles not found"); + ?? throw new NotFoundException("roles not found") + { + MessageKey = "Identity.RolesNotFound", + ResourceSource = typeof(IdentityResources), + }; // Single membership query instead of one IsInRoleAsync round-trip per role. var memberships = await userManager.GetRolesAsync(user); @@ -90,13 +103,21 @@ private async Task ValidateAdminRoleChangeAsync(FshUser user, List throw new CustomException( "Administrators cannot remove their own admin role.", Array.Empty(), - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AdminCannotRemoveOwnRole", + ResourceSource = typeof(IdentityResources), + }; } // The root tenant's seed admin is the framework's last-resort recovery account. if (IsRootTenantAdmin(user)) { - throw new ForbiddenException("The root tenant administrator cannot be demoted."); + throw new ForbiddenException("The root tenant administrator cannot be demoted.") + { + MessageKey = "Identity.RootAdminCannotBeDemoted", + ResourceSource = typeof(IdentityResources), + }; } // After this removal, at least one admin must remain in the tenant — matches @@ -118,7 +139,11 @@ private async Task EnsureMinimumAdminCountAsync() throw new CustomException( "Tenant must retain at least one administrator.", Array.Empty(), - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.TenantMustRetainOneAdmin", + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.cs index e11963512f..827eab17bc 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.cs @@ -55,8 +55,8 @@ public Task> GetListAsync(CancellationToken cancellationToken) public Task GetCountAsync(CancellationToken cancellationToken) => profileService.GetCountAsync(cancellationToken); - public Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default) - => profileService.UpdateAsync(userId, firstName, lastName, phoneNumber, image, deleteCurrentImage, cancellationToken); + public Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default) + => profileService.UpdateAsync(userId, firstName, lastName, phoneNumber, image, deleteCurrentImage, locale, cancellationToken); public Task ExistsWithEmailAsync(string email, string? exceptId = null, CancellationToken cancellationToken = default) => profileService.ExistsWithEmailAsync(email, exceptId, cancellationToken); diff --git a/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs index f0e12c4829..28666a248f 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs @@ -7,6 +7,7 @@ using FSH.Modules.Auditing.Contracts; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -40,7 +41,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -52,16 +56,26 @@ private async Task BuildToggleContextAsync( var actorId = currentUser.GetUserId(); if (actorId == Guid.Empty) { - throw new UnauthorizedException("authenticated user required to toggle status"); + throw new UnauthorizedException("authenticated user required to toggle status") + { + MessageKey = "Error.NoCurrentUser", + }; } var actor = await userManager.FindByIdAsync(actorId.ToString()) - ?? throw new UnauthorizedException("current user not found"); + ?? throw new UnauthorizedException("current user not found") + { + MessageKey = "Error.NoCurrentUser", + }; var targetUser = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException("User Not Found."); + ?? throw new NotFoundException("User Not Found.") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; return new ToggleStatusContext( ActorId: actorId, @@ -78,19 +92,31 @@ private async Task ValidateTogglePermissionsAsync( if (!await userManager.IsInRoleAsync(context.Actor, RoleConstants.Admin)) { await AuditPolicyFailureAsync(context, "ActorNotAdmin", cancellationToken); - throw new ForbiddenException("Only administrators can change user status."); + throw new ForbiddenException("Only administrators can change user status.") + { + MessageKey = "Identity.OnlyAdminsCanChangeStatus", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser && context.ActorId.ToString() == context.TargetUser.Id) { await AuditPolicyFailureAsync(context, "SelfDeactivationBlocked", cancellationToken); - throw new CustomException("Users cannot deactivate themselves.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Users cannot deactivate themselves.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.CannotDeactivateSelf", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser && await userManager.IsInRoleAsync(context.TargetUser, RoleConstants.Admin)) { await AuditPolicyFailureAsync(context, "AdminDeactivationBlocked", cancellationToken); - throw new CustomException("Administrators cannot be deactivated.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Administrators cannot be deactivated.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AdminsCannotBeDeactivated", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser) @@ -107,7 +133,11 @@ private async Task EnsureMinimumActiveAdminsAsync( if (!activeAdmins.Any(u => u.IsActive)) { await AuditPolicyFailureAsync(context, "NoActiveAdmins", cancellationToken); - throw new CustomException("Tenant must have at least one active administrator.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Tenant must have at least one active administrator.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.TenantMustHaveActiveAdmin", + ResourceSource = typeof(IdentityResources), + }; } } @@ -130,7 +160,11 @@ private async Task SaveAndAuditAsync( var result = await userManager.UpdateAsync(context.TargetUser); if (!result.Succeeded) { - throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList(), HttpStatusCode.BadRequest); + throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.ToggleStatusFailed", + ResourceSource = typeof(IdentityResources), + }; } await auditClient.WriteActivityAsync( diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs index cf7d7a5282..13d4bad51a 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs @@ -1,12 +1,14 @@ using FluentValidation; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts.v1.AdjustTenantValidity; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.AdjustTenantValidity; public sealed class AdjustTenantValidityCommandValidator : AbstractValidator { - public AdjustTenantValidityCommandValidator() + public AdjustTenantValidityCommandValidator(IStringLocalizer localizer) { RuleFor(t => t.TenantId).NotEmpty(); @@ -14,10 +16,10 @@ public AdjustTenantValidityCommandValidator() // Activate/Deactivate guards that already refuse the root tenant). RuleFor(t => t.TenantId) .Must(id => !string.Equals(id, MultitenancyConstants.Root.Id, StringComparison.Ordinal)) - .WithMessage("The root operator tenant's validity cannot be adjusted."); + .WithMessage(_ => localizer["Validation.RootTenantValidityImmutable"]); RuleFor(t => t.ValidUpto) .Must(d => d != default) - .WithMessage("A valid 'validUpto' date is required."); + .WithMessage(_ => localizer["Validation.ValidUptoRequired"]); } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs index 2e199a329d..ba57a6f7b7 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs @@ -2,26 +2,28 @@ using FSH.Framework.Persistence; using FSH.Modules.Multitenancy.Contracts; using FSH.Modules.Multitenancy.Contracts.v1.CreateTenant; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.CreateTenant; public sealed class CreateTenantCommandValidator : AbstractValidator { - public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStringValidator connectionStringValidator) + public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStringValidator connectionStringValidator, IStringLocalizer localizer) { RuleFor(t => t.Id).Cascade(CascadeMode.Stop) .NotEmpty() .MustAsync(async (id, ct) => !await tenantService.ExistsWithIdAsync(id, ct).ConfigureAwait(false)) - .WithMessage((_, id) => $"Tenant {id} already exists."); + .WithMessage((_, id) => localizer["Validation.TenantAlreadyExists", id]); RuleFor(t => t.Name).Cascade(CascadeMode.Stop) .NotEmpty() .MustAsync(async (name, ct) => !await tenantService.ExistsWithNameAsync(name!, ct).ConfigureAwait(false)) - .WithMessage((_, name) => $"Tenant {name} already exists."); + .WithMessage((_, name) => localizer["Validation.TenantAlreadyExists", name!]); RuleFor(t => t.ConnectionString).Cascade(CascadeMode.Stop) .Must((_, cs) => string.IsNullOrWhiteSpace(cs) || connectionStringValidator.TryValidate(cs)) - .WithMessage("Connection string invalid."); + .WithMessage(_ => localizer["Validation.ConnectionStringInvalid"]); RuleFor(t => t.AdminEmail).Cascade(CascadeMode.Stop) .NotEmpty() @@ -32,13 +34,13 @@ public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStr RuleFor(t => t.AdminPassword).Cascade(CascadeMode.Stop) .NotEmpty() .MinimumLength(8) - .WithMessage("Admin password must be at least 8 characters."); + .WithMessage(_ => localizer["Validation.AdminPasswordMinLength"]); // Optional — null/empty falls back to the configured default plan. When supplied it must be a // lowercase plan slug; existence is validated by GetPlanTerm in the handler. RuleFor(t => t.PlanKey) .Matches("^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$") .When(t => !string.IsNullOrWhiteSpace(t.PlanKey)) - .WithMessage("Plan key must be a lowercase slug (a-z, 0-9, hyphen)."); + .WithMessage(_ => localizer["Validation.PlanKeySlug"]); } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs index 1e1d159d12..5d6a694d85 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.GetTenants; public sealed class GetTenantsQueryValidator : AbstractValidator { - public GetTenantsQueryValidator() + public GetTenantsQueryValidator(IStringLocalizer localizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs index 3ccec43cca..a17367b3ef 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Multitenancy.Contracts.v1.RenewTenant; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.RenewTenant; public sealed class RenewTenantCommandValidator : AbstractValidator { - public RenewTenantCommandValidator() + public RenewTenantCommandValidator(IStringLocalizer localizer) { RuleFor(t => t.TenantId).NotEmpty(); RuleFor(t => t.PlanKey) .Matches("^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$") .When(t => !string.IsNullOrWhiteSpace(t.PlanKey)) - .WithMessage("Plan key must be a lowercase slug (a-z, 0-9, hyphen)."); + .WithMessage(_ => localizer["Validation.PlanKeySlug"]); } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs index 889d447f56..534dc74178 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs @@ -1,33 +1,35 @@ using FluentValidation; using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Contracts.v1.UpdateTenantTheme; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; using System.Text.RegularExpressions; namespace FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; public partial class UpdateTenantThemeCommandValidator : AbstractValidator { - public UpdateTenantThemeCommandValidator() + public UpdateTenantThemeCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Theme) .NotNull() - .WithMessage("Theme is required."); + .WithMessage(_ => localizer["Validation.ThemeRequired"]); RuleFor(x => x.Theme.LightPalette) .NotNull() - .SetValidator(new PaletteValidator()); + .SetValidator(new PaletteValidator(localizer)); RuleFor(x => x.Theme.DarkPalette) .NotNull() - .SetValidator(new PaletteValidator()); + .SetValidator(new PaletteValidator(localizer)); RuleFor(x => x.Theme.Typography) .NotNull() - .SetValidator(new TypographyValidator()); + .SetValidator(new TypographyValidator(localizer)); RuleFor(x => x.Theme.Layout) .NotNull() - .SetValidator(new LayoutValidator()); + .SetValidator(new LayoutValidator(localizer)); } [GeneratedRegex("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$")] @@ -35,17 +37,17 @@ public UpdateTenantThemeCommandValidator() private sealed class PaletteValidator : AbstractValidator { - public PaletteValidator() + public PaletteValidator(IStringLocalizer localizer) { - RuleFor(x => x.Primary).Must(BeValidHexColor).WithMessage("Primary must be a valid hex color."); - RuleFor(x => x.Secondary).Must(BeValidHexColor).WithMessage("Secondary must be a valid hex color."); - RuleFor(x => x.Tertiary).Must(BeValidHexColor).WithMessage("Tertiary must be a valid hex color."); - RuleFor(x => x.Background).Must(BeValidHexColor).WithMessage("Background must be a valid hex color."); - RuleFor(x => x.Surface).Must(BeValidHexColor).WithMessage("Surface must be a valid hex color."); - RuleFor(x => x.Error).Must(BeValidHexColor).WithMessage("Error must be a valid hex color."); - RuleFor(x => x.Warning).Must(BeValidHexColor).WithMessage("Warning must be a valid hex color."); - RuleFor(x => x.Success).Must(BeValidHexColor).WithMessage("Success must be a valid hex color."); - RuleFor(x => x.Info).Must(BeValidHexColor).WithMessage("Info must be a valid hex color."); + RuleFor(x => x.Primary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Primary"]); + RuleFor(x => x.Secondary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Secondary"]); + RuleFor(x => x.Tertiary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Tertiary"]); + RuleFor(x => x.Background).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Background"]); + RuleFor(x => x.Surface).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Surface"]); + RuleFor(x => x.Error).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Error"]); + RuleFor(x => x.Warning).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Warning"]); + RuleFor(x => x.Success).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Success"]); + RuleFor(x => x.Info).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Info"]); } private static bool BeValidHexColor(string color) => @@ -54,27 +56,27 @@ private static bool BeValidHexColor(string color) => private sealed class TypographyValidator : AbstractValidator { - public TypographyValidator() + public TypographyValidator(IStringLocalizer localizer) { RuleFor(x => x.FontFamily) .NotEmpty() .MaximumLength(200) .Must(BeValidFontFamily) - .WithMessage("FontFamily must be a valid web-safe font."); + .WithMessage(_ => localizer["Validation.FontFamilyWebSafe", "FontFamily"]); RuleFor(x => x.HeadingFontFamily) .NotEmpty() .MaximumLength(200) .Must(BeValidFontFamily) - .WithMessage("HeadingFontFamily must be a valid web-safe font."); + .WithMessage(_ => localizer["Validation.FontFamilyWebSafe", "HeadingFontFamily"]); RuleFor(x => x.FontSizeBase) .InclusiveBetween(10, 24) - .WithMessage("FontSizeBase must be between 10 and 24."); + .WithMessage(_ => localizer["Validation.FontSizeBaseRange"]); RuleFor(x => x.LineHeightBase) .InclusiveBetween(1.0, 2.5) - .WithMessage("LineHeightBase must be between 1.0 and 2.5."); + .WithMessage(_ => localizer["Validation.LineHeightBaseRange"]); } private static bool BeValidFontFamily(string fontFamily) => @@ -83,17 +85,17 @@ private static bool BeValidFontFamily(string fontFamily) => private sealed class LayoutValidator : AbstractValidator { - public LayoutValidator() + public LayoutValidator(IStringLocalizer localizer) { RuleFor(x => x.BorderRadius) .NotEmpty() .MaximumLength(20) .Matches(@"^\d+(px|rem|em|%)$") - .WithMessage("BorderRadius must be a valid CSS value (e.g., '4px', '0.5rem')."); + .WithMessage(_ => localizer["Validation.BorderRadiusInvalid"]); RuleFor(x => x.DefaultElevation) .InclusiveBetween(0, 24) - .WithMessage("DefaultElevation must be between 0 and 24."); + .WithMessage(_ => localizer["Validation.DefaultElevationRange"]); } } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs new file mode 100644 index 0000000000..74961366d1 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Multitenancy.Localization; + +/// Marker type binding IStringLocalizer<MultitenancyResources> to the Multitenancy resx catalog. +public sealed class MultitenancyResources; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx new file mode 100644 index 0000000000..3f32483d63 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Este tenant foi desativado. Entre em contato com seu administrador. + + + A assinatura deste tenant expirou. Renove para continuar. + + + Tenant {0} não encontrado durante o provisionamento. + + + Tenant {0} não encontrado para provisionamento. + + + O provisionamento já está em execução para o tenant {0}. + + + Provisionamento não encontrado para o tenant {0}. + + + O tenant {0} não está provisionado. Status: {1}. + + + Provisionamento {0} para o tenant {1} não encontrado. + + + o tenant {0} já está ativado + + + o tenant {0} já está desativado + + + É necessário pelo menos um tenant ativo. + + + O tenant raiz não pode ser desativado. + + + AppTenantInfo {0} não encontrado. + + + Apenas o tenant raiz pode definir o tema padrão. + + + Tema do tenant {0} não encontrado. + + + A validade do tenant operador raiz não pode ser ajustada. + + + Uma data 'validUpto' válida é obrigatória. + + + O tenant {0} já existe. + + + String de conexão inválida. + + + A senha do administrador deve ter pelo menos 8 caracteres. + + + A chave do plano deve ser um slug minúsculo (a-z, 0-9, hífen). + + + O tema é obrigatório. + + + {0} deve ser uma cor hexadecimal válida. + + + {0} deve ser uma fonte web-safe válida. + + + FontSizeBase deve estar entre 10 e 24. + + + LineHeightBase deve estar entre 1.0 e 2.5. + + + BorderRadius deve ser um valor CSS válido (ex.: '4px', '0.5rem'). + + + DefaultElevation deve estar entre 0 e 24. + + + Pendente + + + Em execução + + + Concluído + + + Falhou + + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx new file mode 100644 index 0000000000..1107f7d245 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + This tenant has been deactivated. Contact your administrator. + + + This tenant's subscription has expired. Please renew to continue. + + + Tenant {0} not found during provisioning. + + + Tenant {0} not found for provisioning. + + + Provisioning already running for tenant {0}. + + + Provisioning not found for tenant {0}. + + + Tenant {0} is not provisioned. Status: {1}. + + + Provisioning {0} for tenant {1} not found. + + + tenant {0} is already activated + + + tenant {0} is already deactivated + + + At least one active tenant is required. + + + The root tenant cannot be deactivated. + + + AppTenantInfo {0} Not Found. + + + Only the root tenant can set the default theme + + + Theme for tenant {0} not found + + + The root operator tenant's validity cannot be adjusted. + + + A valid 'validUpto' date is required. + + + Tenant {0} already exists. + + + Connection string invalid. + + + Admin password must be at least 8 characters. + + + Plan key must be a lowercase slug (a-z, 0-9, hyphen). + + + Theme is required. + + + {0} must be a valid hex color. + + + {0} must be a valid web-safe font. + + + FontSizeBase must be between 10 and 24. + + + LineHeightBase must be between 1.0 and 2.5. + + + BorderRadius must be a valid CSS value (e.g., '4px', '0.5rem'). + + + DefaultElevation must be between 0 and 24. + + + Pending + + + Running + + + Completed + + + Failed + + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj index eb11887cb2..34ba1b60c0 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj @@ -2,7 +2,8 @@ FSH.Modules.Multitenancy FSH.Modules.Multitenancy - $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172 + + $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172;S2094 diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs index 698a90277c..f79a604756 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs @@ -26,6 +26,7 @@ using FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.RetryTenantProvisioning; using FSH.Modules.Multitenancy.Features.v1.RenewTenant; using FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Provisioning; using FSH.Modules.Multitenancy.Services; using Hangfire; @@ -180,7 +181,11 @@ public void ConfigureMiddleware(IApplicationBuilder app) { if (!tenant.IsActive) { - throw new ForbiddenException("This tenant has been deactivated. Contact your administrator."); + throw new ForbiddenException("This tenant has been deactivated. Contact your administrator.") + { + MessageKey = "Multitenancy.TenantDeactivated", + ResourceSource = typeof(MultitenancyResources), + }; } // Expiry is enforced on every request (not just at login) with a grace period: @@ -191,7 +196,11 @@ public void ConfigureMiddleware(IApplicationBuilder app) var graceEndsUtc = tenant.ValidUpto.AddDays(graceDays); if (nowUtc > graceEndsUtc) { - throw new ForbiddenException("This tenant's subscription has expired. Please renew to continue."); + throw new ForbiddenException("This tenant's subscription has expired. Please renew to continue.") + { + MessageKey = "Multitenancy.TenantSubscriptionExpired", + ResourceSource = typeof(MultitenancyResources), + }; } // Inside the grace period: surface days-left so clients can warn. Set via OnStarting so diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs index 221125a3c4..0239b67cd5 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs @@ -4,6 +4,7 @@ using FSH.Framework.Persistence; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Services; using Microsoft.Extensions.Logging; @@ -34,7 +35,12 @@ public TenantProvisioningJob( public async Task RunAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default) { var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) - ?? throw new NotFoundException($"Tenant {tenantId} not found during provisioning."); + ?? throw new NotFoundException($"Tenant {tenantId} not found during provisioning.") + { + MessageKey = "Multitenancy.TenantNotFoundDuringProvisioning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; var currentStep = TenantProvisioningStepName.Database; try diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs index c7a9ec719a..7a4ee5d9ea 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs @@ -4,6 +4,7 @@ using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Data; +using FSH.Modules.Multitenancy.Localization; using Hangfire; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -36,12 +37,22 @@ public TenantProvisioningService( public async Task StartAsync(string tenantId, CancellationToken cancellationToken) { var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) - ?? throw new NotFoundException($"Tenant {tenantId} not found for provisioning."); + ?? throw new NotFoundException($"Tenant {tenantId} not found for provisioning.") + { + MessageKey = "Multitenancy.TenantNotFoundForProvisioning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; var existing = await GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false); if (existing is not null && (existing.Status is TenantProvisioningStatus.Running or TenantProvisioningStatus.Pending)) { - throw new CustomException($"Provisioning already running for tenant {tenantId}."); + throw new CustomException($"Provisioning already running for tenant {tenantId}.") + { + MessageKey = "Multitenancy.ProvisioningAlreadyRunning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } var correlationId = Guid.NewGuid().ToString(); @@ -85,7 +96,12 @@ public async Task StartAsync(string tenantId, CancellationTo public async Task GetStatusAsync(string tenantId, CancellationToken cancellationToken) { var provisioning = await GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Provisioning not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Provisioning not found for tenant {tenantId}.") + { + MessageKey = "Multitenancy.ProvisioningNotFound", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; return ToDto(provisioning); } @@ -100,7 +116,12 @@ public async Task EnsureCanActivateAsync(string tenantId, CancellationToken canc if (provisioning.Status != TenantProvisioningStatus.Completed) { - throw new CustomException($"Tenant {tenantId} is not provisioned. Status: {provisioning.Status}."); + throw new CustomException($"Tenant {tenantId} is not provisioned. Status: {provisioning.Status}.") + { + MessageKey = "Multitenancy.TenantNotProvisioned", + MessageArgs = [tenantId, provisioning.Status], + ResourceSource = typeof(MultitenancyResources), + }; } } @@ -171,7 +192,12 @@ private async Task RequireAsync(string tenantId, string corr .Include(p => p.Steps) .FirstOrDefaultAsync(p => p.TenantId == tenantId && p.CorrelationId == correlationId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Provisioning {correlationId} for tenant {tenantId} not found."); + ?? throw new NotFoundException($"Provisioning {correlationId} for tenant {tenantId} not found.") + { + MessageKey = "Multitenancy.ProvisioningCorrelationNotFound", + MessageArgs = [correlationId, tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } private static bool TryEnsureJobStorage() diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs index 15d8fdbe01..701b5c135d 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs @@ -9,6 +9,7 @@ using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; using FSH.Modules.Multitenancy.Data; using FSH.Modules.Multitenancy.Features.v1.GetTenants; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Provisioning; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -56,7 +57,12 @@ public async Task ActivateAsync(string id, CancellationToken cancellatio if (tenant.IsActive) { - throw new CustomException($"tenant {id} is already activated"); + throw new CustomException($"tenant {id} is already activated") + { + MessageKey = "Multitenancy.TenantAlreadyActivated", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; } await _provisioningService.EnsureCanActivateAsync(id, cancellationToken).ConfigureAwait(false); @@ -123,18 +129,31 @@ public async Task DeactivateAsync(string id, CancellationToken cancellat var tenant = await GetTenantInfoAsync(id, cancellationToken).ConfigureAwait(false); if (!tenant.IsActive) { - throw new CustomException($"tenant {id} is already deactivated"); + throw new CustomException($"tenant {id} is already deactivated") + { + MessageKey = "Multitenancy.TenantAlreadyDeactivated", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; } int tenantCount = (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Count(t => t.IsActive); if (tenantCount <= 1) { - throw new CustomException("At least one active tenant is required."); + throw new CustomException("At least one active tenant is required.") + { + MessageKey = "Multitenancy.AtLeastOneActiveTenantRequired", + ResourceSource = typeof(MultitenancyResources), + }; } if (tenant.Id.Equals(MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase)) { - throw new CustomException("The root tenant cannot be deactivated."); + throw new CustomException("The root tenant cannot be deactivated.") + { + MessageKey = "Multitenancy.RootTenantCannotBeDeactivated", + ResourceSource = typeof(MultitenancyResources), + }; } tenant.Deactivate(); @@ -247,7 +266,12 @@ public async Task AdjustValidityAsync(string id, DateTime validUpto, C private async Task GetTenantInfoAsync(string id, CancellationToken cancellationToken = default) => await _tenantStore.GetAsync(id).ConfigureAwait(false) - ?? throw new NotFoundException($"{typeof(AppTenantInfo).Name} {id} Not Found."); + ?? throw new NotFoundException($"{typeof(AppTenantInfo).Name} {id} Not Found.") + { + MessageKey = "Multitenancy.TenantNotFound", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; // Finbuckle resolves via the distributed-cache store first (60-min TTL) while the injected store only // writes EF, so push the new state into the cache store too — otherwise flips lag until cache expiry. diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs index e21fc9e4fe..16f94ea406 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs @@ -10,6 +10,7 @@ using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Data; using FSH.Modules.Multitenancy.Domain; +using FSH.Modules.Multitenancy.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; @@ -220,7 +221,11 @@ public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = var currentTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id; if (currentTenantId != MultitenancyConstants.Root.Id) { - throw new ForbiddenException("Only the root tenant can set the default theme"); + throw new ForbiddenException("Only the root tenant can set the default theme") + { + MessageKey = "Multitenancy.OnlyRootCanSetDefaultTheme", + ResourceSource = typeof(MultitenancyResources), + }; } // Clear existing default @@ -240,7 +245,12 @@ public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = if (entity is null) { - throw new NotFoundException($"Theme for tenant {tenantId} not found"); + throw new NotFoundException($"Theme for tenant {tenantId} not found") + { + MessageKey = "Multitenancy.TenantThemeNotFound", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } entity.IsDefault = true; diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs index 4f46f535b0..8d7676c23a 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs @@ -16,7 +16,13 @@ public async ValueTask Handle(GetUnreadCountQuery query, CancellationToken { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); return await db.Notifications.AsNoTracking() diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs index cf3f1968e3..d5458aac42 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs @@ -19,7 +19,13 @@ public async ValueTask> Handle(ListNotificat { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs index d565f1e73f..2d8f3d83ab 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs @@ -16,7 +16,13 @@ public async ValueTask Handle(MarkAllNotificationsReadCommand cmd, Cancella { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); var now = DateTime.UtcNow; diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs index 0fb8ead556..ea73ece82a 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Notifications.Contracts.v1.Commands; using FSH.Modules.Notifications.Data; +using FSH.Modules.Notifications.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,13 @@ public async ValueTask Handle(MarkNotificationReadCommand cmd, Cancellatio { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); // Caller-scoped: filter by (Id, UserId) so users can only mutate their own rows. Returns @@ -24,7 +31,11 @@ public async ValueTask Handle(MarkNotificationReadCommand cmd, Cancellatio var notification = await db.Notifications .FirstOrDefaultAsync(n => n.Id == cmd.NotificationId && n.UserId == currentUserId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Notification not found."); + ?? throw new NotFoundException("Notification not found.") + { + MessageKey = "Notifications.NotificationNotFound", + ResourceSource = typeof(NotificationsResources), + }; notification.MarkRead(); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs b/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs index c2ff2d7d3c..d53586b4cf 100644 --- a/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs +++ b/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs @@ -1,3 +1,4 @@ +// TODO(i18n): email bodies are localized in a follow-up PR — recipient locale must be propagated (no HTTP request culture in background handlers). using System.Globalization; namespace FSH.Modules.Notifications.IntegrationEventHandlers; diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs new file mode 100644 index 0000000000..e67084865a --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Notifications.Localization; + +/// Marker type binding IStringLocalizer<NotificationsResources> to the Notifications resx catalog. +public sealed class NotificationsResources; diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx new file mode 100644 index 0000000000..4d02b7864a --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Notificação não encontrada. + + diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx new file mode 100644 index 0000000000..6db9a0e6e8 --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Notification not found. + + diff --git a/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj b/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj index e97c3074bb..77ee76efc8 100644 --- a/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj +++ b/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj @@ -3,7 +3,9 @@ FSH.Modules.Notifications FSH.Modules.Notifications - $(NoWarn);CA1031;CA1711;CA1812;CA1859;CA1002;CA2227;S3267 + + + $(NoWarn);CA1031;CA1711;CA1812;CA1859;CA1002;CA2227;S3267;S2094;S1135 diff --git a/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs b/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs index 939d27256e..cf6592f573 100644 --- a/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs +++ b/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; using FSH.Modules.Tickets.Domain.Events; +using FSH.Modules.Tickets.Localization; namespace FSH.Modules.Tickets.Domain; @@ -87,7 +88,7 @@ public static Ticket Create( public void Assign(Guid? assigneeUserId) { - ThrowIfClosedOrResolved("assign"); + ThrowIfNotAssignable(); if (assigneeUserId == AssignedToUserId) { @@ -120,7 +121,11 @@ public void Resolve(string? resolutionNote) throw new CustomException( "A closed ticket cannot be resolved — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotResolve", + ResourceSource = typeof(TicketsResources), + }; } if (Status == TicketStatus.Resolved) { @@ -148,7 +153,12 @@ public void Close() throw new CustomException( $"Only a resolved ticket can be closed — current status is {Status}. Resolve it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.OnlyResolvedCanClose", + MessageArgs = [Status], + ResourceSource = typeof(TicketsResources), + }; } ClosedAtUtc = DateTime.UtcNow; @@ -168,7 +178,11 @@ public void UpdateDetails(string title, string? description, TicketPriority prio throw new CustomException( "A closed ticket cannot be edited — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotEdit", + ResourceSource = typeof(TicketsResources), + }; } Title = title.Trim(); @@ -201,7 +215,11 @@ public Guid AddComment(Guid authorUserId, string body) throw new CustomException( "A closed ticket cannot accept new comments — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotComment", + ResourceSource = typeof(TicketsResources), + }; } var comment = TicketComment.Create(Id, authorUserId, body); @@ -227,14 +245,19 @@ private void TransitionStatus(TicketStatus next) (id, ts) => new TicketStatusChangedDomainEvent(Id, previous, next, id, ts))); } - private void ThrowIfClosedOrResolved(string action) + private void ThrowIfNotAssignable() { if (Status is TicketStatus.Closed or TicketStatus.Resolved) { throw new CustomException( - $"Cannot {action} a ticket in status {Status} — reopen it first.", + $"Cannot assign a ticket in status {Status} — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.CannotAssignInStatus", + MessageArgs = [Status], + ResourceSource = typeof(TicketsResources), + }; } } } diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs index 45189fae0a..bc206ff847 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; +using FSH.Modules.Tickets.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,11 @@ public async ValueTask Handle(AddTicketCommentCommand command, Cancellatio throw new CustomException( "Cannot post a comment without an authenticated author.", (IEnumerable?)null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Tickets.CommentAuthorRequired", + ResourceSource = typeof(TicketsResources), + }; } // Load the Comments collection up front so EF's change tracker detects the new TicketComment @@ -32,7 +37,12 @@ public async ValueTask Handle(AddTicketCommentCommand command, Cancellatio .Include(t => t.Comments) .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; var commentId = ticket.AddComment(authorId, command.Body); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs index 6c106b4126..b10b921d28 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(AssignTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Assign(command.AssigneeUserId); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs index eb58248a2f..df5ed4b12b 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(CloseTicketCommand command, CancellationToke var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Close(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs index 41dda7cd16..735027b7a6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; +using FSH.Modules.Tickets.Localization; using Mediator; using FSH.Framework.Persistence; using Microsoft.EntityFrameworkCore; @@ -26,7 +27,11 @@ public async ValueTask Handle(CreateTicketCommand command, CancellationTok throw new CustomException( "Cannot create a ticket without an authenticated reporter.", (IEnumerable?)null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Tickets.ReporterRequired", + ResourceSource = typeof(TicketsResources), + }; } // Sequential, tenant-scoped ticket numbers (TK-1, …). Count ALL rows incl. soft-deleted so a diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs index ff513feb02..32bc9a1013 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; // Soft delete: the audit interceptor converts the EF Delete into an IsDeleted flip. // Comments are not auto-included, so they are left untouched and survive a Restore. diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs index fd5e439bae..06dc641b70 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -22,7 +23,12 @@ public async ValueTask Handle(GetTicketByIdQuery query, CancellationT if (ticket is null) { - throw new NotFoundException($"Ticket {query.TicketId} not found."); + throw new NotFoundException($"Ticket {query.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [query.TicketId], + ResourceSource = typeof(TicketsResources), + }; } int commentCount = await dbContext.TicketComments diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs index cc3effd3c4..9a865168e6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -25,7 +26,12 @@ public async ValueTask> Handle( .ConfigureAwait(false); if (!ticketExists) { - throw new NotFoundException($"Ticket {query.TicketId} not found."); + throw new NotFoundException($"Ticket {query.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [query.TicketId], + ResourceSource = typeof(TicketsResources), + }; } var comments = await dbContext.TicketComments diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs index c327bdb0a2..7c17450ed9 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ReopenTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Reopen(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs index 136f15734e..ea57e17ff6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ResolveTicketCommand command, CancellationTo var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Resolve(command.ResolutionNote); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs index e6faf03b04..385312559a 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Persistence; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using Mediator; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreTicketCommand command, CancellationTo .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs index 72812e96df..b6378e2337 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(UpdateTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.UpdateDetails(command.Title, command.Description, command.Priority); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs new file mode 100644 index 0000000000..472be0e99e --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Tickets.Localization; + +/// Marker type binding IStringLocalizer<TicketsResources> to the Tickets resx catalog. +public sealed class TicketsResources; diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx new file mode 100644 index 0000000000..22bdf540b2 --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Chamado {0} não encontrado. + + + Um chamado fechado não pode ser resolvido. Reabra-o primeiro. + + + Somente um chamado resolvido pode ser fechado. O status atual é {0}. Resolva-o primeiro. + + + Um chamado fechado não pode ser editado. Reabra-o primeiro. + + + Um chamado fechado não aceita novos comentários. Reabra-o primeiro. + + + Não é possível atribuir um chamado no status {0}. Reabra-o primeiro. + + + Não é possível publicar um comentário sem um autor autenticado. + + + Não é possível criar um chamado sem um solicitante autenticado. + + + Aberto + + + Em andamento + + + Resolvido + + + Fechado + + diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx new file mode 100644 index 0000000000..8ccde88de7 --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ticket {0} not found. + + + A closed ticket cannot be resolved — reopen it first. + + + Only a resolved ticket can be closed — current status is {0}. Resolve it first. + + + A closed ticket cannot be edited — reopen it first. + + + A closed ticket cannot accept new comments — reopen it first. + + + Cannot assign a ticket in status {0} — reopen it first. + + + Cannot post a comment without an authenticated author. + + + Cannot create a ticket without an authenticated reporter. + + + Open + + + In progress + + + Resolved + + + Closed + + diff --git a/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj b/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj index f0058bb022..5324c71542 100644 --- a/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj +++ b/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj @@ -3,7 +3,8 @@ FSH.Modules.Tickets FSH.Modules.Tickets - $(NoWarn);CA1031;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs index 651f842910..638049555a 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs @@ -1,19 +1,21 @@ using FluentValidation; using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; +using FSH.Modules.Webhooks.Localization; using FSH.Modules.Webhooks.Services; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; public sealed class CreateWebhookSubscriptionCommandValidator : AbstractValidator { - public CreateWebhookSubscriptionCommandValidator() + public CreateWebhookSubscriptionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Url).NotEmpty() .Must(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - .WithMessage("A valid absolute URL is required.") + .WithMessage(_ => localizer["Validation.WebhookUrlInvalid"]) .Must(url => !Uri.TryCreate(url, UriKind.Absolute, out var uri) || !WebhookUrlGuard.IsBlockedHost(uri.Host)) - .WithMessage("The URL must not target a private, loopback, link-local, or metadata address."); - RuleFor(x => x.Events).NotEmpty().WithMessage("At least one event type is required."); + .WithMessage(_ => localizer["Validation.WebhookUrlBlockedTarget"]); + RuleFor(x => x.Events).NotEmpty().WithMessage(_ => localizer["Validation.WebhookEventsRequired"]); } } diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs index 2b550396df..d879925855 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Webhooks.Contracts.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Data; +using FSH.Modules.Webhooks.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteWebhookSubscriptionCommand command, Ca var subscription = await dbContext.Subscriptions .FirstOrDefaultAsync(s => s.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Webhook subscription {command.Id} not found."); + ?? throw new NotFoundException($"Webhook subscription {command.Id} not found.") + { + MessageKey = "Webhooks.SubscriptionNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(WebhooksResources), + }; dbContext.Subscriptions.Remove(subscription); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs index 92b97bc227..69918661b4 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Webhooks.Contracts.v1.TestWebhookSubscription; using FSH.Modules.Webhooks.Data; +using FSH.Modules.Webhooks.Localization; using FSH.Modules.Webhooks.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,12 @@ public async ValueTask Handle(TestWebhookSubscriptionCommand command, Canc .AsNoTracking() .FirstOrDefaultAsync(s => s.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Webhook subscription {command.Id} not found."); + ?? throw new NotFoundException($"Webhook subscription {command.Id} not found.") + { + MessageKey = "Webhooks.SubscriptionNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(WebhooksResources), + }; var testPayload = JsonSerializer.Serialize(new { diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs new file mode 100644 index 0000000000..dbcd1cec87 --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Webhooks.Localization; + +/// Marker type binding IStringLocalizer<WebhooksResources> to the Webhooks resx catalog. +public sealed class WebhooksResources; diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx new file mode 100644 index 0000000000..a22a34ec78 --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Inscrição de webhook {0} não encontrada. + + + Uma URL absoluta válida é obrigatória. + + + A URL não pode apontar para um endereço privado, de loopback, link-local ou de metadados. + + + É obrigatório pelo menos um tipo de evento. + + diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx new file mode 100644 index 0000000000..c86f71f6fa --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Webhook subscription {0} not found. + + + A valid absolute URL is required. + + + The URL must not target a private, loopback, link-local, or metadata address. + + + At least one event type is required. + + diff --git a/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj b/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj index facaf13e82..6402b01143 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj +++ b/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj @@ -2,7 +2,8 @@ FSH.Modules.Webhooks FSH.Modules.Webhooks - $(NoWarn);CA1031;CA1054;CA1056;CA1308;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1054;CA1056;CA1308;CA1812;CA1859;S3267;S2094 diff --git a/src/Tests/Architecture.Tests/CatalogParityTests.cs b/src/Tests/Architecture.Tests/CatalogParityTests.cs new file mode 100644 index 0000000000..a8a37b5d2e --- /dev/null +++ b/src/Tests/Architecture.Tests/CatalogParityTests.cs @@ -0,0 +1,183 @@ +using FSH.Framework.Core.Localization; +using Shouldly; +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Resources; +using System.Text.RegularExpressions; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Generic key-parity guard across EVERY resx catalog, discovered by reflection. +/// +/// Each module already has a hand-written parity test, but those only cover the modules +/// someone remembered to write one for — Notifications shipped a catalog with no parity +/// test at all, and has no test project to put one in. This closes that class of gap: a +/// new module catalog is covered the moment its assembly lands in the output, with no new +/// test and no new test project. +/// +/// Parity matters because a key missing from a translated catalog does not fail — resource +/// fallback quietly serves the neutral (English) string, so an untranslated message ships +/// looking translated. +/// +public sealed class CatalogParityTests +{ + /// + /// A catalog marker is a type with an embedded `.resources` manifest matching its own + /// full name — which is exactly the co-located `ResourcesPath = ""` convention the + /// framework relies on. Anything else named `*Resources` is skipped. + /// + private static List DiscoverCatalogMarkers() + { + var assemblies = ModuleAssemblyDiscovery.GetModuleAssemblies() + .Append(typeof(SharedResources).Assembly) + .Distinct() + .ToList(); + + var markers = new List(); + foreach (var assembly in assemblies) + { + var manifests = assembly.GetManifestResourceNames(); + foreach (var type in SafeGetTypes(assembly)) + { + if (!type.IsClass || type.FullName is null) continue; + if (!type.Name.EndsWith("Resources", StringComparison.Ordinal)) continue; + if (manifests.Contains($"{type.FullName}.resources", StringComparer.Ordinal)) + { + markers.Add(type); + } + } + } + + return markers.OrderBy(t => t.FullName, StringComparer.Ordinal).ToList(); + } + + private static IEnumerable SafeGetTypes(Assembly assembly) + { + try { return assembly.GetTypes(); } + catch (ReflectionTypeLoadException ex) { return ex.Types.Where(t => t is not null)!; } + } + + /// + /// Keys declared by this culture's OWN catalog. `tryParents: false` is the whole point: + /// with parent fallback on, a missing pt-BR key would be answered by the neutral catalog + /// and parity would look perfect while half the strings were English. + /// + private static Dictionary? OwnEntries(ResourceManager manager, CultureInfo culture) + { + var set = manager.GetResourceSet(culture, createIfNotExists: true, tryParents: false); + if (set is null) return null; + + var entries = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry entry in set) + { + if (entry.Key is string key) + { + entries[key] = entry.Value as string ?? string.Empty; + } + } + + return entries; + } + + /// + /// The `{0}`-style argument indexes a message consumes. Escaped braces (`{{`, `}}`) are + /// stripped first so a literal brace is not mistaken for a placeholder. + /// + private static SortedSet PlaceholderIndexes(string value) + { + var unescaped = value.Replace("{{", string.Empty, StringComparison.Ordinal) + .Replace("}}", string.Empty, StringComparison.Ordinal); + + var indexes = new SortedSet(); + foreach (var match in PlaceholderPattern.Matches(unescaped).Cast()) + { + indexes.Add(int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture)); + } + + return indexes; + } + + private static readonly Regex PlaceholderPattern = + new(@"\{(\d+)(?::[^}]*)?\}", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + [Fact] + public void Every_Catalog_Has_Matching_Keys_In_Every_Supported_Culture() + { + var markers = DiscoverCatalogMarkers(); + + // Never let a discovery regression read as a pass: if the reflection stops finding + // catalogs, this test would otherwise assert nothing and go green. + markers.Count.ShouldBeGreaterThanOrEqualTo( + 11, + "expected the Core catalog plus one per module; discovery found fewer, so this " + + "test would silently stop guarding the ones it lost"); + + var violations = new List(); + + foreach (var marker in markers) + { + var manager = new ResourceManager(marker); + + var neutral = OwnEntries(manager, CultureInfo.InvariantCulture); + if (neutral is null || neutral.Count == 0) + { + violations.Add($"{marker.FullName}: neutral catalog is missing or empty"); + continue; + } + + foreach (var tag in SupportedCultures.Tags) + { + // The neutral catalog IS the default culture's catalog; there is no + // `*.en-US.resx` and there should not be one. + if (tag == SupportedCultures.Default) continue; + + var translated = OwnEntries(manager, new CultureInfo(tag)); + if (translated is null) + { + violations.Add($"{marker.FullName}: no `.{tag}.resx` catalog at all"); + continue; + } + + var missing = neutral.Keys.Except(translated.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var extra = translated.Keys.Except(neutral.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToList(); + + if (missing.Count > 0) + { + violations.Add($"{marker.FullName} [{tag}]: missing {missing.Count} key(s) — {string.Join(", ", missing)}"); + } + + if (extra.Count > 0) + { + violations.Add($"{marker.FullName} [{tag}]: {extra.Count} key(s) not in the neutral catalog — {string.Join(", ", extra)}"); + } + + // Matching keys are not enough. The caller passes ONE argument list for every + // culture, so a translation consuming a different set of `{n}` placeholders than + // the neutral string either drops data silently or throws FormatException at + // render time — in the translated culture only, i.e. never on the reviewer's + // machine. `{1}` present in Portuguese but not English is the dangerous + // direction: string.Format throws when the index is out of range. + foreach (var key in neutral.Keys.Intersect(translated.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var neutralArgs = PlaceholderIndexes(neutral[key]); + var translatedArgs = PlaceholderIndexes(translated[key]); + + if (!neutralArgs.SetEquals(translatedArgs)) + { + violations.Add( + $"{marker.FullName} [{tag}] key '{key}': placeholder mismatch — neutral uses " + + $"{{{string.Join(",", neutralArgs)}}} but {tag} uses {{{string.Join(",", translatedArgs)}}}"); + } + } + } + } + + violations.ShouldBeEmpty( + "Every resx catalog must declare the same keys in every supported culture. A key " + + "present only in the neutral catalog falls back to English and ships as if it were " + + "translated. Violations:\n " + string.Join("\n ", violations)); + } +} diff --git a/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs b/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs new file mode 100644 index 0000000000..270f67387f --- /dev/null +++ b/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs @@ -0,0 +1,49 @@ +using FSH.Framework.Core.Exceptions; +using Shouldly; +using System.Reflection; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Audit.RealExceptionType reports a localization wrapper as its base type, so an audit query +/// filtering on exceptionType keeps matching the BCL exception after an endpoint is localized. +/// That single step up is only correct while the wrappers are leaves: a subclass of one would +/// report the wrapper itself, and the same BCL exception would show up under two names. +/// The rule is cheaper to enforce than the walk is to write, so it is enforced here. +/// +public sealed class LocalizedExceptionShapeTests +{ + [Fact] + public void Every_localization_wrapper_around_a_BCL_exception_is_sealed() + { + var assemblies = ModuleAssemblyDiscovery.GetModuleAssemblies() + .Append(typeof(ILocalizableMessage).Assembly) + .Distinct() + .ToArray(); + + var offenders = assemblies + .SelectMany(SafeGetTypes) + .Where(t => t.IsClass && !t.IsAbstract) + .Where(typeof(ILocalizableMessage).IsAssignableFrom) + // CustomException is ours, not a wrapper around a BCL type, and is meant to be derived from. + .Where(t => !typeof(CustomException).IsAssignableFrom(t)) + .Where(t => !t.IsSealed) + .Select(t => t.FullName!) + .ToList(); + + offenders.ShouldBeEmpty(); + } + + private static IEnumerable SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null)!; + } + } +} diff --git a/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs b/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs index 2650be91f0..ac9698d198 100644 --- a/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs +++ b/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; namespace Auditing.Tests.Contracts; @@ -140,6 +141,45 @@ public void Classify_Should_ReturnInformation_For_DerivedOperationCanceledExcept result.ShouldBe(AuditSeverity.Information); } + // The localization work introduced LocalizedUnauthorizedAccessException specifically so that + // subclassing the BCL type — rather than swapping it for a CustomException — keeps this + // classifier mapping unauthorized access to Warning. That intent lived only in a code + // comment: changing the base type would silently reclassify every unauthorized access as + // Error and no test would have noticed. This is the test that notices. + [Fact] + public void Classify_Should_ReturnWarning_For_LocalizedUnauthorizedAccessException() + { + // Arrange + var exception = new LocalizedUnauthorizedAccessException("Authentication failed.") + { + MessageKey = "Error.AuthenticationFailed", + }; + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Warning); + } + + // The KeyNotFound counterpart lands on Error either way; pinned so the classification is + // stated rather than left to be derived from the switch's default arm. + [Fact] + public void Classify_Should_ReturnError_For_LocalizedKeyNotFoundException() + { + // Arrange + var exception = new LocalizedKeyNotFoundException("Not found.") + { + MessageKey = "Error.NotFound", + }; + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1032:Implement standard exception constructors", Justification = "Test-only exception class")] private sealed class CustomCanceledException : OperationCanceledException { diff --git a/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs b/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs new file mode 100644 index 0000000000..3ee4bfbd3b --- /dev/null +++ b/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Auditing.Tests.Localization; + +// Proves the AuditingResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class AuditingResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(AuditingResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // AuditingResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // AuditingResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Validation.DateRangeOrder"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Validation.DateRangeOrder' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("FromUtc must be less than or equal to ToUtc."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Validation.DateRangeOrder"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Validation.DateRangeOrder' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("FromUtc deve ser menor ou igual a ToUtc."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs new file mode 100644 index 0000000000..c0e903a852 --- /dev/null +++ b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Globalization; +using FSH.Modules.Billing.Contracts; +using System.Linq; +using FSH.Modules.Billing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Billing.Tests.Localization; + +// Proves the BillingResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class BillingResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(BillingResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // BillingResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // BillingResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Billing.OnlyRootOperatorMayGenerateInvoices"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Billing.OnlyRootOperatorMayGenerateInvoices' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Only the root operator may generate invoices across tenants."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Billing.OnlyRootOperatorMayGenerateInvoices"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Billing.OnlyRootOperatorMayGenerateInvoices' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Apenas o operador raiz pode gerar faturas entre tenants."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which puts an + // English word inside an otherwise translated sentence, so every member needs both entries. + [Theory] + [InlineData(typeof(TopupRequestStatus))] + public void Every_enum_member_that_reaches_a_message_is_translated(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + var pt = KeysFor("pt-BR"); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + pt.ShouldContain(key); + } + } +} diff --git a/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs b/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs new file mode 100644 index 0000000000..c77c031bf9 --- /dev/null +++ b/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Catalog.Tests.Localization; + +// Proves the CatalogResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class CatalogResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(CatalogResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // CatalogResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // CatalogResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Catalog.CategoryCannotBeOwnParent"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Catalog.CategoryCannotBeOwnParent' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("A category cannot be its own parent."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Catalog.CategoryCannotBeOwnParent"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Catalog.CategoryCannotBeOwnParent' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Uma categoria não pode ser pai de si mesma."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // The AdjustStock domain overflow message is localized with two positional args ({0}=delta, {1}=current stock). + [Fact] + public void StockAdjustmentNegative_formats_args_in_both_cultures() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Catalog.StockAdjustmentNegative", -4, 3]; + en.ResourceNotFound.ShouldBeFalse(); + en.Value.ShouldBe("Stock adjustment of -4 would result in negative stock (current: 3)."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Catalog.StockAdjustmentNegative", -4, 3]; + pt.ResourceNotFound.ShouldBeFalse(); + pt.Value.ShouldBe("O ajuste de estoque de -4 resultaria em estoque negativo (atual: 3)."); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs b/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..cc6beedd0a --- /dev/null +++ b/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Catalog.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class CatalogResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Catalog.Tests/Support/TestUiCulture.cs b/src/Tests/Catalog.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..737f745999 --- /dev/null +++ b/src/Tests/Catalog.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Catalog.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} diff --git a/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs b/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs new file mode 100644 index 0000000000..b86c1ec57a --- /dev/null +++ b/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs @@ -0,0 +1,35 @@ +using Catalog.Tests.Support; +using FSH.Modules.Catalog.Contracts.v1.Products; +using FSH.Modules.Catalog.Features.v1.Products.AdjustProductStock; + +namespace Catalog.Tests.Validators; + +// Exercises the validator with a REAL IStringLocalizer so the localized +// message key ("Validation.DeltaNonZero") is proven to resolve against the embedded catalog. +public sealed class AdjustProductStockCommandValidatorTests +{ + private readonly AdjustProductStockCommandValidator _sut = new(CatalogResourcesLocalizerFactory.Create()); + + [Fact] + public void Delta_Should_Pass_When_NonZero() + { + var command = new AdjustProductStockCommand(Guid.NewGuid(), 5); + + var result = _sut.Validate(command); + + result.Errors.ShouldNotContain(e => e.PropertyName == nameof(AdjustProductStockCommand.Delta)); + } + + [Fact] + public void Delta_Should_Fail_With_LocalizedMessage_When_Zero() + { + var command = new AdjustProductStockCommand(Guid.NewGuid(), 0); + + var result = _sut.Validate(command); + + result.IsValid.ShouldBeFalse(); + result.Errors + .Where(e => e.PropertyName == nameof(AdjustProductStockCommand.Delta)) + .ShouldContain(e => e.ErrorMessage == "Delta must be non-zero."); + } +} diff --git a/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs b/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs new file mode 100644 index 0000000000..943d0f9c75 --- /dev/null +++ b/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Chat.Tests.Localization; + +// Proves the ChatResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class ChatResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(ChatResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // ChatResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // ChatResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Chat.ChannelNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Chat.ChannelNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Channel not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Chat.ChannelNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Chat.ChannelNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Canal não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs b/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..5f02505aa0 --- /dev/null +++ b/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Chat.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class ChatResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs new file mode 100644 index 0000000000..70147a8203 --- /dev/null +++ b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Globalization; +using FSH.Modules.Files.Contracts.v1.DTOs; +using System.Linq; +using FSH.Modules.Files.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Files.Tests.Localization; + +// Proves the FilesResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class FilesResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(FilesResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // FilesResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // FilesResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Files.FileNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Files.FileNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("File not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Files.FileNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Files.FileNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Arquivo não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which puts an + // English word inside an otherwise translated sentence, so every member needs both entries. + [Theory] + [InlineData(typeof(FileAssetStatus))] + [InlineData(typeof(Visibility))] + public void Every_enum_member_that_reaches_a_message_is_translated(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + var pt = KeysFor("pt-BR"); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + pt.ShouldContain(key); + } + } +} diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs new file mode 100644 index 0000000000..25838684c5 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Framework.Tests.Localization; + +// Guards against an English key missing from the .pt-BR catalog (a silent English fallback shipped as +// "translated"). Enumerates each culture's own embedded resx (includeParentCultures: false) and +// asserts identical key sets. +public sealed class SharedResourcesKeyParityTests +{ + private static List KeysFor(string culture) => + EntriesFor(culture).Select(e => e.Key).ToList(); + + private static Dictionary EntriesFor(string culture) + { + var localizer = SharedResourcesLocalizerFactory.Create(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .ToDictionary(s => s.Name, s => s.Value, StringComparer.Ordinal); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // {0}, {1:N2}, {0,-10} — the index is what has to match; alignment and format do not. + private static readonly Regex PlaceholderPattern = new(@"\{(\d+)(?:[,:][^}]*)?\}", RegexOptions.Compiled); + + private static string PlaceholderSignature(string value) => + string.Join( + ",", + PlaceholderPattern.Matches(value) + .Select(m => m.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .OrderBy(i => int.Parse(i, CultureInfo.InvariantCulture))); + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // SharedResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // SharedResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + // Matching keys are not enough. A translation that drops {0}, or renumbers it, either + // swallows the argument or throws FormatException at the point the message is built — + // and neither shows up as a missing key. + [Fact] + public void Neutral_and_ptBR_messages_take_the_same_arguments() + { + var neutral = EntriesFor(string.Empty); + var pt = EntriesFor("pt-BR"); + + var divergent = neutral + .Where(entry => pt.ContainsKey(entry.Key)) + .Select(entry => new + { + entry.Key, + Neutral = PlaceholderSignature(entry.Value), + PtBR = PlaceholderSignature(pt[entry.Key]), + }) + .Where(x => !string.Equals(x.Neutral, x.PtBR, StringComparison.Ordinal)) + .Select(x => $"{x.Key}: neutral [{x.Neutral}] vs pt-BR [{x.PtBR}]") + .ToList(); + + divergent.ShouldBeEmpty(); + } +} diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs new file mode 100644 index 0000000000..c860d93d65 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs @@ -0,0 +1,51 @@ +using System.Globalization; +using FSH.Framework.Core.Localization; +using FSH.Framework.Web.Localization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Framework.Tests.Localization; + +// Proves the whole resx wiring: SharedResources marker + co-located resx + ResourcesPath="" in +// AddHeroLocalization resolve to the embedded catalog under the current UI culture. If the manifest +// name or ResourcesPath is wrong, ResourceNotFound flips true and IStringLocalizer leaks the raw key. +public sealed class SharedResourcesLocalizationTests +{ + private static IStringLocalizer BuildLocalizer() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHeroLocalization(configuration); + return services.BuildServiceProvider().GetRequiredService>(); + } + + // Catalogs are named for specific cultures (SharedResources.pt-BR.resx), matching the front-end. + // The consequence is deliberate and pinned here: only pt-BR is served Portuguese. A bare `pt` or + // an unsupported variant like pt-PT walks its parent chain, finds no catalog of its own and lands + // on the neutral (English) one, rather than being silently handed Brazilian strings. + [Theory] + [InlineData("pt-BR", "Não encontrado")] // specific pt-BR resolves directly + [InlineData("pt", "Not Found")] // bare pt has no catalog -> neutral English + [InlineData("pt-PT", "Not Found")] // unsupported variant -> neutral English, NOT pt-BR + [InlineData("en-US", "Not Found")] // en-US falls back to the neutral (default) catalog + public void Localizer_resolves_error_key_per_culture(string culture, string expected) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + var localized = localizer["Error.NotFound"]; + + localized.ResourceNotFound.ShouldBeFalse( + $"resx for '{culture}' did not resolve 'Error.NotFound' — check ResourcesPath/resx manifest name."); + localized.Value.ShouldBe(expected); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..150d667725 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,27 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Framework.Tests.Localization; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx). Shared by the handler and validator tests +// so they exercise the actual catalog resolution rather than a stub. +internal static class SharedResourcesLocalizerFactory +{ + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider(); + } + + public static IStringLocalizer Create() => + BuildProvider().GetRequiredService>(); + + // The GlobalExceptionHandler resolves module-catalog keys through IStringLocalizerFactory + // (via CustomException.ResourceSource); tests build a real factory bound to the same setup. + public static IStringLocalizerFactory CreateFactory() => + BuildProvider().GetRequiredService(); +} diff --git a/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs b/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs new file mode 100644 index 0000000000..4bfb0594ce --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using System.Security.Claims; +using FSH.Framework.Web.Localization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Framework.Tests.Localization; + +public sealed class UserLocaleRequestCultureProviderTests +{ + private static IOptions BuildOptions() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var services = new ServiceCollection(); + services.AddHeroLocalization(configuration); + return services.BuildServiceProvider().GetRequiredService>(); + } + + private static DefaultHttpContext BuildContext(string? claim, string? header, string? query) + { + var context = new DefaultHttpContext(); + if (claim is not null) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("locale", claim)], "test")); + } + + if (header is not null) + { + context.Request.Headers.AcceptLanguage = header; + } + + if (query is not null) + { + context.Request.QueryString = new QueryString($"?culture={query}"); + } + + return context; + } + + // Full culture-provider chain: Query -> user locale claim -> Accept-Language -> configured default -> en-US. + [Theory] + [InlineData("pt-BR", "en-US", null, "pt-BR")] // supported claim wins over the header + [InlineData(null, "pt-BR", null, "pt-BR")] // header used when there is no claim + [InlineData(null, null, null, "en-US")] // nothing set -> default fallback + [InlineData("xx-YY", "pt-BR", null, "pt-BR")] // unsupported claim ignored -> falls to header + [InlineData(null, "pt-BR", "en-US", "en-US")] // explicit query override wins over everything + [InlineData(null, "pt", null, "en-US")] // bare pt is not a supported tag -> default + [InlineData(null, "pt-PT", null, "en-US")] // unsupported variant -> default, never pt-BR + public async Task Resolves_expected_culture_through_chain(string? claim, string? header, string? query, string expected) + { + var options = BuildOptions(); + var context = BuildContext(claim, header, query); + + string? resolved = null; + var middleware = new RequestLocalizationMiddleware( + _ => { resolved = CultureInfo.CurrentUICulture.Name; return Task.CompletedTask; }, + options, + NullLoggerFactory.Instance); + + var previous = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); + try + { + await middleware.Invoke(context); + } + finally + { + CultureInfo.CurrentCulture = previous.Item1; + CultureInfo.CurrentUICulture = previous.Item2; + } + + resolved.ShouldBe(expected); + } + + // Localization negotiates the UI culture ONLY. CurrentCulture must stay invariant no matter what + // the request asks for, so no endpoint's ToString()/Parse()/interpolation shifts per request. Runs + // the real RequestLocalizationMiddleware, because this property comes out of the interaction + // between DefaultRequestCulture and a null SupportedCultures, not out of our provider. + [Theory] + [InlineData("pt-BR", null, null, "pt-BR")] // claim + [InlineData(null, "pt-BR", null, "pt-BR")] // header + [InlineData(null, null, "pt-BR", "pt-BR")] // query override + [InlineData(null, null, null, "en-US")] // nothing set + public async Task Formatting_culture_stays_invariant_while_ui_culture_negotiates( + string? claim, string? header, string? query, string expectedUiCulture) + { + var options = BuildOptions(); + var context = BuildContext(claim, header, query); + + string? formattingCulture = null; + string? uiCulture = null; + var middleware = new RequestLocalizationMiddleware( + _ => + { + formattingCulture = CultureInfo.CurrentCulture.Name; + uiCulture = CultureInfo.CurrentUICulture.Name; + return Task.CompletedTask; + }, + options, + NullLoggerFactory.Instance); + + var previous = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); + try + { + CultureInfo.CurrentCulture = new CultureInfo("pt-BR"); + await middleware.Invoke(context); + } + finally + { + CultureInfo.CurrentCulture = previous.Item1; + CultureInfo.CurrentUICulture = previous.Item2; + } + + uiCulture.ShouldBe(expectedUiCulture); + formattingCulture.ShouldBe( + string.Empty, + "CurrentCulture must be the invariant culture; a negotiated formatting culture would shift " + + "number and date rendering for every endpoint in the request."); + } + + // The custom provider in isolation: emit the claim only when supported, otherwise fall through (null). + [Theory] + [InlineData("pt-BR", "pt-BR")] + [InlineData("en-US", "en-US")] + [InlineData("xx-YY", null)] + [InlineData(null, null)] + public async Task Provider_returns_claim_only_when_supported(string? claim, string? expected) + { + var context = BuildContext(claim, header: null, query: null); + var result = await new UserLocaleRequestCultureProvider().DetermineProviderCultureResult(context); + (result?.Cultures[0].Value).ShouldBe(expected); + } +} diff --git a/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs b/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs new file mode 100644 index 0000000000..6efe140041 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs @@ -0,0 +1,187 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Exceptions; +using FSH.Framework.Web.Localization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Framework.Tests.Web; + +// Pipeline-level counterpart to GlobalExceptionHandlerLocalizationTests. Those tests assign +// CultureInfo.CurrentUICulture by hand and call the handler directly, so they never exercise the one +// thing production depends on: that the culture RequestLocalizationMiddleware negotiates is still +// visible to the handler, which UseExceptionHandler runs from ABOVE that middleware. It is not — the +// assignment lives in the middleware own async frame — so the handler has to read the negotiated +// culture off the request instead. +// +// The ambient culture is pinned to invariant for the duration of each case, which is what a container +// with no LANG gives the API. Without the pin the developer machine culture decides the outcome and a +// pt-BR machine reports a false pass. +public sealed class ExceptionLocalizationPipelineTests +{ + private sealed record Outcome(string? Title, string? Detail, string? Code, string? NegotiatedUiCulture, string? ContentLanguage); + + // The production order: UseExceptionHandler at the top of the pipeline, localization further in. + private static Task InvokeAsync(string acceptLanguage, Exception exception) => + RunAsync(app => app.UseHeroLocalization(), acceptLanguage, exception); + + // A pipeline with no localization at all, standing in for the middleware registered between + // UseExceptionHandler and UseHeroLocalization: nothing negotiates a culture, so no feature exists. + private static Task InvokeWithoutLocalizationAsync(string acceptLanguage, Exception exception) => + RunAsync(_ => { }, acceptLanguage, exception); + + private static async Task RunAsync(Action configure, string acceptLanguage, Exception exception) + { + var previousCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + try + { + var configuration = new ConfigurationBuilder().Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMetrics(); + + // ExceptionHandlerMiddlewareImpl is activated from DI and takes a DiagnosticListener; + // the real host gets it from WebApplicationBuilder, so a bare ServiceCollection must supply it. + services.AddSingleton(_ => new DiagnosticListener("Microsoft.AspNetCore")); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddHeroLocalization(configuration); + services.AddExceptionHandler(); + services.AddProblemDetails(); + + await using var provider = services.BuildServiceProvider(); + + var app = new ApplicationBuilder(provider); + app.UseExceptionHandler(); + configure(app); + app.Run(_ => throw exception); + var pipeline = app.Build(); + + using var scope = provider.CreateScope(); + var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + context.Request.Path = "/api/v1/test"; + context.Request.Headers.AcceptLanguage = acceptLanguage; + using var body = new MemoryStream(); + context.Response.Body = body; + + await pipeline(context); + + using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(body.ToArray())); + var root = doc.RootElement; + var contentLanguage = context.Response.Headers.ContentLanguage.ToString(); + return new Outcome( + root.TryGetProperty("title", out var t) ? t.GetString() : null, + root.TryGetProperty("detail", out var d) ? d.GetString() : null, + root.TryGetProperty("code", out var c) ? c.GetString() : null, + context.Features.Get()?.RequestCulture.UICulture.Name, + string.IsNullOrEmpty(contentLanguage) ? null : contentLanguage); + } + finally + { + CultureInfo.CurrentUICulture = previousCulture; + } + } + + // Baseline: the middleware DOES negotiate the requested UI culture. If this fails, the gap is in + // negotiation and the assertions below say nothing about the handler. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task Request_culture_is_negotiated_from_the_accept_language_header(string acceptLanguage) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.NegotiatedUiCulture.ShouldBe(acceptLanguage); + } + + // The production symptom: a localized exception answered in English despite Accept-Language: pt-BR. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task Detail_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.Code.ShouldBe("Error.NotFound"); + outcome.Detail.ShouldBe(expected); + } + + // Title comes from the status-mapped catalog key through the injected IStringLocalizer, a separate + // resolution path from Detail IStringLocalizerFactory. Both read the ambient culture, so both broke. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task Title_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var outcome = await InvokeAsync(acceptLanguage, new NotFoundException("English fallback")); + + outcome.Title.ShouldBe(expected); + } + + // A raw exception takes the 500 branch, whose Title and Detail come straight off the injected + // localizer with no MessageKey involved. + [Theory] + [InlineData("pt-BR", "Ocorreu um erro inesperado")] + [InlineData("en-US", "An unexpected error occurred")] + public async Task Unexpected_error_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var outcome = await InvokeAsync(acceptLanguage, new InvalidOperationException("boom")); + + outcome.Title.ShouldBe(expected); + } + + // ExceptionHandlerMiddleware clears the response before re-executing, dropping the Content-Language + // the localization middleware had written. The handler restores it so the body culture is declared. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task Content_language_declares_the_culture_the_body_was_written_in(string acceptLanguage) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.ContentLanguage.ShouldBe(acceptLanguage); + } + + // No localization in the pipeline: the handler must still answer, from the ambient culture, and + // must not claim a Content-Language it never negotiated. + [Fact] + public async Task Handler_still_answers_when_no_culture_was_negotiated() + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeWithoutLocalizationAsync("pt-BR", exception); + + outcome.NegotiatedUiCulture.ShouldBeNull(); + outcome.ContentLanguage.ShouldBeNull(); + outcome.Code.ShouldBe("Error.NotFound"); + outcome.Detail.ShouldBe("Not Found"); + outcome.Title.ShouldBe("Not Found"); + } + + // The ambient culture is restored on the way out, so the handler cannot leak the request culture + // onto whatever else runs on this thread afterwards. + [Fact] + public async Task Ambient_culture_is_restored_after_handling() + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + var before = CultureInfo.CurrentUICulture; + + await InvokeAsync("pt-BR", exception); + + CultureInfo.CurrentUICulture.ShouldBe(before); + } +} diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs new file mode 100644 index 0000000000..71d20ee1a9 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs @@ -0,0 +1,278 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Exceptions; +using Framework.Tests.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Framework.Tests.Web; + +// Handler-level (Docker-free) proof that GlobalExceptionHandler localizes the ProblemDetails body +// from the shared resx under the ambient UI culture. Covers both the framework branches (raw +// KeyNotFoundException/InvalidOperationException) and the CustomException branch, whose Title now +// comes from the status-mapped catalog key and whose Detail is resolved from MessageKey (falling +// back to the English Message when no key is set). +public sealed class GlobalExceptionHandlerLocalizationTests +{ + private static async Task<(string? Title, string? Detail)> HandleAsync(Exception exception, string culture) + { + var (title, detail, _) = await HandleWithCodeAsync(exception, culture); + return (title, detail); + } + + private static async Task<(string? Title, string? Detail, string? Code)> HandleWithCodeAsync(Exception exception, string culture) + { + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + + var context = new DefaultHttpContext(); + context.Request.Path = "/api/v1/test"; + using var body = new MemoryStream(); + context.Response.Body = body; + + var handler = new GlobalExceptionHandler( + NullLogger.Instance, + SharedResourcesLocalizerFactory.Create(), + SharedResourcesLocalizerFactory.CreateFactory()); + await handler.TryHandleAsync(context, exception, CancellationToken.None); + + var json = Encoding.UTF8.GetString(body.ToArray()); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var title = root.TryGetProperty("title", out var t) ? t.GetString() : null; + var detail = root.TryGetProperty("detail", out var d) ? d.GetString() : null; + var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; + return (title, detail, code); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task NotFound_title_is_localized(string culture, string expected) + { + var (title, _) = await HandleAsync(new KeyNotFoundException("missing"), culture); + title.ShouldBe(expected); + } + + [Theory] + [InlineData("pt-BR", "Ocorreu um erro inesperado")] + [InlineData("en-US", "An unexpected error occurred")] + public async Task Unexpected_title_is_localized(string culture, string expected) + { + var (title, _) = await HandleAsync(new InvalidOperationException("boom"), culture); + title.ShouldBe(expected); + } + + // CustomException Title is now the status-mapped catalog key (404 -> Error.NotFound), localized. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task CustomException_title_is_localized_by_status(string culture, string expected) + { + var (title, _) = await HandleAsync(new NotFoundException("some entity was not found"), culture); + title.ShouldBe(expected); + } + + // Detail resolves from MessageKey under the request culture (using an existing Core key). + [Theory] + [InlineData("pt-BR", "Não autorizado")] + [InlineData("en-US", "Unauthorized")] + public async Task CustomException_detail_is_localized_from_key(string culture, string expected) + { + var exception = new UnauthorizedException("english fallback") { MessageKey = "Error.Unauthorized" }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + // No MessageKey: Detail falls back to the literal (English) Message regardless of culture (non-breaking). + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task CustomException_detail_falls_back_to_message_without_key(string culture) + { + var (_, detail) = await HandleAsync(new NotFoundException("Plain English detail."), culture); + detail.ShouldBe("Plain English detail."); + } + + // Parameterless UnauthorizedException carries Error.AuthenticationFailed, so generic auth failures + // localize their Detail without any call-site key (English fallback stays "Authentication failed."). + [Theory] + [InlineData("pt-BR", "Falha na autenticação.")] + [InlineData("en-US", "Authentication failed.")] + public async Task Parameterless_unauthorized_detail_is_localized(string culture, string expected) + { + var (_, detail) = await HandleAsync(new UnauthorizedException(), culture); + detail.ShouldBe(expected); + } + + // Unknown MessageKey: ResourceNotFound path falls back to the English Message, never leaks the raw key. + [Fact] + public async Task CustomException_detail_falls_back_when_key_missing() + { + var exception = new NotFoundException("English fallback detail.") { MessageKey = "Does.Not.Exist" }; + var (_, detail) = await HandleAsync(exception, "pt-BR"); + detail.ShouldBe("English fallback detail."); + } + + // BCL-subclass exceptions (kept as their base type so audit severity classification is unaffected) + // still localize their Detail from MessageKey through the shared handler path. + [Theory] + [InlineData("pt-BR", "Falha na autenticação.")] + [InlineData("en-US", "Authentication failed.")] + public async Task LocalizedUnauthorizedAccess_detail_is_localized(string culture, string expected) + { + var exception = new LocalizedUnauthorizedAccessException("english fallback") + { + MessageKey = "Error.AuthenticationFailed", + }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task LocalizedKeyNotFound_detail_is_localized(string culture, string expected) + { + var exception = new LocalizedKeyNotFoundException("english fallback") + { + MessageKey = "Error.NotFound", + }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + // No MessageKey on a localized subclass → Detail falls back to the literal (English) message. + [Fact] + public async Task LocalizedUnauthorizedAccess_without_key_falls_back_to_message() + { + var (_, detail) = await HandleAsync(new LocalizedUnauthorizedAccessException("Plain English."), "pt-BR"); + detail.ShouldBe("Plain English."); + } + + // Detail is prose under the request culture, so the MessageKey travels as a stable "code" extension: + // clients branch on the code instead of matching localized text. Same key in every culture. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task CustomException_surfaces_the_message_key_as_code(string culture) + { + var exception = new UnauthorizedException("english fallback") { MessageKey = "Error.Unauthorized" }; + var (_, _, code) = await HandleWithCodeAsync(exception, culture); + code.ShouldBe("Error.Unauthorized"); + } + + // A localized BCL subclass carries its key through the same path. + [Fact] + public async Task LocalizedKeyNotFound_surfaces_the_message_key_as_code() + { + var exception = new LocalizedKeyNotFoundException("english fallback") { MessageKey = "Error.NotFound" }; + var (_, _, code) = await HandleWithCodeAsync(exception, "pt-BR"); + code.ShouldBe("Error.NotFound"); + } + + // No key → no code property at all, rather than a null or an invented one. + [Fact] + public async Task Exception_without_key_omits_the_code() + { + var (_, _, code) = await HandleWithCodeAsync(new NotFoundException("Plain English detail."), "pt-BR"); + code.ShouldBeNull(); + } + + // An unknown key still travels as the code even though Detail fell back to English: the code is the + // contract, the resx lookup is presentation. + [Fact] + public async Task Unknown_key_still_surfaces_as_code() + { + var exception = new NotFoundException("English fallback detail.") { MessageKey = "Does.Not.Exist" }; + var (_, detail, code) = await HandleWithCodeAsync(exception, "pt-BR"); + detail.ShouldBe("English fallback detail."); + code.ShouldBe("Does.Not.Exist"); + } + + // A raw BCL exception (no ILocalizableMessage) keeps its message and gets no code. + [Fact] + public async Task Raw_key_not_found_has_no_code() + { + var (_, detail, code) = await HandleWithCodeAsync(new KeyNotFoundException("missing"), "pt-BR"); + detail.ShouldBe("missing"); + code.ShouldBeNull(); + } + + // A 409 must not be titled "an unexpected error occurred". Before Error.Conflict existed the + // status-to-key map fell through to Error.Unexpected — which RESOLVES, so the type-name fallback + // never fired and every conflict in the API reported a title contradicting its own status and its + // own detail. There are 41 Conflict throw sites across Billing and Catalog. + [Theory] + [InlineData("en-US", "Conflict")] + [InlineData("pt-BR", "Conflito")] + public async Task Conflict_is_titled_as_a_conflict_not_as_unexpected(string culture, string expected) + { + var exception = new CustomException("Brand name already taken.", [], HttpStatusCode.Conflict); + + var (title, _) = await HandleAsync(exception, culture); + + title.ShouldBe(expected); + } + + // A status with no title of its own keeps the pre-localization behaviour — the exception type + // name — instead of claiming the error was unexpected. Status-consistent beats confidently wrong. + [Theory] + [InlineData("en-US")] + [InlineData("pt-BR")] + public async Task Untranslated_status_falls_back_to_the_exception_type_name(string culture) + { + var exception = new CustomException("Mailbox is locked.", [], HttpStatusCode.Locked); + + var (title, _) = await HandleAsync(exception, culture); + + title.ShouldBe(nameof(CustomException)); + } + + // The lookup is by "{EnumType}.{Member}" against the message own catalog. SharedResources ships + // no enum of its own, so these two stand in for the two outcomes: a member whose name happens to + // match an existing key (translated) and one that matches nothing (kept as the C# name). + private enum Error { Unauthorized } + + private enum Untranslated { Member } + + // Without this, a pt-BR reader gets a translated sentence ending in an English enum name: + // "um chamado no status Closed". + [Fact] + public async Task An_enum_argument_is_translated_through_the_same_catalog() + { + var exception = new CustomException("english fallback", [], HttpStatusCode.BadRequest) + { + MessageKey = "Validation.ImpersonationTakeRange", + MessageArgs = [Error.Unauthorized], + }; + + var (_, detail) = await HandleAsync(exception, "pt-BR"); + + detail.ShouldBe("O valor de Take deve estar entre 1 e Não autorizado."); + } + + [Fact] + public async Task An_enum_argument_with_no_entry_keeps_its_member_name() + { + var exception = new CustomException("english fallback", [], HttpStatusCode.BadRequest) + { + MessageKey = "Validation.ImpersonationTakeRange", + MessageArgs = [Untranslated.Member], + }; + + var (_, detail) = await HandleAsync(exception, "pt-BR"); + + detail.ShouldBe("O valor de Take deve estar entre 1 e Member."); + } +} diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs index 5355719f66..8d941ac71a 100644 --- a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Web.Exceptions; +using Framework.Tests.Localization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using System.Net; @@ -14,7 +15,10 @@ private static async Task HandleAsync(Exception exception) context.Request.Path = "/api/v1/identity/forgot-password"; context.Response.Body = new MemoryStream(); - var handler = new GlobalExceptionHandler(NullLogger.Instance); + var handler = new GlobalExceptionHandler( + NullLogger.Instance, + SharedResourcesLocalizerFactory.Create(), + SharedResourcesLocalizerFactory.CreateFactory()); await handler.TryHandleAsync(context, exception, CancellationToken.None); return context; } diff --git a/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs b/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..43d0e9fd0f --- /dev/null +++ b/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Generic.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so the Auditing validators that require the module +// localizer can be instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class AuditingResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs b/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..2580fb2deb --- /dev/null +++ b/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Generic.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class SharedResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Generic.Tests/Support/TestUiCulture.cs b/src/Tests/Generic.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..824b0ec9a7 --- /dev/null +++ b/src/Tests/Generic.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Generic.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} diff --git a/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs b/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs index 2fd0253ac6..52215fb250 100644 --- a/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs +++ b/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAudits; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; @@ -10,6 +11,9 @@ using FSH.Modules.Auditing.Features.v1.GetAuditSummary; using FSH.Modules.Auditing.Features.v1.GetExceptionAudits; using FSH.Modules.Auditing.Features.v1.GetSecurityAudits; +using FSH.Modules.Auditing.Localization; +using Generic.Tests.Support; +using Microsoft.Extensions.Localization; namespace Generic.Tests.Validators; @@ -20,12 +24,14 @@ namespace Generic.Tests.Validators; public sealed class DateRangeValidatorTests { private static readonly DateTime BaseDate = new(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc); + private static readonly IStringLocalizer Localizer = SharedResourcesLocalizerFactory.Create(); + private static readonly IStringLocalizer AuditingLocalizer = AuditingResourcesLocalizerFactory.Create(); [Fact] public void DateRange_Should_Pass_When_BothNull_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = null, ToUtc = null }; // Act @@ -39,7 +45,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAudits() public void DateRange_Should_Pass_When_BothNull_GetAuditsByCorrelation() { // Arrange - var validator = new GetAuditsByCorrelationQueryValidator(); + var validator = new GetAuditsByCorrelationQueryValidator(AuditingLocalizer); var query = new GetAuditsByCorrelationQuery { CorrelationId = "test-id", FromUtc = null, ToUtc = null }; // Act @@ -53,7 +59,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditsByCorrelation() public void DateRange_Should_Pass_When_BothNull_GetAuditsByTrace() { // Arrange - var validator = new GetAuditsByTraceQueryValidator(); + var validator = new GetAuditsByTraceQueryValidator(AuditingLocalizer); var query = new GetAuditsByTraceQuery { TraceId = "test-trace", FromUtc = null, ToUtc = null }; // Act @@ -67,7 +73,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditsByTrace() public void DateRange_Should_Pass_When_BothNull_GetAuditSummary() { // Arrange - var validator = new GetAuditSummaryQueryValidator(); + var validator = new GetAuditSummaryQueryValidator(AuditingLocalizer); var query = new GetAuditSummaryQuery { FromUtc = null, ToUtc = null }; // Act @@ -81,7 +87,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditSummary() public void DateRange_Should_Pass_When_OnlyFromUtcSet_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, ToUtc = null }; // Act @@ -95,7 +101,7 @@ public void DateRange_Should_Pass_When_OnlyFromUtcSet_GetAudits() public void DateRange_Should_Pass_When_OnlyToUtcSet_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = null, ToUtc = BaseDate }; // Act @@ -109,7 +115,7 @@ public void DateRange_Should_Pass_When_OnlyToUtcSet_GetAudits() public void DateRange_Should_Pass_When_FromUtcEqualsToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, ToUtc = BaseDate }; // Act @@ -123,7 +129,7 @@ public void DateRange_Should_Pass_When_FromUtcEqualsToUtc_GetAudits() public void DateRange_Should_Pass_When_FromUtcBeforeToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, @@ -141,7 +147,7 @@ public void DateRange_Should_Pass_When_FromUtcBeforeToUtc_GetAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -160,7 +166,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByCorrelation() { // Arrange - var validator = new GetAuditsByCorrelationQueryValidator(); + var validator = new GetAuditsByCorrelationQueryValidator(AuditingLocalizer); var query = new GetAuditsByCorrelationQuery { CorrelationId = "test-id", @@ -180,7 +186,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByCorrelation( public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByTrace() { // Arrange - var validator = new GetAuditsByTraceQueryValidator(); + var validator = new GetAuditsByTraceQueryValidator(AuditingLocalizer); var query = new GetAuditsByTraceQuery { TraceId = "test-trace", @@ -200,7 +206,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByTrace() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditSummary() { // Arrange - var validator = new GetAuditSummaryQueryValidator(); + var validator = new GetAuditSummaryQueryValidator(AuditingLocalizer); var query = new GetAuditSummaryQuery { FromUtc = BaseDate.AddDays(7), @@ -219,7 +225,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditSummary() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetExceptionAudits() { // Arrange - var validator = new GetExceptionAuditsQueryValidator(); + var validator = new GetExceptionAuditsQueryValidator(AuditingLocalizer); var query = new GetExceptionAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -238,7 +244,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetExceptionAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetSecurityAudits() { // Arrange - var validator = new GetSecurityAuditsQueryValidator(); + var validator = new GetSecurityAuditsQueryValidator(AuditingLocalizer); var query = new GetSecurityAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -260,7 +266,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetSecurityAudits() public void DateRange_Should_Pass_When_FromUtcSlightlyBeforeToUtc(int secondsDiff) { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, diff --git a/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs b/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs index 48ed96d00b..843ee8d841 100644 --- a/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs +++ b/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs @@ -1,7 +1,11 @@ +using FSH.Framework.Core.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAudits; using FSH.Modules.Auditing.Features.v1.GetAudits; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; using FSH.Modules.Identity.Features.v1.Users.SearchUsers; +using Generic.Tests.Support; +using Microsoft.Extensions.Localization; namespace Generic.Tests.Validators; @@ -11,10 +15,13 @@ namespace Generic.Tests.Validators; /// public sealed class PagedQueryValidatorTests { + private static readonly IStringLocalizer Localizer = SharedResourcesLocalizerFactory.Create(); + private static readonly IStringLocalizer AuditingLocalizer = AuditingResourcesLocalizerFactory.Create(); + public static TheoryData PagedQueryValidators => new() { - { new GetAuditsQueryValidator(), new GetAuditsQuery() }, - { new SearchUsersQueryValidator(), new SearchUsersQuery() } + { new GetAuditsQueryValidator(Localizer, AuditingLocalizer), new GetAuditsQuery() }, + { new SearchUsersQueryValidator(Localizer), new SearchUsersQuery() } }; [Theory] @@ -35,7 +42,7 @@ public void PageNumber_Should_Pass_When_Null(IValidator validator, object query) public void PageNumber_Should_Pass_When_GreaterThanZero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = 1 }; // Act @@ -49,7 +56,7 @@ public void PageNumber_Should_Pass_When_GreaterThanZero_Auditing() public void PageNumber_Should_Pass_When_GreaterThanZero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = 5 }; // Act @@ -63,7 +70,7 @@ public void PageNumber_Should_Pass_When_GreaterThanZero_Identity() public void PageNumber_Should_Fail_When_Zero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = 0 }; // Act @@ -77,7 +84,7 @@ public void PageNumber_Should_Fail_When_Zero_Auditing() public void PageNumber_Should_Fail_When_Zero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = 0 }; // Act @@ -91,7 +98,7 @@ public void PageNumber_Should_Fail_When_Zero_Identity() public void PageNumber_Should_Fail_When_Negative_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = -1 }; // Act @@ -105,7 +112,7 @@ public void PageNumber_Should_Fail_When_Negative_Auditing() public void PageNumber_Should_Fail_When_Negative_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = -5 }; // Act @@ -119,7 +126,7 @@ public void PageNumber_Should_Fail_When_Negative_Identity() public void PageSize_Should_Pass_When_Null_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = null }; // Act @@ -133,7 +140,7 @@ public void PageSize_Should_Pass_When_Null_Auditing() public void PageSize_Should_Pass_When_Null_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = null }; // Act @@ -150,7 +157,7 @@ public void PageSize_Should_Pass_When_Null_Identity() public void PageSize_Should_Pass_When_Between1And100_Auditing(int pageSize) { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = pageSize }; // Act @@ -167,7 +174,7 @@ public void PageSize_Should_Pass_When_Between1And100_Auditing(int pageSize) public void PageSize_Should_Pass_When_Between1And100_Identity(int pageSize) { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = pageSize }; // Act @@ -181,7 +188,7 @@ public void PageSize_Should_Pass_When_Between1And100_Identity(int pageSize) public void PageSize_Should_Fail_When_Zero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = 0 }; // Act @@ -195,7 +202,7 @@ public void PageSize_Should_Fail_When_Zero_Auditing() public void PageSize_Should_Fail_When_Zero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = 0 }; // Act @@ -209,7 +216,7 @@ public void PageSize_Should_Fail_When_Zero_Identity() public void PageSize_Should_Fail_When_GreaterThan100_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = 101 }; // Act @@ -223,7 +230,7 @@ public void PageSize_Should_Fail_When_GreaterThan100_Auditing() public void PageSize_Should_Fail_When_GreaterThan100_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = 150 }; // Act diff --git a/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs b/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs new file mode 100644 index 0000000000..2145754fba --- /dev/null +++ b/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs @@ -0,0 +1,56 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Identity.Tests.Data; + +public class IdentityDbContextModelTests +{ + private static IdentityDbContext CreateContext() + { + var accessor = Substitute.For>(); + accessor.MultiTenantContext.Returns(new MultiTenantContext(new AppTenantInfo())); + + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=arch;Database=arch;Username=arch;Password=arch") + .Options; + + var settings = Options.Create(new DatabaseOptions + { + Provider = "postgresql", + ConnectionString = string.Empty, + MigrationsAssembly = "FSH.Starter.Migrations.PostgreSQL", + }); + + var environment = Substitute.For(); + environment.EnvironmentName.Returns("Production"); + + return new IdentityDbContext(accessor, options, settings, environment); + } + + // User.Locale holds a BCP-47 tag, which is short and bounded. Unbounded `text` + // invites arbitrary input at the storage layer for a value the write boundary + // already restricts to SupportedCultures.Tags. 10 covers the longest form the + // platform could offer (language-script-region, e.g. zh-Hant-TW). + [Fact] + public void User_Locale_Is_Bounded_To_A_Bcp47_Tag_Length() + { + using var context = CreateContext(); + + var locale = context.Model.FindEntityType(typeof(FshUser))?.FindProperty(nameof(FshUser.Locale)); + + locale.ShouldNotBeNull(); + locale!.GetMaxLength().ShouldBe( + 10, + "an unbounded locale column accepts arbitrary input for a value that is always a short BCP-47 tag"); + } +} diff --git a/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs new file mode 100644 index 0000000000..10381c4c85 --- /dev/null +++ b/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs @@ -0,0 +1,100 @@ +using System.Security.Claims; +using FSH.Framework.Core.Context; +using FSH.Framework.Shared.Constants; +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Impersonation; +using FSH.Modules.Identity.Contracts.v1.Impersonation.StartImpersonation; +using FSH.Modules.Identity.Features.v1.Impersonation.StartImpersonation; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.IdentityModel.Tokens.Jwt; + +namespace Identity.Tests.Handlers; + +/// +/// The impersonation token must NOT carry the target user's `locale` claim — language is a +/// presentation concern, so the operator keeps reading in their own language. +/// +public sealed class StartImpersonationCommandHandlerTests +{ + private const string TenantId = "codefi"; + private const string TargetUserId = "target-user"; + + private readonly IIdentityService _identityService = Substitute.For(); + private readonly ITokenService _tokenService = Substitute.For(); + private readonly ISecurityAudit _securityAudit = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IRequestContext _requestContext = Substitute.For(); + private readonly IImpersonationGrantService _grantService = Substitute.For(); + + private StartImpersonationCommandHandler CreateSut() => + new(_identityService, _tokenService, _securityAudit, _currentUser, _requestContext, + _grantService, TimeProvider.System, Substitute.For>()); + + [Fact] + public async Task Handle_strips_locale_but_preserves_identity_claims_and_injects_actor() + { + // Arrange — an authenticated operator in the same tenant as the target. + var actorUserId = Guid.NewGuid(); + _currentUser.IsAuthenticated().Returns(true); + _currentUser.GetUserId().Returns(actorUserId); + _currentUser.GetTenant().Returns(TenantId); + _currentUser.Name.Returns("operator"); + _currentUser.GetUserClaims().Returns(new List()); + + // A realistic target claim set: the persisted `locale` must be dropped, but every other + // identity claim (name, role, subject, tenant) must survive into the impersonation token. + var targetClaims = new List + { + new(JwtRegisteredClaimNames.Jti, "orig-jti"), + new(JwtRegisteredClaimNames.Sub, TargetUserId), + new(ClaimConstants.Tenant, TenantId), + new(ClaimTypes.Name, "Target User"), + new(ClaimTypes.Role, "Admin"), + new("locale", "pt-BR"), + }; + _identityService + .BuildClaimsForUserAsync(TargetUserId, TenantId, Arg.Any()) + .Returns(((string, IEnumerable)?)(TargetUserId, targetClaims)); + + IEnumerable? issuedClaims = null; + _tokenService + .IssueAccessOnlyAsync( + Arg.Any(), + Arg.Do>(c => issuedClaims = c), + Arg.Any(), + Arg.Any()) + .Returns(("access-token", DateTime.UtcNow.AddMinutes(15))); + + var sut = CreateSut(); + + // Act + await sut.Handle(new StartImpersonationCommand(TargetUserId, TenantId, "reason", 15), CancellationToken.None); + + // Assert + issuedClaims.ShouldNotBeNull(); + var issued = issuedClaims.ToList(); + + // (a) locale is stripped — the operator keeps reading in their own language. + issued.ShouldNotContain(c => c.Type == "locale"); + + // (b) every NON-locale identity claim survives — a mutation that over-strips (e.g. drops Name, + // role, sub or tenant) must fail here. + issued.ShouldContain(c => c.Type == ClaimTypes.Name && c.Value == "Target User"); + issued.ShouldContain(c => c.Type == ClaimTypes.Role && c.Value == "Admin"); + issued.ShouldContain(c => c.Type == JwtRegisteredClaimNames.Sub && c.Value == TargetUserId); + issued.ShouldContain(c => c.Type == ClaimConstants.Tenant && c.Value == TenantId); + + // (c) RFC 8693 actor claims are injected so the token records who is acting. + issued.ShouldContain(c => c.Type == ClaimConstants.ActorSubject && c.Value == actorUserId.ToString()); + issued.ShouldContain(c => c.Type == ClaimConstants.ActorTenant && c.Value == TenantId); + + // (d) the jti is swapped: the target's original jti is gone and exactly one fresh, non-empty + // jti is present (so the persisted grant row and the JWT share a new identifier). + issued.ShouldNotContain(c => c.Type == JwtRegisteredClaimNames.Jti && c.Value == "orig-jti"); + var jti = issued.Single(c => c.Type == JwtRegisteredClaimNames.Jti); + jti.Value.ShouldNotBe("orig-jti"); + jti.Value.ShouldNotBeNullOrWhiteSpace(); + } +} diff --git a/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs index f89478916a..2a9e25d006 100644 --- a/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs +++ b/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs @@ -39,7 +39,8 @@ await _userService.Received(1).UpdateAsync( command.LastName ?? string.Empty, command.PhoneNumber ?? string.Empty, command.Image!, - command.DeleteCurrentImage); + command.DeleteCurrentImage, + command.Locale); } [Fact] @@ -66,7 +67,8 @@ await _userService.Received(1).UpdateAsync( string.Empty, string.Empty, null!, - true); + true, + command.Locale); } [Fact] @@ -83,7 +85,7 @@ public async Task Handle_Should_ThrowException_When_UserServiceThrows() // Arrange var command = _fixture.Create(); var expectedExceptionMessage = "Update failed"; - _userService.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _userService.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(x => throw new InvalidOperationException(expectedExceptionMessage)); // Act & Assert diff --git a/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs b/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs new file mode 100644 index 0000000000..283ab6886b --- /dev/null +++ b/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Identity.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Identity.Tests.Localization; + +// Proves the IdentityResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class IdentityResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(IdentityResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // IdentityResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // IdentityResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Identity.UserNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Identity.UserNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("User not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Identity.UserNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Identity.UserNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Usuário não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs b/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs new file mode 100644 index 0000000000..ab8a29c746 --- /dev/null +++ b/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs @@ -0,0 +1,38 @@ +using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Services; + +namespace Identity.Tests.Services; + +/// +/// The JWT carries the OIDC-standard `locale` claim only when the user explicitly chose a language; +/// an unset locale emits no claim so culture resolution can fall through to Accept-Language. +/// +public sealed class CreateBasicClaimsTests +{ + private static FshUser User(string? locale) => + new() { Id = "u1", Email = "u@codefi.com.br", UserName = "u", FirstName = "First", LastName = "Last", Locale = locale }; + + [Fact] + public void Emits_locale_claim_when_user_locale_is_set() + { + var claims = IdentityService.CreateBasicClaims(User("pt-BR"), "codefi"); + + claims.Single(c => c.Type == "locale").Value.ShouldBe("pt-BR"); + } + + [Fact] + public void Omits_locale_claim_when_user_locale_is_null() + { + var claims = IdentityService.CreateBasicClaims(User(null), "codefi"); + + claims.Any(c => c.Type == "locale").ShouldBeFalse(); + } + + [Fact] + public void Omits_locale_claim_when_user_locale_is_whitespace() + { + var claims = IdentityService.CreateBasicClaims(User(" "), "codefi"); + + claims.Any(c => c.Type == "locale").ShouldBeFalse(); + } +} diff --git a/src/Tests/Identity.Tests/Services/UserLocaleTests.cs b/src/Tests/Identity.Tests/Services/UserLocaleTests.cs new file mode 100644 index 0000000000..f8a6d1726a --- /dev/null +++ b/src/Tests/Identity.Tests/Services/UserLocaleTests.cs @@ -0,0 +1,95 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Storage.Services; +using FSH.Framework.Web.Origin; +using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Services; +using Identity.Tests.Support; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Identity.Tests.Services; + +/// +/// Covers the User.Locale foundation: UpdateAsync persists the locale onto the entity and +/// GetAsync projects it back onto the DTO. +/// +public sealed class UserLocaleTests +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IStorageService _storageService; + private readonly IMultiTenantContextAccessor _tenantAccessor; + + public UserLocaleTests() + { + _userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + _signInManager = Substitute.For>( + _userManager, + Substitute.For(), + Substitute.For>(), + Options.Create(new IdentityOptions()), + Substitute.For>>(), + Substitute.For(), + Substitute.For>()); + _signInManager.RefreshSignInAsync(Arg.Any()).Returns(Task.CompletedTask); + _storageService = Substitute.For(); + _tenantAccessor = Substitute.For>(); + } + + private UserProfileService CreateSut() => + new(_userManager, _signInManager, _storageService, _tenantAccessor, + Options.Create(new OriginOptions()), Substitute.For()); + + [Fact] + public async Task UpdateAsync_persists_the_supplied_locale_onto_the_user() + { + // Arrange + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u" }; + _userManager.FindByIdAsync("u1").Returns(user); + _userManager.UpdateAsync(user).Returns(IdentityResult.Success); + var sut = CreateSut(); + + // Act + await sut.UpdateAsync("u1", "First", "Last", string.Empty, null!, false, "pt-BR", CancellationToken.None); + + // Assert + user.Locale.ShouldBe("pt-BR"); + } + + [Fact] + public async Task UpdateAsync_with_null_locale_preserves_the_existing_value() + { + // Arrange — a text-only edit forwards a null locale; the user already chose en-US. + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u", Locale = "en-US" }; + _userManager.FindByIdAsync("u1").Returns(user); + _userManager.UpdateAsync(user).Returns(IdentityResult.Success); + var sut = CreateSut(); + + // Act + await sut.UpdateAsync("u1", "First", "Last", string.Empty, null!, false, null, CancellationToken.None); + + // Assert + user.Locale.ShouldBe("en-US"); + } + + [Fact] + public async Task GetAsync_projects_the_persisted_locale_onto_the_dto() + { + // Arrange + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u", Locale = "pt-BR" }; + _userManager.Users.Returns(new[] { user }.AsAsyncQueryable()); + var sut = CreateSut(); + + // Act + var dto = await sut.GetAsync("u1", CancellationToken.None); + + // Assert + dto.Locale.ShouldBe("pt-BR"); + } +} diff --git a/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs b/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..fb4b79f15a --- /dev/null +++ b/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,18 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Identity.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog so validator +// tests exercise the actual catalog under the ambient UI culture (default culture -> neutral English). +internal static class SharedResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs b/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs new file mode 100644 index 0000000000..39a27f199b --- /dev/null +++ b/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs @@ -0,0 +1,68 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; + +namespace Identity.Tests.Support; + +/// +/// Minimal in-memory async queryable so services that call EF's FirstOrDefaultAsync +/// (e.g. UserManager.Users.Where(...).FirstOrDefaultAsync) can be unit tested against a +/// mocked UserManager.Users without a database. Standard EF unit-testing scaffold. +/// +internal static class TestAsyncQueryable +{ + public static IQueryable AsAsyncQueryable(this IEnumerable source) => + new TestAsyncEnumerable(source); +} + +internal sealed class TestAsyncQueryProvider : IAsyncQueryProvider +{ + private readonly IQueryProvider _inner; + + internal TestAsyncQueryProvider(IQueryProvider inner) => _inner = inner; + + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + + public object? Execute(Expression expression) => _inner.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Execute(expression); + + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) + { + // TResult is Task; run the query synchronously through the base provider and wrap the result. + var resultType = typeof(TResult).GetGenericArguments()[0]; + var executionResult = _inner.Execute(expression); + var fromResult = typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(resultType); + return (TResult)fromResult.Invoke(null, new[] { executionResult })!; + } +} + +internal sealed class TestAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable +{ + public TestAsyncEnumerable(IEnumerable enumerable) : base(enumerable) { } + + public TestAsyncEnumerable(Expression expression) : base(expression) { } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); + + IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); +} + +internal sealed class TestAsyncEnumerator : IAsyncEnumerator +{ + private readonly IEnumerator _inner; + + public TestAsyncEnumerator(IEnumerator inner) => _inner = inner; + + public T Current => _inner.Current; + + public ValueTask MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext()); + + public ValueTask DisposeAsync() + { + _inner.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/Tests/Identity.Tests/Support/TestUiCulture.cs b/src/Tests/Identity.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..54b69f1b47 --- /dev/null +++ b/src/Tests/Identity.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Identity.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} diff --git a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs index 72e8a0e36f..de8a53d385 100644 --- a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; using FSH.Modules.Identity.Features.v1.Groups.CreateGroup; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class CreateGroupCommandValidatorTests { - private readonly CreateGroupCommandValidator _sut = new(); + private readonly CreateGroupCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Name Validation diff --git a/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs index 399d4de894..d85b145aa6 100644 --- a/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; using FSH.Modules.Identity.Features.v1.Users.DeleteUser; +using Identity.Tests.Support; using Shouldly; using Xunit; @@ -7,7 +8,7 @@ namespace Identity.Tests.Validators; public sealed class DeleteUserCommandValidatorTests { - private readonly DeleteUserCommandValidator _sut = new(); + private readonly DeleteUserCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); [Fact] public void Validate_Should_Pass_When_IdIsProvided() diff --git a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs index c0c1cdb2b9..305a1c76d7 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; using FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class UpdateGroupCommandValidatorTests { - private readonly UpdateGroupCommandValidator _sut = new(); + private readonly UpdateGroupCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Id Validation diff --git a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs index ee98206a87..f486c769c4 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs @@ -1,5 +1,8 @@ +using System.Globalization; +using System.Linq; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; using FSH.Modules.Identity.Features.v1.Users.UpdateUser; +using Identity.Tests.Support; using Shouldly; using Xunit; @@ -7,7 +10,21 @@ namespace Identity.Tests.Validators; public sealed class UpdateUserCommandValidatorTests { - private readonly UpdateUserCommandValidator _sut = new(); + private readonly UpdateUserCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); + + private static TResult WithCulture(string culture, Func action) + { + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + return action(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } [Fact] public void Validate_Should_Pass_When_ValidMinimalCommand() @@ -40,10 +57,10 @@ public void Validate_Should_Fail_When_IdIsEmpty() public void Validate_Should_Fail_When_FirstNameExceedsMaxLength() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - FirstName = new string('a', 51) + var command = new UpdateUserCommand + { + Id = "user-123", + FirstName = new string('a', 51) }; // Act @@ -58,10 +75,10 @@ public void Validate_Should_Fail_When_FirstNameExceedsMaxLength() public void Validate_Should_Fail_When_EmailIsInvalid() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - Email = "not-an-email" + var command = new UpdateUserCommand + { + Id = "user-123", + Email = "not-an-email" }; // Act @@ -76,18 +93,69 @@ public void Validate_Should_Fail_When_EmailIsInvalid() public void Validate_Should_Fail_When_DeleteImageAndUploadImage_Simultaneously() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - DeleteCurrentImage = true, - Image = new FSH.Framework.Shared.Storage.FileUploadRequest { FileName = "test.png", Data = [0] } + var command = new UpdateUserCommand + { + Id = "user-123", + DeleteCurrentImage = true, + Image = new FSH.Framework.Shared.Storage.FileUploadRequest { FileName = "test.png", Data = [0] } }; - // Act - var result = _sut.Validate(command); + // Act — pin en-US so the localized message resolves to the neutral (English) catalog. + var result = WithCulture("en-US", () => _sut.Validate(command)); // Assert result.IsValid.ShouldBeFalse(); result.Errors.ShouldContain(e => e.ErrorMessage == "You cannot upload a new image and delete the current one simultaneously."); } + + [Theory] + [InlineData("pt-BR", true)] + [InlineData("en-US", true)] + [InlineData(null, true)] + // Empty/whitespace is treated as "not provided": the .When(!IsNullOrWhiteSpace) guard skips the + // rule, so an empty locale is valid (the caller simply isn't changing it). + [InlineData("", true)] + // Case matters: SupportedCultures.Tags.Contains is ordinal, so a wrong-case tag is rejected — + // pins the ordinal comparison against an accidental case-insensitive refactor. + [InlineData("pt-br", false)] + [InlineData("PT-BR", false)] + [InlineData("xx-YY", false)] + [InlineData("notaculture", false)] + public void Locale_Must_Be_Supported_Or_Null(string? locale, bool expectedValid) + { + // Arrange + var command = new UpdateUserCommand { Id = "user-123", Locale = locale }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.Any(e => e.PropertyName == nameof(UpdateUserCommand.Locale)).ShouldBe(!expectedValid); + } + + [Fact] + public void UserId_required_message_is_localized_under_ptBR() + { + // Act + var result = WithCulture("pt-BR", () => _sut.Validate(new UpdateUserCommand { Id = "" })); + + // Assert — custom WithMessage resolves from the .pt-BR catalog. + result.Errors.Single(e => e.PropertyName == "Id").ErrorMessage + .ShouldBe("O ID do usuário é obrigatório."); + } + + [Fact] + public void Builtin_validation_message_is_localized_under_ptBR() + { + // Act — FluentValidation resolves built-in messages via CurrentUICulture (ships a pt catalog). + var result = WithCulture("pt-BR", () => + _sut.Validate(new UpdateUserCommand { Id = "user-123", Email = "not-an-email" })); + + // Assert — pin the actual Portuguese text, not merely the absence of the English one. + // "does not contain the English sentence" is satisfied by a blank message, by a raw + // resource key leaking through, and by any wrong-but-non-English string, so it stayed + // green through exactly the failures it existed to catch. + var message = result.Errors.Single(e => e.PropertyName == "Email").ErrorMessage; + message.ShouldBe("'Email' é um endereço de email inválido."); + } } diff --git a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs index 3fe0559c2c..500b8f7a54 100644 --- a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; using FSH.Modules.Identity.Features.v1.Roles.UpsertRole; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class UpsertRoleCommandValidatorTests { - private readonly UpsertRoleCommandValidator _sut = new(); + private readonly UpsertRoleCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Name Validation diff --git a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs index 4c2939c454..e8b7898023 100644 --- a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs +++ b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs @@ -55,7 +55,8 @@ public sealed class MiddlewareWebApplicationFactory : WebApplicationFactory +/// The 401 body is written by JwtBearer's OnChallenge, not by the global exception handler, so it +/// is the one error response that could stay English while every other one is negotiated. This +/// pins it to the request's culture. It also pins the ordering the localization depends on: +/// UseRequestLocalization sits ahead of UseAuthorization, where the challenge is emitted. +/// +[Collection(MiddlewareCollectionDefinition.Name)] +public sealed class ChallengeLocalizationTests +{ + private readonly MiddlewareWebApplicationFactory _factory; + + public ChallengeLocalizationTests(MiddlewareWebApplicationFactory factory) + { + _factory = factory; + } + + private static async Task<(HttpStatusCode Status, string Title, string Detail)> ChallengeAsync( + HttpClient client, + string? acceptLanguage) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/identity/profile"); + if (acceptLanguage is not null) + { + request.Headers.Add("Accept-Language", acceptLanguage); + } + + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + return ( + response.StatusCode, + root.GetProperty("title").GetString() ?? string.Empty, + root.GetProperty("detail").GetString() ?? string.Empty); + } + + [Fact] + public async Task Challenge_Should_ReturnPortugueseProblemDetails_When_AcceptLanguageIsPtBR() + { + // Arrange + using var client = _factory.CreateClient(); + + // Act + var (status, title, detail) = await ChallengeAsync(client, "pt-BR"); + + // Assert + status.ShouldBe(HttpStatusCode.Unauthorized); + title.ShouldBe("Não autorizado"); + detail.ShouldBe("É necessário autenticar-se para acessar este recurso."); + } + + [Fact] + public async Task Challenge_Should_ReturnEnglishProblemDetails_When_NoAcceptLanguageIsSent() + { + // Arrange + using var client = _factory.CreateClient(); + + // Act + var (status, title, detail) = await ChallengeAsync(client, acceptLanguage: null); + + // Assert + status.ShouldBe(HttpStatusCode.Unauthorized); + title.ShouldBe("Unauthorized"); + detail.ShouldBe("Authentication is required to access this resource."); + } +} diff --git a/src/Tests/Integration.Tests/Infrastructure/Dtos.cs b/src/Tests/Integration.Tests/Infrastructure/Dtos.cs index f88a09ab2a..515ee0e933 100644 --- a/src/Tests/Integration.Tests/Infrastructure/Dtos.cs +++ b/src/Tests/Integration.Tests/Infrastructure/Dtos.cs @@ -31,6 +31,7 @@ public sealed class UserDto public bool EmailConfirmed { get; set; } public string? PhoneNumber { get; set; } public string? ImageUrl { get; set; } + public string? Locale { get; set; } } public sealed class RoleDto diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index ab8cfe3c65..59fcf52ad5 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -41,7 +41,8 @@ public sealed class FshWebApplicationFactory : WebApplicationFactory, I .WithCleanUp(true) .Build(); - private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest") + // quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. + private readonly MinioContainer _minio = new MinioBuilder("quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z") .WithUsername(MinioAccessKey) .WithPassword(MinioSecretKey) .WithAutoRemove(true) diff --git a/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs b/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs index f999e85300..83c7973375 100644 --- a/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs +++ b/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs @@ -111,6 +111,43 @@ public async Task UpdateProfile_Should_Return400_When_PhoneNumberExceedsMaxLengt response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } + [Fact] + public async Task UpdateProfile_Should_KeepChosenLocale_When_BodyCarriesAnEmptyLocale() + { + // Arrange — the validator reads an empty locale as "not provided" (its rule is + // guarded by .When(!IsNullOrWhiteSpace)), so an edit that serialises the field as + // "" must leave the language the user already picked alone. + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "upd-locale"); + using var userClient = await _auth.CreateAuthenticatedClientAsync(user.Email, user.Password); + + var chosen = await userClient.PutAsJsonAsync( + $"{TestConstants.IdentityBasePath}/profile", new + { + firstName = "Ana", + lastName = "Souza", + locale = "pt-BR" + }); + chosen.StatusCode.ShouldBe(HttpStatusCode.OK); + + // Act — a text-only edit whose form serialises the untouched locale field as "". + var response = await userClient.PutAsJsonAsync( + $"{TestConstants.IdentityBasePath}/profile", new + { + firstName = "Ana Maria", + lastName = "Souza", + locale = "" + }); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var profile = await userClient.GetAsync($"{TestConstants.IdentityBasePath}/profile"); + var dto = await profile.DeserializeAsync(); + dto.FirstName.ShouldBe("Ana Maria"); + dto.Locale.ShouldBe("pt-BR"); + } + #endregion #region SetProfileImage (PUT /profile/image) diff --git a/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs new file mode 100644 index 0000000000..d5c38d6a3a --- /dev/null +++ b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Globalization; +using FSH.Modules.Multitenancy.Provisioning; +using System.Linq; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Multitenancy.Tests.Localization; + +// Proves the MultitenancyResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class MultitenancyResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(MultitenancyResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // MultitenancyResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // MultitenancyResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Multitenancy.RootTenantCannotBeDeactivated"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Multitenancy.RootTenantCannotBeDeactivated' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("The root tenant cannot be deactivated."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Multitenancy.RootTenantCannotBeDeactivated"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Multitenancy.RootTenantCannotBeDeactivated' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("O tenant raiz não pode ser desativado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which puts an + // English word inside an otherwise translated sentence, so every member needs both entries. + [Theory] + [InlineData(typeof(TenantProvisioningStatus))] + public void Every_enum_member_that_reaches_a_message_is_translated(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + var pt = KeysFor("pt-BR"); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + pt.ShouldContain(key); + } + } +} diff --git a/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs b/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..5d8c6a6236 --- /dev/null +++ b/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Multitenancy.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class MultitenancyResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Tickets.Tests/GlobalUsings.cs b/src/Tests/Tickets.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..3a6ad15e89 --- /dev/null +++ b/src/Tests/Tickets.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Shouldly; +global using Xunit; diff --git a/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs new file mode 100644 index 0000000000..6ff30ee1c1 --- /dev/null +++ b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs @@ -0,0 +1,128 @@ +using System; +using System.Globalization; +using FSH.Modules.Tickets.Contracts.Dtos; +using System.Linq; +using FSH.Modules.Tickets.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Tickets.Tests.Localization; + +// Proves the TicketsResources catalog is embedded under the correct manifest name so the module +// resx resolves at runtime. A wrong manifest name would flip ResourceNotFound and leak raw keys or +// English text, and a missing pt-BR entry would ship English as if it were translated. Both are caught +// here. This is the module's only unit test project, added when Tickets exception bodies were localized. +public sealed class TicketsResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(TicketsResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // TicketsResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // TicketsResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Tickets.ClosedCannotResolve"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Tickets.ClosedCannotResolve' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("A closed ticket cannot be resolved — reopen it first."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Tickets.ClosedCannotResolve"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Tickets.ClosedCannotResolve' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Um chamado fechado não pode ser resolvido. Reabra-o primeiro."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // TicketNotFound carries the ticket id ({0}); OnlyResolvedCanClose carries the status ({0}). + [Fact] + public void Parameterized_keys_format_args_in_both_cultures() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + localizer["Tickets.TicketNotFound", "abc"].Value.ShouldBe("Ticket abc not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + localizer["Tickets.TicketNotFound", "abc"].Value.ShouldBe("Chamado abc não encontrado."); + // The status argument arrives already localized: GlobalExceptionHandler resolves an enum + // argument through this same catalog before formatting, so asserting the raw "Open" here + // would pin a sentence no user ever sees. + localizer["Tickets.OnlyResolvedCanClose", localizer["TicketStatus.Open"].Value].Value + .ShouldBe("Somente um chamado resolvido pode ser fechado. O status atual é Aberto. Resolva-o primeiro."); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which puts an + // English word inside an otherwise translated sentence, so every member needs both entries. + [Theory] + [InlineData(typeof(TicketStatus))] + public void Every_enum_member_that_reaches_a_message_is_translated(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + var pt = KeysFor("pt-BR"); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + pt.ShouldContain(key); + } + } +} diff --git a/src/Tests/Tickets.Tests/Tickets.Tests.csproj b/src/Tests/Tickets.Tests/Tickets.Tests.csproj new file mode 100644 index 0000000000..0cc930f5df --- /dev/null +++ b/src/Tests/Tickets.Tests/Tickets.Tests.csproj @@ -0,0 +1,25 @@ + + + + Tickets.Tests + Tickets.Tests + false + true + $(NoWarn);CA1515;CA1861;CA1707 + + + + + + + + + + + + + + + + + diff --git a/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs b/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs index e4bef7aa96..b43c0313fa 100644 --- a/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs +++ b/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs @@ -2,6 +2,7 @@ using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Services; +using Webhooks.Tests.Support; namespace Webhooks.Tests; @@ -13,7 +14,7 @@ namespace Webhooks.Tests; /// public sealed class CreateWebhookSubscriptionSsrfValidatorTests { - private readonly CreateWebhookSubscriptionCommandValidator _validator = new(); + private readonly CreateWebhookSubscriptionCommandValidator _validator = new(WebhooksResourcesLocalizerFactory.Create()); [Theory] [InlineData("http://169.254.169.254/latest/meta-data/")] // cloud instance metadata diff --git a/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs b/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs new file mode 100644 index 0000000000..b09e8ba919 --- /dev/null +++ b/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Webhooks.Tests.Localization; + +// Proves the WebhooksResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class WebhooksResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(WebhooksResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // WebhooksResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // WebhooksResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Webhooks.SubscriptionNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Webhooks.SubscriptionNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Webhook subscription {0} not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Webhooks.SubscriptionNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Webhooks.SubscriptionNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Inscrição de webhook {0} não encontrada."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Webhooks.Tests/Support/TestUiCulture.cs b/src/Tests/Webhooks.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..685290eefb --- /dev/null +++ b/src/Tests/Webhooks.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Webhooks.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} diff --git a/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs b/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..d14626c129 --- /dev/null +++ b/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Webhooks.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class WebhooksResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs b/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs index c7794b29a4..54a8953374 100644 --- a/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs +++ b/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs @@ -1,20 +1,25 @@ using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Contracts.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Contracts.v1.TestWebhookSubscription; +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.Localization; using FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.TestWebhookSubscription; +using Webhooks.Tests.Support; namespace Webhooks.Tests.Validators; public sealed class WebhookValidatorTests { + private static readonly IStringLocalizer Localizer = WebhooksResourcesLocalizerFactory.Create(); + #region CreateWebhookSubscription [Fact] public void Create_Should_Pass_When_Url_Absolute_And_Events_Present() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("https://example.com/hook", ["user.created"], "secret"); var result = validator.Validate(command); @@ -25,7 +30,7 @@ public void Create_Should_Pass_When_Url_Absolute_And_Events_Present() [Fact] public void Create_Should_Fail_When_Url_Empty() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand(string.Empty, ["user.created"], null); var result = validator.Validate(command); @@ -37,7 +42,7 @@ public void Create_Should_Fail_When_Url_Empty() [Fact] public void Create_Should_Fail_When_Url_Not_Absolute() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("not-a-url", ["user.created"], null); var result = validator.Validate(command); @@ -49,7 +54,7 @@ public void Create_Should_Fail_When_Url_Not_Absolute() [Fact] public void Create_Should_Fail_When_Url_Relative() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("/relative/path", ["user.created"], null); var result = validator.Validate(command); @@ -60,7 +65,7 @@ public void Create_Should_Fail_When_Url_Relative() [Fact] public void Create_Should_Fail_When_Events_Empty() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("https://example.com", [], null); var result = validator.Validate(command);