diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 694f356..0ec0cd4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,10 +7,10 @@ apply: always ## Build, test, and lint commands ### Backend and solution -- Install the Aspire workload before using the AppHost: `dotnet workload install` +- Install the Aspire workload before using the AppHost: `dotnet workload install aspire` - Restore packages: `dotnet restore` - Build the solution: `dotnet build --configuration Release` -- Use `dotnet build` as the .NET lint gate. `Directory.Build.props` enables analyzers, enforces code style in build, and treats warnings as errors across all `.csproj` files. +- Use `dotnet build` as the .NET lint gate. `Directory.Build.props` enables `AnalysisMode=All`, `EnforceCodeStyleInBuild`, `TreatWarningsAsErrors`, and SonarAnalyzer across all `.csproj` files. - Run all tests: `dotnet test --configuration Release` - Run one test project: `dotnet test Tests\Domain.UnitTests\Domain.UnitTests.csproj --configuration Release` - Run a single test class or method: `dotnet test Tests\Domain.UnitTests\Domain.UnitTests.csproj --configuration Release --filter "FullyQualifiedName~Domain.UnitTests.BookTests"` @@ -25,30 +25,65 @@ apply: always - Install dependencies: `pnpm install` - Start the admin app: `pnpm dev` - Build the admin app: `pnpm build` -- Lint the admin app: `pnpm lint` -- Regenerate API hooks and Zod schemas: `pnpm generate` +- Lint the admin app: `pnpm lint` (eslint with `--max-warnings=0`) +- Regenerate API hooks and Zod schemas: `pnpm generate` (runs orval against `http://localhost:5049/openapi/v1.json`) ## High-level architecture - `CleanArchitecture.sln` is split into Core (`CleanArchitecture.Domain`, `CleanArchitecture.Application`), Infrastructure (`CleanArchitecture.Infrastructure`, `CleanArchitecture.Infrastructure.Persistence`), Presentation (`CleanArchitecture.Presentation\API`, `CleanArchitecture.Presentation\admin`), Aspire (`CleanArchitecture.AppHost`, `CleanArchitecture.ServiceDefaults`), and four test projects. - `CleanArchitecture.Presentation\API\Program.cs` is the backend composition root. It adds Aspire service defaults first, then wires the Application, Infrastructure, Infrastructure.Persistence, and Presentation layers through their `Add*Services()` extension methods. - The API is a versioned Minimal API. Endpoints are grouped under `/api/v{version:apiVersion}` and then mapped by feature extension classes such as `BookEndpoints`. -- `CleanArchitecture.Infrastructure.Persistence` owns EF Core concerns: `ApplicationDbContext`, entity configurations, migrations, SaveChanges interceptors, and the unit-of-work implementation. -- `CleanArchitecture.Aspire\CleanArchitecture.AppHost\AppHost.cs` is the real local-dev entry point. In Development it starts Postgres + PgAdmin, Keycloak with realm import, the API, and the Next.js admin app. In Testing it starts only the API and an ephemeral Postgres database, which is why integration tests do not need a separately managed database or Keycloak instance. +- `CleanArchitecture.Infrastructure.Persistence` owns EF Core concerns: `ApplicationDbContext`, entity configurations, migrations, SaveChanges interceptors (`AuditableEntityInterceptor` for timestamps, `DispatchDomainEventsInterceptor` for publishing domain events after save), and the unit-of-work implementation. +- `CleanArchitecture.Aspire\CleanArchitecture.AppHost\AppHost.cs` is the real local-dev entry point. It has three environment modes: **Development** starts Postgres + PgAdmin, Keycloak with realm import, the API, and the Next.js admin app. **Testing** starts only the API and an ephemeral Postgres database (no Keycloak; `TestAuthHandler` grants all roles). **Production** targets Azure Container Apps, PostgreSQL Flexible Server, Key Vault, and Application Insights. - `CleanArchitecture.Aspire\CleanArchitecture.ServiceDefaults\Extensions.cs` adds the shared cross-cutting runtime behavior: Serilog, OpenTelemetry, service discovery, resilience handlers, and health endpoints. -- `CleanArchitecture.Presentation\admin` is a Next.js 16 App Router admin app. It uses NextAuth with Keycloak, TanStack Query for API hooks, and generated API clients/Zod schemas under `src\lib\api` and `src\lib\api\zod`. -- The Next.js frontend acts as a Backend for Frontend (BFF), with only the frontend and Keycloak admin UI publicly exposed. The .NET API remains private, allowing internal HTTP communication between the frontend, API, and Keycloak. +- `CleanArchitecture.Presentation\admin` is a Next.js 16 App Router admin app acting as a **Backend for Frontend (BFF)**. Only the frontend and Keycloak admin UI are publicly exposed; the .NET API remains private. The admin uses NextAuth with Keycloak, TanStack Query for server state, and orval-generated API clients/Zod schemas under `src\lib\api` and `src\lib\api\zod`. ## Key conventions -- Preserve the layer boundaries enforced by `Tests\Architecture.UnitTests\ArchitectureTests.cs`. Domain must not reference Application, Infrastructure, or Presentation; Application must not reference Infrastructure or Presentation; Infrastructure projects must not reference Presentation. -- Backend use cases follow CQRS with Mediator source generation and FluentValidation auto-registration from `CleanArchitecture.Application\DependencyInjection.cs`. New features should follow the existing `Entities\\Commands` and `Entities\\Queries` layout. -- Application and domain code return `Result` or `Result`. Minimal API endpoints translate those results to HTTP responses through `CleanArchitecture.Presentation\API\Extensions\ResultExtensions.cs` instead of building ad hoc response shapes. -- Write endpoints use `SendWithRetryAsync()` from `CleanArchitecture.Presentation\API\Configuration\MediatorPollyExtensions.cs`, which wraps mediator calls with Polly retry, circuit-breaker, and fallback behavior. Read endpoints call `sender.Send()` directly. -- Non-production startup automatically applies EF Core migrations in `CleanArchitecture.Presentation\API\Configuration\WebApplicationExtensions.cs`. The EF CLI works because `CleanArchitecture.Infrastructure.Persistence\Data\ApplicationDbContextFactory.cs` supplies a design-time Npgsql connection when Aspire has not injected `ConnectionStrings:postgresdb`. -- Authentication is environment-sensitive. Normal environments use Keycloak JWT bearer auth with named policies from `CleanArchitecture.Infrastructure\Security`; the `Testing` environment swaps in `TestAuthHandler`, which grants the integration test client all required roles. +### Architecture and layer boundaries +- Preserve the layer boundaries enforced by `Tests\Architecture.UnitTests\ArchitectureTests.cs`. Domain → no project references. Application → Domain only. Infrastructure → Application (+ Domain). Presentation → Application + Infrastructure. +- Backend use cases follow CQRS with the **Mediator** source-generator package (not MediatR) and FluentValidation auto-registration from `CleanArchitecture.Application\DependencyInjection.cs`. New features should follow the existing `Entities\\Commands\\` and `Entities\\Queries\\` layout, each containing a command/query record, handler, and validator. + +### Domain model +- Domain entities inherit from `Entity` → `AggregateRoot` (or their auditable variants `EntityAuditable` → `AggregateRootAuditable` which add `CreatedDate`/`UpdatedDate`). `Entity` provides identity-based equality and a `DomainEvents` collection. +- Aggregates expose static factory methods (e.g., `Book.Create(...)`) that perform domain validation and return `Result`. Use `DomainError` records with `ErrorType` (Validation, NotFound, Conflict, Failure) for domain-level errors, defined in a per-aggregate `*Errors` class. +- Domain events are raised via `AddDomainEvent()` on the entity and dispatched automatically by `DispatchDomainEventsInterceptor` after `SaveChangesAsync`. Handlers live in `Application\Entities\\EventHandlers\`. + +### Application layer patterns +- Command/query handlers extend `BaseRequestHandler`, which runs FluentValidation before delegating to the `HandleRequest` override. This means validation is automatic — add a `*Validator.cs` and it's picked up. +- `LoggingBehaviour<,>` is registered as a pipeline behavior. It logs all request start/end and flags slow requests (>1 second) with a warning. +- Application and domain code return `Result` or `Result` (from the `DomainValidation` package). Never throw exceptions for expected failures. + +### Minimal API endpoints +- Endpoints are static methods in feature-scoped classes (e.g., `BookEndpoints`). Each class exposes a `Map*Endpoints(this RouteGroupBuilder)` extension. +- Translate `Result` to HTTP via `ResultExtensions`: `ToCreatedResponse`, `ToProblemDetails`, `ToNoContentResponse`. Error types map to: Validation → 422, NotFound → 404, Conflict → 409, other → 400/500. Responses use RFC 9110 ProblemDetails with error codes in `extensions.errors`. +- Write endpoints use `sender.SendWithRetryAsync()` (Polly: retry 3× exponential, circuit breaker, fallback). Read endpoints use `sender.Send()` directly. +- Read endpoints use `CacheOutput` with tag-based invalidation. After a successful write, call `cacheStore.EvictByTagAsync("tag")`. +- Authorization uses named policies: `ViewerPolicy`, `EditorPolicy`, `AdminPolicy`. Permission roles from Keycloak: `view`, `create`, `edit`, `delete`. + +### Authentication +- Authentication is environment-sensitive. Normal environments use Keycloak JWT bearer auth via `AddKeycloakJwtBearer`; the `Testing` environment swaps in `TestAuthHandler`, which grants the integration test client all required roles. - OpenAPI and Scalar are only mapped in Development when the backend user secret `ScalarApi:ClientSecret` is set. This matters for `pnpm generate`, because `orval.config.ts` reads `http://localhost:5049/openapi/v1.json`. -- Treat `CleanArchitecture.Presentation\admin\src\lib\api` and `CleanArchitecture.Presentation\admin\src\lib\api\zod` as generated output. Update the backend contract and rerun `pnpm generate` instead of hand-editing those files. The repo also relies on `scripts\fix-zod-schemas.js` after generation. -- Use `pnpm` for the admin app. The repo commits `pnpm-lock.yaml`, the AppHost starts the frontend with `.WithPnpm()`, and `.aiassistant\rules\instructions.md` assumes pnpm-based frontend workflows. -- The admin app behaves like a thin BFF. `next.config.ts` rewrites browser calls from `/api/v1/*` to `API_BASE_URL`, while `src\lib\orval-fetch.ts` attaches the NextAuth access token for both server-side and client-side requests. -- Local secrets live in two places: backend user secrets for `ScalarApi:ClientSecret`, and `CleanArchitecture.Presentation\admin\.env.local` for `API_BASE_URL`, `NEXTAUTH_*`, and `KEYCLOAK_*`. + +### Database and migrations +- Non-production startup automatically applies EF Core migrations in `WebApplicationExtensions.cs`. The EF CLI works because `ApplicationDbContextFactory.cs` supplies a design-time Npgsql connection when Aspire has not injected `ConnectionStrings:postgresdb`. +- Entity configurations use `IEntityTypeConfiguration` in `Infrastructure.Persistence`, auto-discovered via `ApplyConfigurationsFromAssembly`. + +### Testing patterns +- Integration tests use `DistributedApplicationTestingBuilder` to spin up the full Aspire stack with `--environment=Testing`. Tests share a fixture via `[Collection("DistributedApplication collection")]` and create `HttpClient` per class via `App.CreateHttpClient("cleanarchitecture-api")`. +- Unit tests use xUnit + Moq. Domain and Application behavior are tested in `Tests\Domain.UnitTests` and `Tests\Application.UnitTests`. +- CI workflow (`.github\workflows\dotnet.yml`) builds with `--configuration Release`, runs all tests, and sets `ASPIRE_HOSTING_TESTING_DISABLE_DASHBOARD=true`. + +### Admin app (Next.js) +- Use `pnpm` exclusively. The repo commits `pnpm-lock.yaml` and the AppHost starts the frontend with `.WithPnpm()`. +- Treat `src\lib\api` and `src\lib\api\zod` as **generated output**. Update the backend contract and rerun `pnpm generate` instead of hand-editing those files. +- The custom fetcher `src\lib\orval-fetch.ts` detects server vs. client context: server-side prepends `API_BASE_URL` and uses `getServerSession()`; client-side sends relative URLs and uses `getSession()`. Both inject `Authorization: Bearer` headers. On 401, it triggers Keycloak re-authentication. +- Routes are organized under `src\app\(admin)\` (protected layout with sidebar) and unauthenticated routes at root (`/signin`). An API proxy at `src\app\api\v1\[...path]\route.ts` forwards browser calls to the backend. +- Frontend has i18n support (en, fa, ar) with RTL layout via `LanguageContext`. Theme supports dark/light mode via `ThemeContext`. +- Permission-based UI: `useUserPermissions()` hook reads roles from the NextAuth session (populated from Keycloak JWT) to conditionally render create/edit/delete actions. +- Forms use React Hook Form + Zod validation + a `fieldErrorMap` pattern that maps API `ProblemDetails` error codes to form field names for inline error display. +- Context provider hierarchy in root layout: `AuthProvider` → `QueryProvider` (TanStack Query, 60s stale time) → `LanguageProvider` → `ThemeProvider` → `SidebarProvider`. +- Local secrets: backend user secrets for `ScalarApi:ClientSecret`, and `CleanArchitecture.Presentation\admin\.env.local` for `API_BASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `KEYCLOAK_ISSUER`. + +### Commits and PRs +- Use conventional commits: `feat:`, `fix:`, `chore:`, `refactor:`, `test:`, `docs:`. diff --git a/CleanArchitecture.Domain/Resources/BookErrors.ar.resx b/CleanArchitecture.Domain/Resources/BookErrors.ar.resx new file mode 100644 index 0000000..2206ed1 --- /dev/null +++ b/CleanArchitecture.Domain/Resources/BookErrors.ar.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 + + + العنوان مطلوب ولا يمكن أن يكون فارغًا. + + + لا يمكن أن يتجاوز العنوان 200 حرف. + + + النوع المحدد غير مدعوم. + + + لم يتم العثور على الكتاب بالمعرف المحدد. + + + يجب أن يتكون العنوان من 3 أحرف على الأقل. + + + النوع مطلوب ولا يمكن أن يكون فارغًا. + + + يجب أن يكون طول النوع بين 1 و 2 حرف. + + diff --git a/CleanArchitecture.Domain/Resources/BookErrors.fa.resx b/CleanArchitecture.Domain/Resources/BookErrors.fa.resx new file mode 100644 index 0000000..e14f4d1 --- /dev/null +++ b/CleanArchitecture.Domain/Resources/BookErrors.fa.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 + + + عنوان الزامی است و نمی‌تواند خالی باشد. + + + عنوان نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد. + + + ژانر مشخص شده پشتیبانی نمی‌شود. + + + کتاب با شناسه مشخص شده یافت نشد. + + + عنوان باید حداقل ۳ کاراکتر باشد. + + + ژانر الزامی است و نمی‌تواند خالی باشد. + + + طول ژانر باید بین ۱ تا ۲ کاراکتر باشد. + + diff --git a/CleanArchitecture.Domain/Validations/Book/BookErrors.cs b/CleanArchitecture.Domain/Validations/Book/BookErrors.cs index 313e474..2f654df 100644 --- a/CleanArchitecture.Domain/Validations/Book/BookErrors.cs +++ b/CleanArchitecture.Domain/Validations/Book/BookErrors.cs @@ -7,31 +7,31 @@ namespace CleanArchitecture.Domain.Validations.Book; /// public static class BookErrors { - public static readonly DomainError TitleIsRequired = DomainError.Validation( + public static DomainError TitleIsRequired => DomainError.Validation( "Book.TitleIsRequired", Resources.BookErrors.Book_TitleIsRequired); - public static readonly DomainError TitleTooLong = DomainError.Validation( + public static DomainError TitleTooLong => DomainError.Validation( "Book.TitleTooLong", Resources.BookErrors.Book_TitleTooLong); - public static readonly DomainError InvalidGenre = DomainError.Validation( + public static DomainError InvalidGenre => DomainError.Validation( "Book.InvalidGenre", Resources.BookErrors.Book_InvalidGenre); - public static readonly DomainError BookNotFound = DomainError.NotFound( + public static DomainError BookNotFound => DomainError.NotFound( "Book.NotFound", Resources.BookErrors.Book_NotFound); - public static readonly DomainError TitleIsTooShort = DomainError.Validation( + public static DomainError TitleIsTooShort => DomainError.Validation( "Book.TitleIsTooShort", Resources.BookErrors.Book_TitleIsTooShort); - public static readonly DomainError GenreIsRequired = DomainError.Validation( + public static DomainError GenreIsRequired => DomainError.Validation( "Book.GenreIsRequired", Resources.BookErrors.Book_GenreIsRequired); - public static readonly DomainError GenreIsInvalidLength = DomainError.Validation( + public static DomainError GenreIsInvalidLength => DomainError.Validation( "Book.GenreIsInvalidLength", Resources.BookErrors.Book_GenreIsInvalidLength); } diff --git a/CleanArchitecture.Presentation/API/Program.cs b/CleanArchitecture.Presentation/API/Program.cs index e3994a6..be81a70 100644 --- a/CleanArchitecture.Presentation/API/Program.cs +++ b/CleanArchitecture.Presentation/API/Program.cs @@ -13,6 +13,8 @@ .AddInfrastructurePersistenceServices(builder.Configuration) .AddPresentationServices(builder); +builder.Services.AddLocalization(); + builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; @@ -36,6 +38,15 @@ app.UseResponseCompression(); +string[] supportedCultures = ["en", "fa", "ar"]; + +app.UseRequestLocalization(options => +{ + options.SetDefaultCulture(supportedCultures[0]) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures); +}); + app.UseAuthentication(); app.UseAuthorization(); @@ -57,4 +68,4 @@ versionedApi.MapBookEndpoints(); -await app.RunAsync().ConfigureAwait(false); \ No newline at end of file +await app.RunAsync().ConfigureAwait(false); diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/PageContent.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/PageContent.tsx new file mode 100644 index 0000000..2adde07 --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/PageContent.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useGetApiV1BooksId } from "@/lib/api/books/books"; +import { + extractApiErrors, + formatErrorMessages, +} from "@/lib/utils/error-handler"; +import ComponentCard from "@/components/common/ComponentCard"; +import { useUserPermissions } from "@/hooks/useUserPermissions"; +import { getGenreLabel } from "@/lib/books/genre"; +import { useLanguage } from "@/context/LanguageContext"; + +interface PageContentProps { + id: string; +} + +export default function PageContent({ id }: PageContentProps) { + const router = useRouter(); + const { canEdit } = useUserPermissions(); + const { t } = useLanguage(); + + const { data: response, error, isLoading } = useGetApiV1BooksId(id, { + query: { + enabled: Boolean(id), + }, + }); + + if (isLoading) { + return
{t("loading_book_details")}
; + } + + if (error) { + return ( +
+ {formatErrorMessages(extractApiErrors(error))} +
+ ); + } + + const book = response?.status === 200 && response.data.isSuccess ? response.data.value : null; + if (!book) { + return
{t("book_not_found")}
; + } + + return ( + +
+
+

{t("title")}

+

{book.title}

+
+
+

{t("genre")}

+

{getGenreLabel(book.genre)}

+
+
+ {canEdit ? ( + + {t("edit_book")} + + ) : null} + +
+
+
+ ); +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/page.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/page.tsx index 96e8875..8d30126 100644 --- a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/page.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/page.tsx @@ -1,82 +1,25 @@ -"use client"; - -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { useGetApiV1BooksId } from "@/lib/api/books/books"; -import { - extractApiErrors, - formatErrorMessages, -} from "@/lib/utils/error-handler"; -import ComponentCard from "@/components/common/ComponentCard"; +import type { Metadata } from "next"; import PageBreadcrumb from "@/components/common/PageBreadCrumb"; -import { useUserPermissions } from "@/hooks/useUserPermissions"; -import { getGenreLabel } from "@/lib/books/genre"; -import { useLanguage } from "@/context/LanguageContext"; - -export default function BookDetailPage() { - const params = useParams<{ id: string }>(); - const bookId = Array.isArray(params.id) ? params.id[0] : params.id; - const router = useRouter(); - const { canEdit } = useUserPermissions(); - const { t } = useLanguage(); - - const { data: response, error, isLoading } = useGetApiV1BooksId(bookId ?? "", { - query: { - enabled: Boolean(bookId), - }, - }); +import PageContent from "./PageContent"; - if (isLoading) { - return
{t("loading_book_details")}
; - } +interface BookDetailPageProps { + params: Promise<{ id: string }>; +} - if (error) { - return ( -
- {formatErrorMessages(extractApiErrors(error))} -
- ); - } +export const metadata: Metadata = { + title: "Book Details | Clean Architecture Admin", + description: "View book details", +}; - const book = response?.status === 200 && response.data.isSuccess ? response.data.value : null; - if (!book) { - return
{t("book_not_found")}
; - } +export default async function BookDetailPage({ params }: BookDetailPageProps) { + const { id } = await params; return (
- +
- -
-
-

{t("title")}

-

{book.title}

-
-
-

{t("genre")}

-

{getGenreLabel(book.genre)}

-
-
- {canEdit ? ( - - {t("edit_book")} - - ) : null} - -
-
-
+
); -} +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/edit/[id]/page.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/edit/[id]/page.tsx index 8a515c5..82f7e0c 100644 --- a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/edit/[id]/page.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/edit/[id]/page.tsx @@ -1,21 +1,25 @@ -"use client"; - -import { useParams } from "next/navigation"; -import BookForm from "@/components/book/BookForm"; +import type { Metadata } from "next"; import PageBreadcrumb from "@/components/common/PageBreadCrumb"; -import { useLanguage } from "@/context/LanguageContext"; +import BookForm from "@/components/book/BookForm"; -export default function EditBookPage() { - const params = useParams<{ id: string }>(); - const bookId = Array.isArray(params.id) ? params.id[0] : params.id; - const { t } = useLanguage(); +interface EditBookPageProps { + params: Promise<{ id: string }>; +} + +export const metadata: Metadata = { + title: "Edit Book | Clean Architecture Admin", + description: "Edit an existing book", +}; + +export default async function EditBookPage({ params }: EditBookPageProps) { + const { id } = await params; return (
- +
- +
); -} +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx new file mode 100644 index 0000000..686ee1f --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useLanguage } from "@/context/LanguageContext"; + +interface BookErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function BookError({ error, reset }: BookErrorProps) { + const { t } = useLanguage(); + + return ( +
+

{t("something_went_wrong")}

+

{error.message}

+ +
+ ); +} diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/list/page.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/list/page.tsx index 632dfb1..b1b2b6b 100644 --- a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/list/page.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/list/page.tsx @@ -1,4 +1,4 @@ -import { getAuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAuthOptions } from "@/lib/auth/auth-options"; import { Metadata } from "next"; import { getServerSession } from "next-auth"; import { hasRole } from "@/lib/auth/permissions"; diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/loading.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/loading.tsx new file mode 100644 index 0000000..8050d62 --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/loading.tsx @@ -0,0 +1,7 @@ +export default function BookLoading() { + return ( +
+
+
+ ); +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/api/auth/[...nextauth]/route.ts b/CleanArchitecture.Presentation/admin/src/app/api/auth/[...nextauth]/route.ts index afb2ffb..0a8eb5c 100644 --- a/CleanArchitecture.Presentation/admin/src/app/api/auth/[...nextauth]/route.ts +++ b/CleanArchitecture.Presentation/admin/src/app/api/auth/[...nextauth]/route.ts @@ -1,93 +1,11 @@ import type { NextRequest } from "next/server"; -import NextAuth, { type NextAuthOptions } from "next-auth"; -import KeycloakProvider from "next-auth/providers/keycloak"; -import { getEnvVars } from "@/config/env-vars"; +import NextAuth from "next-auth"; +import { getAuthOptions } from "@/lib/auth/auth-options"; type RouteHandlerContext = { params: Promise<{ nextauth: string[] }>; }; -type JwtPayload = { - roles?: string[]; - realm_access?: { - roles?: string[]; - }; -}; - -function parseJwtPayload(token: string): JwtPayload | null { - try { - const payload = token.split(".")[1]; - - if (!payload) { - return null; - } - - return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as JwtPayload; - } catch { - return null; - } -} - -function extractRoles(accessToken: string): string[] { - const payload = parseJwtPayload(accessToken); - const roles = payload?.roles ?? payload?.realm_access?.roles ?? []; - - return Array.isArray(roles) ? roles : []; -} - -export function getAuthOptions(): NextAuthOptions { - const { KEYCLOAK_ISSUER, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, NEXTAUTH_SECRET } = getEnvVars(); - const keycloakIssuer = KEYCLOAK_ISSUER.replace(/\/+$/, ""); - - return { - providers: [ - KeycloakProvider({ - clientId: KEYCLOAK_CLIENT_ID, - clientSecret: KEYCLOAK_CLIENT_SECRET, - issuer: keycloakIssuer, - authorization: { - params: { - scope: "openid profile email permissions", - }, - }, - }), - ], - pages: { - signIn: "/signin", - }, - secret: NEXTAUTH_SECRET, - session: { - strategy: "jwt", - }, - callbacks: { - async jwt({ token, account }) { - if (account?.access_token) { - token.accessToken = account.access_token; - token.roles = extractRoles(account.access_token); - } - - if (account?.id_token) { - token.idToken = account.id_token; - } - - return token; - }, - async session({ session, token }) { - if (session.user && token.sub) { - session.user.id = token.sub; - session.user.roles = Array.isArray(token.roles) ? token.roles : []; - } - - if (typeof token.accessToken === "string") { - session.accessToken = token.accessToken; - } - - return session; - }, - }, - } satisfies NextAuthOptions; -} - async function handleAuth(request: NextRequest, context: RouteHandlerContext) { return NextAuth(request, context, getAuthOptions()); } @@ -98,4 +16,4 @@ export function GET(request: NextRequest, context: RouteHandlerContext) { export function POST(request: NextRequest, context: RouteHandlerContext) { return handleAuth(request, context); -} +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/not-found.tsx b/CleanArchitecture.Presentation/admin/src/app/not-found.tsx index 5494db8..73eea8c 100644 --- a/CleanArchitecture.Presentation/admin/src/app/not-found.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/not-found.tsx @@ -40,7 +40,7 @@ export default function NotFound() {
{/* */}

- © {new Date().getFullYear()} - TailAdmin + © {new Date().getFullYear()} - Clean Architecture

); diff --git a/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx b/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx index 164d4cc..2986563 100644 --- a/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx +++ b/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx @@ -1,23 +1,12 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; -import { - getGetApiV1BooksIdQueryKey, - getGetApiV1BooksQueryKey, - useGetApiV1BooksId, - usePostApiV1Books, - usePutApiV1BooksId, -} from "@/lib/api/books/books"; -import type { CreateBookCommand, UpdateBookCommand } from "@/lib/api/model"; -import { - extractApiErrors, - getFieldErrors, - type DomainError, -} from "@/lib/utils/error-handler"; +import { useGetApiV1BooksId } from "@/lib/api/books/books"; +import { extractApiErrors } from "@/lib/utils/error-handler"; +import { useBookMutation } from "@/hooks/useBookMutation"; import ComponentCard from "../common/ComponentCard"; import Label from "../form/Label"; import Input from "../form/input/InputField"; @@ -26,11 +15,6 @@ import { bookSchema, type BookFormValues } from "@/lib/validations/book"; import { genreOptions } from "@/lib/books/genre"; import { useLanguage } from "@/context/LanguageContext"; -const fieldErrorMap = { - Title: "title", - Genre: "genre", -} satisfies Record; - interface BookFormProps { id?: string; } @@ -39,8 +23,6 @@ export default function BookForm({ id }: BookFormProps) { const isEditMode = Boolean(id); const bookId = id ?? ""; const router = useRouter(); - const queryClient = useQueryClient(); - const [serverErrors, setServerErrors] = useState([]); const { t } = useLanguage(); const { @@ -58,6 +40,8 @@ export default function BookForm({ id }: BookFormProps) { }, }); + const { submit, isSaving, serverErrors } = useBookMutation({ id, setError }); + const { data: bookResponse, error: bookError, @@ -84,92 +68,15 @@ export default function BookForm({ id }: BookFormProps) { }); }, [bookResponse, isEditMode, reset]); - async function invalidateBookQueries(): Promise { - await queryClient.invalidateQueries({ - queryKey: getGetApiV1BooksQueryKey(), - }); - - if (isEditMode) { - await queryClient.invalidateQueries({ - queryKey: getGetApiV1BooksIdQueryKey(bookId), - }); - } - } - - function applyDomainErrors(domainErrors: DomainError[]): void { - const fieldErrors = getFieldErrors(domainErrors, fieldErrorMap); - - (Object.keys(fieldErrors) as Array).forEach((fieldName) => { - const message = fieldErrors[fieldName]; - if (message) { - setError(fieldName, { - type: "server", - message, - }); - } - }); - } - - function handleMutationError(error: unknown): void { - const domainErrors = extractApiErrors(error); - setServerErrors(domainErrors); - applyDomainErrors(domainErrors); - } - - const createMutation = usePostApiV1Books({ - mutation: { - onSuccess: async (response) => { - if (response.status === 201) { - await invalidateBookQueries(); - router.push("/book/list"); - } - }, - onError: handleMutationError, - }, - }); - - const updateMutation = usePutApiV1BooksId({ - mutation: { - onSuccess: async (response) => { - if (response.status === 204) { - await invalidateBookQueries(); - router.push("/book/list"); - } - }, - onError: handleMutationError, - }, - }); - - const isSaving = createMutation.isPending || updateMutation.isPending; const displayedServerErrors = bookError ? extractApiErrors(bookError) : serverErrors; - function onSubmit(formData: BookFormValues): void { - setServerErrors([]); - - if (isEditMode) { - const updateCommand: UpdateBookCommand = { - id: bookId, - title: formData.title, - genre: formData.genre, - }; - - updateMutation.mutate({ - id: bookId, - data: updateCommand, - }); - return; - } - - createMutation.mutate({ data: formData as CreateBookCommand }); - } - if (isFetchingBook) { return
{t("loading_book_data")}
; } return ( -
+ {displayedServerErrors.length > 0 ? (

{t("validation_errors")}

@@ -196,7 +103,7 @@ export default function BookForm({ id }: BookFormProps) { {...register("title")} /> {errors.title ? ( -

{errors.title.message}

+

{t(errors.title.message as any)}

) : null}
@@ -219,7 +126,7 @@ export default function BookForm({ id }: BookFormProps) { ))} {errors.genre ? ( -

{errors.genre.message}

+

{t(errors.genre.message as any)}

) : null}

{t("genre_help_text")} diff --git a/CleanArchitecture.Presentation/admin/src/components/header/NotificationDropdown.tsx b/CleanArchitecture.Presentation/admin/src/components/header/NotificationDropdown.tsx index 2485f43..aed179e 100644 --- a/CleanArchitecture.Presentation/admin/src/components/header/NotificationDropdown.tsx +++ b/CleanArchitecture.Presentation/admin/src/components/header/NotificationDropdown.tsx @@ -4,10 +4,12 @@ import Link from "next/link"; import React, { useState } from "react"; import { Dropdown } from "../ui/dropdown/Dropdown"; import { DropdownItem } from "../ui/dropdown/DropdownItem"; +import { useLanguage } from "@/context/LanguageContext"; export default function NotificationDropdown() { const [isOpen, setIsOpen] = useState(false); const [notifying, setNotifying] = useState(true); + const { t } = useLanguage(); function toggleDropdown() { setIsOpen(!isOpen); @@ -52,11 +54,11 @@ export default function NotificationDropdown() {

- Notification + {t("notification") as React.ReactNode}
diff --git a/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx b/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx index cff6140..401e567 100644 --- a/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx +++ b/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useEffect, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Locale, defaultLocale, isRtl, dictionaries } from "@/i18n"; type LanguageContextType = { @@ -16,21 +16,23 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil const [locale, setLocaleState] = useState(defaultLocale); useEffect(() => { - // Try to load locale from localStorage on mount const savedLocale = localStorage.getItem("locale") as Locale; if (savedLocale && dictionaries[savedLocale]) { setLocaleState(savedLocale); } }, []); - const setLocale = (newLocale: Locale) => { + const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); localStorage.setItem("locale", newLocale); - }; + }, []); - const t = (key: keyof typeof dictionaries["en"]) => { - return dictionaries[locale][key] || dictionaries[defaultLocale][key] || key; - }; + const t = useCallback( + (key: keyof typeof dictionaries["en"]) => { + return dictionaries[locale][key] || dictionaries[defaultLocale][key] || key; + }, + [locale], + ); const rtl = isRtl(locale); @@ -39,8 +41,13 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil document.documentElement.lang = locale; }, [locale, rtl]); + const value = useMemo( + () => ({ locale, setLocale, t, isRtl: rtl }), + [locale, setLocale, t, rtl], + ); + return ( - + {children} ); diff --git a/CleanArchitecture.Presentation/admin/src/context/SidebarContext.tsx b/CleanArchitecture.Presentation/admin/src/context/SidebarContext.tsx index c377119..83ca275 100644 --- a/CleanArchitecture.Presentation/admin/src/context/SidebarContext.tsx +++ b/CleanArchitecture.Presentation/admin/src/context/SidebarContext.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { createContext, useContext, useState, useEffect } from "react"; +import React, { createContext, useCallback, useContext, useMemo, useState, useEffect } from "react"; type SidebarContextType = { isExpanded: boolean; @@ -51,33 +51,36 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ }; }, []); - const toggleSidebar = () => { + const toggleSidebar = useCallback(() => { setIsExpanded((prev) => !prev); - }; + }, []); - const toggleMobileSidebar = () => { + const toggleMobileSidebar = useCallback(() => { setIsMobileOpen((prev) => !prev); - }; + }, []); - const toggleSubmenu = (item: string) => { + const toggleSubmenu = useCallback((item: string) => { setOpenSubmenu((prev) => (prev === item ? null : item)); - }; + }, []); + + const value = useMemo( + () => ({ + isExpanded: isMobile ? false : isExpanded, + isMobileOpen, + isHovered, + activeItem, + openSubmenu, + toggleSidebar, + toggleMobileSidebar, + setIsHovered, + setActiveItem, + toggleSubmenu, + }), + [isMobile, isExpanded, isMobileOpen, isHovered, activeItem, openSubmenu, toggleSidebar, toggleMobileSidebar, toggleSubmenu], + ); return ( - + {children} ); diff --git a/CleanArchitecture.Presentation/admin/src/hooks/useBookMutation.ts b/CleanArchitecture.Presentation/admin/src/hooks/useBookMutation.ts new file mode 100644 index 0000000..88c8c8a --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/hooks/useBookMutation.ts @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import type { UseFormSetError } from "react-hook-form"; +import { + getGetApiV1BooksIdQueryKey, + getGetApiV1BooksQueryKey, + usePostApiV1Books, + usePutApiV1BooksId, +} from "@/lib/api/books/books"; +import type { CreateBookCommand, UpdateBookCommand } from "@/lib/api/model"; +import { + extractApiErrors, + getFieldErrors, + type DomainError, +} from "@/lib/utils/error-handler"; +import type { BookFormValues } from "@/lib/validations/book"; + +const fieldErrorMap = { + Title: "title", + Genre: "genre", +} satisfies Record; + +interface UseBookMutationOptions { + id?: string; + setError: UseFormSetError; +} + +export function useBookMutation({ id, setError }: UseBookMutationOptions) { + const isEditMode = Boolean(id); + const bookId = id ?? ""; + const router = useRouter(); + const queryClient = useQueryClient(); + const [serverErrors, setServerErrors] = useState([]); + + async function invalidateBookQueries(): Promise { + await queryClient.invalidateQueries({ + queryKey: getGetApiV1BooksQueryKey(), + }); + + if (isEditMode) { + await queryClient.invalidateQueries({ + queryKey: getGetApiV1BooksIdQueryKey(bookId), + }); + } + } + + function applyDomainErrors(domainErrors: DomainError[]): void { + const fieldErrors = getFieldErrors(domainErrors, fieldErrorMap); + + (Object.keys(fieldErrors) as Array).forEach((fieldName) => { + const message = fieldErrors[fieldName]; + if (message) { + setError(fieldName, { + type: "server", + message, + }); + } + }); + } + + function handleMutationError(error: unknown): void { + const domainErrors = extractApiErrors(error); + setServerErrors(domainErrors); + applyDomainErrors(domainErrors); + } + + const createMutation = usePostApiV1Books({ + mutation: { + onSuccess: async (response) => { + if (response.status === 201) { + await invalidateBookQueries(); + router.push("/book/list"); + } + }, + onError: handleMutationError, + }, + }); + + const updateMutation = usePutApiV1BooksId({ + mutation: { + onSuccess: async (response) => { + if (response.status === 204) { + await invalidateBookQueries(); + router.push("/book/list"); + } + }, + onError: handleMutationError, + }, + }); + + const isSaving = createMutation.isPending || updateMutation.isPending; + + function submit(formData: BookFormValues): void { + setServerErrors([]); + + if (isEditMode) { + const updateCommand: UpdateBookCommand = { + id: bookId, + title: formData.title, + genre: formData.genre, + }; + + updateMutation.mutate({ + id: bookId, + data: updateCommand, + }); + return; + } + + createMutation.mutate({ data: formData as CreateBookCommand }); + } + + return { + submit, + isSaving, + serverErrors, + setServerErrors, + }; +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/hooks/useGoBack.ts b/CleanArchitecture.Presentation/admin/src/hooks/useGoBack.ts index 87c628f..61eb706 100644 --- a/CleanArchitecture.Presentation/admin/src/hooks/useGoBack.ts +++ b/CleanArchitecture.Presentation/admin/src/hooks/useGoBack.ts @@ -1,15 +1,16 @@ +import { useCallback } from "react"; import { useRouter } from "next/navigation"; const useGoBack = () => { const router = useRouter(); - const goBack = () => { + const goBack = useCallback(() => { if (window.history.length > 1) { - router.back(); // Navigate to the previous route + router.back(); } else { - router.push("/"); // Redirect to home if no history exists + router.push("/"); } - }; + }, [router]); return goBack; }; diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json index 2b1e82e..e8b5321 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json @@ -86,5 +86,20 @@ "postal_code": "الرمز البريدي", "tax_id": "الرقم الضريبي", "edit_address": "تعديل العنوان", - "united_kingdom": "المملكة المتحدة" + "united_kingdom": "المملكة المتحدة", + "customers_demographic_desc": "عدد العملاء حسب البلد", + "something_went_wrong": "حدث خطأ ما", + "try_again": "حاول مرة أخرى", + "title_required": "العنوان مطلوب.", + "title_min_length": "يجب أن يتكون العنوان من 3 أحرف على الأقل.", + "title_max_length": "لا يمكن أن يتجاوز العنوان 200 حرف.", + "genre_required": "النوع مطلوب.", + "genre_max_length": "لا يمكن أن يتجاوز النوع 50 حرفًا.", + "notification": "إشعار", + "requests_permission_to_change": "يطلب إذنًا للتغيير", + "project_nganter_app": "مشروع - Nganter App", + "project": "مشروع", + "min_ago": "منذ دقيقة", + "hr_ago": "منذ ساعة", + "view_all_notifications": "عرض كل الإشعارات" } diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json index 58d5b53..73a1363 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json @@ -86,5 +86,20 @@ "postal_code": "Postal Code", "tax_id": "TAX ID", "edit_address": "Edit Address", - "united_kingdom": "United Kingdom" + "united_kingdom": "United Kingdom", + "customers_demographic_desc": "Number of customers based on country", + "something_went_wrong": "Something went wrong", + "try_again": "Try again", + "title_required": "Title is required.", + "title_min_length": "Title must be at least 3 characters long.", + "title_max_length": "Title cannot exceed 200 characters.", + "genre_required": "Genre is required.", + "genre_max_length": "Genre cannot exceed 50 characters.", + "notification": "Notification", + "requests_permission_to_change": "requests permission to change", + "project_nganter_app": "Project - Nganter App", + "project": "Project", + "min_ago": "min ago", + "hr_ago": "hr ago", + "view_all_notifications": "View All Notifications" } diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json index 2d1f881..be96897 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json @@ -86,5 +86,20 @@ "postal_code": "کد پستی", "tax_id": "شناسه مالیاتی", "edit_address": "ویرایش آدرس", - "united_kingdom": "انگلستان" + "united_kingdom": "انگلستان", + "customers_demographic_desc": "تعداد مشتریان بر اساس کشور", + "something_went_wrong": "مشکلی پیش آمد", + "try_again": "دوباره تلاش کنید", + "title_required": "عنوان الزامی است.", + "title_min_length": "عنوان باید حداقل ۳ کاراکتر باشد.", + "title_max_length": "عنوان نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد.", + "genre_required": "ژانر الزامی است.", + "genre_max_length": "ژانر نمی‌تواند بیشتر از ۵۰ کاراکتر باشد.", + "notification": "اعلان", + "requests_permission_to_change": "درخواست اجازه برای تغییر", + "project_nganter_app": "پروژه - Nganter App", + "project": "پروژه", + "min_ago": "دقیقه پیش", + "hr_ago": "ساعت پیش", + "view_all_notifications": "مشاهده همه اعلان‌ها" } diff --git a/CleanArchitecture.Presentation/admin/src/layout/AppSidebar.tsx b/CleanArchitecture.Presentation/admin/src/layout/AppSidebar.tsx index da6ebee..c8cdf68 100644 --- a/CleanArchitecture.Presentation/admin/src/layout/AppSidebar.tsx +++ b/CleanArchitecture.Presentation/admin/src/layout/AppSidebar.tsx @@ -1,4 +1,5 @@ "use client"; + import React, {useEffect, useRef, useState, useMemo, useCallback} from "react"; import Link from "next/link"; import Image from "next/image"; @@ -48,7 +49,7 @@ const AppSidebar: React.FC = () => { {} ); const subMenuRefs = useRef>({}); - + const isActive = useCallback((path: string) => path === pathname, [pathname]); type OpenSubmenu = { diff --git a/CleanArchitecture.Presentation/admin/src/lib/auth/auth-options.ts b/CleanArchitecture.Presentation/admin/src/lib/auth/auth-options.ts new file mode 100644 index 0000000..d248f4a --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/lib/auth/auth-options.ts @@ -0,0 +1,84 @@ +import type { NextAuthOptions } from "next-auth"; +import KeycloakProvider from "next-auth/providers/keycloak"; +import { getEnvVars } from "@/config/env-vars"; + +type JwtPayload = { + roles?: string[]; + realm_access?: { + roles?: string[]; + }; +}; + +function parseJwtPayload(token: string): JwtPayload | null { + try { + const payload = token.split(".")[1]; + + if (!payload) { + return null; + } + + return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as JwtPayload; + } catch { + return null; + } +} + +function extractRoles(accessToken: string): string[] { + const payload = parseJwtPayload(accessToken); + const roles = payload?.roles ?? payload?.realm_access?.roles ?? []; + + return Array.isArray(roles) ? roles : []; +} + +export function getAuthOptions(): NextAuthOptions { + const { KEYCLOAK_ISSUER, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, NEXTAUTH_SECRET } = getEnvVars(); + const keycloakIssuer = KEYCLOAK_ISSUER.replace(/\/+$/, ""); + + return { + providers: [ + KeycloakProvider({ + clientId: KEYCLOAK_CLIENT_ID, + clientSecret: KEYCLOAK_CLIENT_SECRET, + issuer: keycloakIssuer, + authorization: { + params: { + scope: "openid profile email permissions", + }, + }, + }), + ], + pages: { + signIn: "/signin", + }, + secret: NEXTAUTH_SECRET, + session: { + strategy: "jwt", + }, + callbacks: { + async jwt({ token, account }) { + if (account?.access_token) { + token.accessToken = account.access_token; + token.roles = extractRoles(account.access_token); + } + + if (account?.id_token) { + token.idToken = account.id_token; + } + + return token; + }, + async session({ session, token }) { + if (session.user && token.sub) { + session.user.id = token.sub; + session.user.roles = Array.isArray(token.roles) ? token.roles : []; + } + + if (typeof token.accessToken === "string") { + session.accessToken = token.accessToken; + } + + return session; + }, + }, + } satisfies NextAuthOptions; +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts b/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts index 2d5b37c..328c5ee 100644 --- a/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts +++ b/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts @@ -1,6 +1,6 @@ import { getServerSession } from "next-auth"; import { getSession, signIn } from "next-auth/react"; -import { getAuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAuthOptions } from "@/lib/auth/auth-options"; import { ApiError } from "@/lib/utils/api-error"; import { getEnvVars } from "@/config/env-vars"; @@ -84,6 +84,13 @@ export const orvalFetch = async (url: string, options: RequestInit = {}): Pro headers.set("Authorization", `Bearer ${accessToken}`); } + if (typeof window !== "undefined") { + const locale = localStorage.getItem("locale") || document.documentElement.lang; + if (locale) { + headers.set("Accept-Language", locale); + } + } + const response = await fetch(resolveRequestUrl(url), { ...options, headers, diff --git a/CleanArchitecture.Presentation/admin/src/lib/utils/error-handler.ts b/CleanArchitecture.Presentation/admin/src/lib/utils/error-handler.ts index 8abe920..c5edab2 100644 --- a/CleanArchitecture.Presentation/admin/src/lib/utils/error-handler.ts +++ b/CleanArchitecture.Presentation/admin/src/lib/utils/error-handler.ts @@ -80,6 +80,14 @@ export function extractApiErrors(error: unknown): DomainError[] { } if (typeof error.data === "string" && error.data.length > 0) { + try { + const parsedData = JSON.parse(error.data); + if (isProblemDetails(parsedData)) { + return extractErrors(parsedData); + } + } catch (e) { + // Not a JSON string, fall through to return the raw string. + } return [ { code: "RequestError", diff --git a/CleanArchitecture.Presentation/admin/src/lib/validations/book.ts b/CleanArchitecture.Presentation/admin/src/lib/validations/book.ts index d216a1a..fae2079 100644 --- a/CleanArchitecture.Presentation/admin/src/lib/validations/book.ts +++ b/CleanArchitecture.Presentation/admin/src/lib/validations/book.ts @@ -3,13 +3,13 @@ import { z } from "zod"; export const bookSchema = z.object({ title: z .string() - .min(1, { message: "Title is required." }) - .min(3, { message: "Title must be at least 3 characters long." }) - .max(200, { message: "Title cannot exceed 200 characters." }), + .min(1, { message: "title_required" }) + .min(3, { message: "title_min_length" }) + .max(200, { message: "title_max_length" }), genre: z .string() - .min(1, { message: "Genre is required." }) - .max(50, { message: "Genre cannot exceed 50 characters." }), + .min(1, { message: "genre_required" }) + .max(50, { message: "genre_max_length" }), }); export type BookFormValues = z.infer;